@skeletiq/mcp 0.1.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/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/index.js +1674 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
var SERVER_NAME = "skeletiq";
|
|
8
|
+
var SERVER_VERSION = "0.1.0";
|
|
9
|
+
var DEFAULT_API_URL = "https://api.skeletiq.com";
|
|
10
|
+
var TOKEN_PREFIX = "skq_";
|
|
11
|
+
var ConfigError = class extends Error {
|
|
12
|
+
};
|
|
13
|
+
function readConfig(env = process.env) {
|
|
14
|
+
const apiKey = (env.SKELETIQ_API_KEY ?? "").trim();
|
|
15
|
+
if (!apiKey) {
|
|
16
|
+
throw new ConfigError(
|
|
17
|
+
`SKELETIQ_API_KEY is not set. Create a token in SkeletIQ under Settings \u2192 Agent access and put it in this server's env. It needs the "read" scope at minimum; add "generate" to let the agent create designs and "report" to let it check drift.`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
if (!apiKey.startsWith(TOKEN_PREFIX)) {
|
|
21
|
+
throw new ConfigError(
|
|
22
|
+
`SKELETIQ_API_KEY does not look like a SkeletIQ API token (they start with "${TOKEN_PREFIX}"). Personal API tokens are created under Settings \u2192 Agent access.`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return { apiUrl: normalizeApiUrl(env.SKELETIQ_API_URL), apiKey };
|
|
26
|
+
}
|
|
27
|
+
function normalizeApiUrl(raw) {
|
|
28
|
+
const value = (raw ?? "").trim();
|
|
29
|
+
if (!value) return DEFAULT_API_URL;
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(value);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ConfigError(`SKELETIQ_API_URL is not a valid URL: ${value}`);
|
|
35
|
+
}
|
|
36
|
+
const path = url.pathname.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
|
|
37
|
+
return `${url.origin}${path}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/server.ts
|
|
41
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
42
|
+
|
|
43
|
+
// src/http/errors.ts
|
|
44
|
+
var SkeletiqApiError = class extends Error {
|
|
45
|
+
status;
|
|
46
|
+
code;
|
|
47
|
+
detail;
|
|
48
|
+
requestId;
|
|
49
|
+
/** From the `Retry-After` header, in seconds, when the server set one. */
|
|
50
|
+
retryAfterSeconds;
|
|
51
|
+
constructor(args) {
|
|
52
|
+
super(args.message);
|
|
53
|
+
this.name = "SkeletiqApiError";
|
|
54
|
+
this.status = args.status;
|
|
55
|
+
this.code = args.code;
|
|
56
|
+
this.detail = args.detail;
|
|
57
|
+
this.requestId = args.requestId;
|
|
58
|
+
this.retryAfterSeconds = args.retryAfterSeconds;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A `GET` may be retried after a 429; a `POST` that may have spent credits may not.
|
|
62
|
+
*
|
|
63
|
+
* A spent daily allowance is the one 429 that is *not* retryable: it refills on a clock
|
|
64
|
+
* measured in hours, so a caller backing off and trying again is burning its budget on a
|
|
65
|
+
* call that cannot succeed today.
|
|
66
|
+
*/
|
|
67
|
+
get isRetryable() {
|
|
68
|
+
if (this.code === "DAILY_LIMIT_REACHED") return false;
|
|
69
|
+
return this.status === 429 || this.code === "IDEMPOTENCY_UNAVAILABLE";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var SkeletiqNetworkError = class extends Error {
|
|
73
|
+
constructor(message, options) {
|
|
74
|
+
super(message, options);
|
|
75
|
+
this.name = "SkeletiqNetworkError";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
function asRecord(value) {
|
|
79
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
80
|
+
}
|
|
81
|
+
function asString(value) {
|
|
82
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
83
|
+
}
|
|
84
|
+
function parseApiError(status, body, retryAfterSeconds) {
|
|
85
|
+
const envelope = asRecord(asRecord(body)?.error);
|
|
86
|
+
const detailRecord = asRecord(envelope?.detail);
|
|
87
|
+
const detail = detailRecord ?? (Array.isArray(envelope?.detail) ? envelope.detail : void 0);
|
|
88
|
+
const code = asString(detailRecord?.code) ?? asString(envelope?.code) ?? `HTTP_${status}`;
|
|
89
|
+
const message = asString(detailRecord?.message) ?? asString(detailRecord?.error) ?? asString(envelope?.message) ?? `SkeletIQ returned HTTP ${status}.`;
|
|
90
|
+
return new SkeletiqApiError({
|
|
91
|
+
status,
|
|
92
|
+
code,
|
|
93
|
+
message,
|
|
94
|
+
detail,
|
|
95
|
+
requestId: asString(envelope?.request_id),
|
|
96
|
+
retryAfterSeconds
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function describeApiError(error) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
const detail = asRecord(error.detail);
|
|
102
|
+
switch (error.code) {
|
|
103
|
+
case "SESSION_AUTH_REQUIRED":
|
|
104
|
+
lines.push(
|
|
105
|
+
"That operation is not available to API tokens at all \u2014 it needs a signed-in browser session. A wider token will not help; ask the user to do it in the SkeletIQ app."
|
|
106
|
+
);
|
|
107
|
+
break;
|
|
108
|
+
case "INSUFFICIENT_SCOPE": {
|
|
109
|
+
const required = asString(detail?.required_scope);
|
|
110
|
+
lines.push(
|
|
111
|
+
required ? `This API token does not carry the "${required}" scope.` : "This API token is not wide enough for that operation."
|
|
112
|
+
);
|
|
113
|
+
lines.push(
|
|
114
|
+
"Ask the user to mint a new token under Settings \u2192 Agent access with that scope ticked, and update SKELETIQ_API_KEY."
|
|
115
|
+
);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "INSUFFICIENT_CREDITS": {
|
|
119
|
+
lines.push(error.message);
|
|
120
|
+
const balance = detail?.balance;
|
|
121
|
+
const required = detail?.required;
|
|
122
|
+
if (typeof balance === "number" && typeof required === "number") {
|
|
123
|
+
lines.push(`Balance ${balance}, this run needs up to ${required}.`);
|
|
124
|
+
}
|
|
125
|
+
const solutions = detail?.solutions;
|
|
126
|
+
if (Array.isArray(solutions)) {
|
|
127
|
+
for (const solution of solutions) {
|
|
128
|
+
if (typeof solution === "string") lines.push(`- ${solution}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "DAILY_LIMIT_REACHED": {
|
|
134
|
+
lines.push(error.message);
|
|
135
|
+
const resetAt = asString(detail?.reset_at);
|
|
136
|
+
if (resetAt) lines.push(`The allowance refills at ${resetAt}.`);
|
|
137
|
+
const upgradeUrl = asString(detail?.upgrade_url);
|
|
138
|
+
if (upgradeUrl) lines.push(`A larger plan raises the daily limit: ${upgradeUrl}`);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "PLAN_LIMIT_EXCEEDED":
|
|
142
|
+
case "FEATURE_NOT_AVAILABLE":
|
|
143
|
+
case "VERSION_LOCKED": {
|
|
144
|
+
lines.push(error.message);
|
|
145
|
+
const upgradeUrl = asString(detail?.upgrade_url);
|
|
146
|
+
if (upgradeUrl) lines.push(`Upgrade: ${upgradeUrl}`);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "BACKGROUND_JOBS_UNAVAILABLE":
|
|
150
|
+
lines.push(
|
|
151
|
+
"This SkeletIQ deployment runs no background worker, so a generation cannot be queued. Call generate_architecture again with wait: true and it will run inline."
|
|
152
|
+
);
|
|
153
|
+
break;
|
|
154
|
+
case "IDEMPOTENCY_UNAVAILABLE":
|
|
155
|
+
lines.push(
|
|
156
|
+
"SkeletIQ could not confirm this was not a duplicate request, so it refused it. Nothing was started and nothing was charged \u2014 retrying is safe."
|
|
157
|
+
);
|
|
158
|
+
break;
|
|
159
|
+
case "JOB_QUEUE_FULL": {
|
|
160
|
+
const wait = detail?.retry_after_seconds;
|
|
161
|
+
lines.push("SkeletIQ's generation queue is full.");
|
|
162
|
+
if (typeof wait === "number") lines.push(`Try again in about ${wait} seconds.`);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "ACTIVE_GENERATION_EXISTS":
|
|
166
|
+
lines.push(
|
|
167
|
+
"A generation is already running for this account. Wait for it to finish \u2014 get_generation_status will tell you when \u2014 before starting another."
|
|
168
|
+
);
|
|
169
|
+
break;
|
|
170
|
+
case "RATE_LIMITED":
|
|
171
|
+
lines.push(error.message);
|
|
172
|
+
if (error.retryAfterSeconds !== void 0) {
|
|
173
|
+
lines.push(`Retry after ${error.retryAfterSeconds} seconds.`);
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
default:
|
|
177
|
+
if (error.status === 401) {
|
|
178
|
+
lines.push(
|
|
179
|
+
"SkeletIQ rejected the API token. It may be wrong, revoked or expired \u2014 SkeletIQ answers all three identically, so there is nothing more specific to report. Check SKELETIQ_API_KEY, or mint a new token under Settings \u2192 Agent access."
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
lines.push(error.message);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (error.requestId) lines.push(`(SkeletIQ request id: ${error.requestId})`);
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/http/client.ts
|
|
190
|
+
var API_PREFIX = "/api/v1";
|
|
191
|
+
var READ_TIMEOUT_MS = 6e5;
|
|
192
|
+
var SkeletiqClient = class {
|
|
193
|
+
config;
|
|
194
|
+
constructor(config) {
|
|
195
|
+
this.config = config;
|
|
196
|
+
}
|
|
197
|
+
/** A parsed JSON response, or a thrown `SkeletiqApiError` carrying the server's own words. */
|
|
198
|
+
async request(path, options = {}) {
|
|
199
|
+
const response = await this.fetch(path, options);
|
|
200
|
+
const text = await response.text();
|
|
201
|
+
return text ? JSON.parse(text) : void 0;
|
|
202
|
+
}
|
|
203
|
+
/** The raw `Response`, for the one caller that reads a stream rather than a body. */
|
|
204
|
+
async fetch(path, options = {}) {
|
|
205
|
+
const url = this.url(path, options.query);
|
|
206
|
+
const timeout = AbortSignal.timeout(READ_TIMEOUT_MS);
|
|
207
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
208
|
+
let response;
|
|
209
|
+
try {
|
|
210
|
+
response = await fetch(url, {
|
|
211
|
+
method: options.method ?? "GET",
|
|
212
|
+
headers: {
|
|
213
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
214
|
+
Accept: "application/json",
|
|
215
|
+
"User-Agent": `${SERVER_NAME}-mcp/${SERVER_VERSION}`,
|
|
216
|
+
...options.body === void 0 ? {} : { "Content-Type": "application/json" },
|
|
217
|
+
...options.headers
|
|
218
|
+
},
|
|
219
|
+
body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
|
|
220
|
+
signal
|
|
221
|
+
});
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
if (options.signal?.aborted) throw cause;
|
|
224
|
+
throw new SkeletiqNetworkError(`Could not reach SkeletIQ at ${this.config.apiUrl}.`, { cause });
|
|
225
|
+
}
|
|
226
|
+
if (!response.ok) throw await this.toError(response);
|
|
227
|
+
return response;
|
|
228
|
+
}
|
|
229
|
+
url(path, query) {
|
|
230
|
+
const url = new URL(`${this.config.apiUrl}${API_PREFIX}${path}`);
|
|
231
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
232
|
+
if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
|
|
233
|
+
}
|
|
234
|
+
return url.toString();
|
|
235
|
+
}
|
|
236
|
+
async toError(response) {
|
|
237
|
+
let body;
|
|
238
|
+
try {
|
|
239
|
+
body = JSON.parse(await response.text());
|
|
240
|
+
} catch {
|
|
241
|
+
body = void 0;
|
|
242
|
+
}
|
|
243
|
+
return parseApiError(response.status, body, parseRetryAfter(response));
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
function parseRetryAfter(response) {
|
|
247
|
+
const header = response.headers.get("retry-after");
|
|
248
|
+
if (!header) return void 0;
|
|
249
|
+
const seconds = Number.parseInt(header, 10);
|
|
250
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : void 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/wire/schemas.ts
|
|
254
|
+
import * as z from "zod/v4";
|
|
255
|
+
var ReleaseFactsSchema = z.looseObject({
|
|
256
|
+
is_released: z.boolean(),
|
|
257
|
+
released_at: z.string().nullable().optional(),
|
|
258
|
+
latest_release_version: z.number().nullable().optional(),
|
|
259
|
+
newer_release_exists: z.boolean(),
|
|
260
|
+
newer_draft_exists: z.boolean()
|
|
261
|
+
});
|
|
262
|
+
var ProjectSchema = z.looseObject({
|
|
263
|
+
id: z.string(),
|
|
264
|
+
title: z.string(),
|
|
265
|
+
description: z.string().nullable().optional(),
|
|
266
|
+
tags: z.array(z.string()).nullable().optional(),
|
|
267
|
+
team_id: z.string().nullable().optional(),
|
|
268
|
+
architecture_count: z.number().optional(),
|
|
269
|
+
created_at: z.string().optional(),
|
|
270
|
+
updated_at: z.string().optional()
|
|
271
|
+
});
|
|
272
|
+
var ProjectListSchema = z.looseObject({
|
|
273
|
+
projects: z.array(ProjectSchema),
|
|
274
|
+
total: z.number(),
|
|
275
|
+
page: z.number().optional(),
|
|
276
|
+
page_size: z.number().optional()
|
|
277
|
+
});
|
|
278
|
+
var ComponentSchema = z.looseObject({
|
|
279
|
+
id: z.string(),
|
|
280
|
+
name: z.string(),
|
|
281
|
+
type: z.string(),
|
|
282
|
+
description: z.string().nullable().optional(),
|
|
283
|
+
technology: z.string().nullable().optional(),
|
|
284
|
+
role: z.string().nullable().optional(),
|
|
285
|
+
/** Why the component is in the design (`core`, `security`, …) — see the API's `component_concerns`. */
|
|
286
|
+
concern: z.string().nullable().optional(),
|
|
287
|
+
requirement_ids: z.array(z.string()).nullable().optional()
|
|
288
|
+
});
|
|
289
|
+
var ConnectionSchema = z.looseObject({
|
|
290
|
+
id: z.string().nullable().optional(),
|
|
291
|
+
source: z.string(),
|
|
292
|
+
target: z.string(),
|
|
293
|
+
label: z.string().nullable().optional(),
|
|
294
|
+
protocol: z.string().nullable().optional(),
|
|
295
|
+
is_async: z.boolean().optional()
|
|
296
|
+
});
|
|
297
|
+
var GapEntrySchema = z.union([
|
|
298
|
+
z.string(),
|
|
299
|
+
z.looseObject({
|
|
300
|
+
text: z.string(),
|
|
301
|
+
recommendation: z.string().nullable().optional(),
|
|
302
|
+
options: z.array(z.string()).nullable().optional(),
|
|
303
|
+
impact: z.string().nullable().optional()
|
|
304
|
+
})
|
|
305
|
+
]);
|
|
306
|
+
var ArchitectureJsonSchema = z.looseObject({
|
|
307
|
+
title: z.string().optional(),
|
|
308
|
+
description: z.string().optional(),
|
|
309
|
+
components: z.array(ComponentSchema).optional(),
|
|
310
|
+
connections: z.array(ConnectionSchema).optional(),
|
|
311
|
+
design_decisions: z.array(z.string()).nullable().optional(),
|
|
312
|
+
trade_offs: z.array(z.string()).nullable().optional(),
|
|
313
|
+
scalability_notes: z.string().nullable().optional(),
|
|
314
|
+
assumptions: z.array(GapEntrySchema).nullable().optional(),
|
|
315
|
+
open_questions: z.array(GapEntrySchema).nullable().optional()
|
|
316
|
+
});
|
|
317
|
+
var ArchitectureSchema = z.looseObject({
|
|
318
|
+
id: z.string(),
|
|
319
|
+
project_id: z.string(),
|
|
320
|
+
prompt: z.string().optional(),
|
|
321
|
+
architecture_json: ArchitectureJsonSchema,
|
|
322
|
+
version: z.number(),
|
|
323
|
+
status: z.string().optional(),
|
|
324
|
+
created_at: z.string().optional(),
|
|
325
|
+
updated_at: z.string().optional(),
|
|
326
|
+
is_released: z.boolean().optional(),
|
|
327
|
+
released_at: z.string().nullable().optional()
|
|
328
|
+
});
|
|
329
|
+
var ArchitecturePageSchema = z.looseObject({
|
|
330
|
+
items: z.array(ArchitectureSchema),
|
|
331
|
+
total: z.number(),
|
|
332
|
+
limit: z.number().optional(),
|
|
333
|
+
offset: z.number().optional()
|
|
334
|
+
});
|
|
335
|
+
var BriefSchema = z.looseObject({
|
|
336
|
+
architecture_id: z.string(),
|
|
337
|
+
version: z.number(),
|
|
338
|
+
is_draft: z.boolean(),
|
|
339
|
+
markdown: z.string(),
|
|
340
|
+
release: ReleaseFactsSchema
|
|
341
|
+
});
|
|
342
|
+
var BuildStepSchema = z.looseObject({
|
|
343
|
+
order: z.number(),
|
|
344
|
+
component_id: z.string(),
|
|
345
|
+
name: z.string(),
|
|
346
|
+
type: z.string(),
|
|
347
|
+
role: z.string().nullable().optional(),
|
|
348
|
+
technology: z.string().nullable().optional(),
|
|
349
|
+
reason: z.string(),
|
|
350
|
+
depends_on: z.array(z.string()).optional(),
|
|
351
|
+
/** The `depends_on` entries built *after* this step — non-empty only where the design cycles. */
|
|
352
|
+
blocked_by: z.array(z.string()).optional()
|
|
353
|
+
});
|
|
354
|
+
var BuildOrderSchema = z.looseObject({
|
|
355
|
+
architecture_id: z.string(),
|
|
356
|
+
version: z.number(),
|
|
357
|
+
steps: z.array(BuildStepSchema),
|
|
358
|
+
release: ReleaseFactsSchema
|
|
359
|
+
});
|
|
360
|
+
var ReadinessRowSchema = z.looseObject({
|
|
361
|
+
key: z.string(),
|
|
362
|
+
label: z.string(),
|
|
363
|
+
count: z.number(),
|
|
364
|
+
// Three states, not two. `unknown` is a *settled* answer meaning the check never ran — never
|
|
365
|
+
// render it as a zero, which would claim a check passed that was never performed.
|
|
366
|
+
state: z.string(),
|
|
367
|
+
category: z.string(),
|
|
368
|
+
gating: z.boolean(),
|
|
369
|
+
detail: z.string().nullable().optional()
|
|
370
|
+
});
|
|
371
|
+
var ReadinessSchema = z.looseObject({
|
|
372
|
+
architecture_id: z.string(),
|
|
373
|
+
version: z.number(),
|
|
374
|
+
/** `verdict === 'ready'`. Kept for callers that only want the boolean. */
|
|
375
|
+
ready: z.boolean(),
|
|
376
|
+
/**
|
|
377
|
+
* Three values, not two. `unanswered` means every gate is clear but at least one
|
|
378
|
+
* could not be answered for this version — a state that reports `ready: false`
|
|
379
|
+
* while no row is `gating`, so a renderer keyed on `gating` alone says "0 of 6
|
|
380
|
+
* gates still open" and means nothing by it.
|
|
381
|
+
*
|
|
382
|
+
* Optional so a server that predates the field parses rather than throwing; the
|
|
383
|
+
* caller falls back to the boolean.
|
|
384
|
+
*/
|
|
385
|
+
verdict: z.enum(["ready", "outstanding", "unanswered"]).optional(),
|
|
386
|
+
unknown_gate_count: z.number().optional(),
|
|
387
|
+
/** Keys of advisory rows whose check never ran. Named, never counted. */
|
|
388
|
+
unrun_checks: z.array(z.string()).optional(),
|
|
389
|
+
rows: z.array(ReadinessRowSchema),
|
|
390
|
+
release: ReleaseFactsSchema
|
|
391
|
+
});
|
|
392
|
+
var DesignGapSchema = z.looseObject({
|
|
393
|
+
gap_id: z.string(),
|
|
394
|
+
kind: z.string(),
|
|
395
|
+
text: z.string(),
|
|
396
|
+
resolved: z.boolean(),
|
|
397
|
+
action: z.string().nullable().optional(),
|
|
398
|
+
note: z.string().nullable().optional(),
|
|
399
|
+
adr_id: z.string().nullable().optional()
|
|
400
|
+
});
|
|
401
|
+
var DesignGapListSchema = z.looseObject({
|
|
402
|
+
project_id: z.string(),
|
|
403
|
+
architecture_id: z.string().nullable().optional(),
|
|
404
|
+
version: z.number().nullable().optional(),
|
|
405
|
+
gaps: z.array(DesignGapSchema),
|
|
406
|
+
unresolved_count: z.number()
|
|
407
|
+
});
|
|
408
|
+
var ComponentRefSchema = z.looseObject({
|
|
409
|
+
id: z.string(),
|
|
410
|
+
name: z.string(),
|
|
411
|
+
type: z.string()
|
|
412
|
+
});
|
|
413
|
+
var UnknownIdSchema = z.looseObject({
|
|
414
|
+
id: z.string(),
|
|
415
|
+
suggestions: z.array(ComponentRefSchema).optional()
|
|
416
|
+
});
|
|
417
|
+
var DriftSchema = z.looseObject({
|
|
418
|
+
architecture_id: z.string(),
|
|
419
|
+
version: z.number(),
|
|
420
|
+
in_sync: z.boolean(),
|
|
421
|
+
missing: z.array(ComponentRefSchema),
|
|
422
|
+
unreported: z.array(ComponentRefSchema),
|
|
423
|
+
partial: z.array(ComponentRefSchema),
|
|
424
|
+
elsewhere: z.array(ComponentRefSchema),
|
|
425
|
+
unknown_ids: z.array(UnknownIdSchema),
|
|
426
|
+
extra_components: z.array(z.looseObject({ name: z.string() })),
|
|
427
|
+
extra_connections: z.array(z.looseObject({ source: z.string(), target: z.string() })),
|
|
428
|
+
counts: z.record(z.string(), z.number()),
|
|
429
|
+
release: ReleaseFactsSchema
|
|
430
|
+
});
|
|
431
|
+
var CritiqueFindingSchema = z.looseObject({
|
|
432
|
+
message: z.string(),
|
|
433
|
+
pattern_id: z.string(),
|
|
434
|
+
category: z.string(),
|
|
435
|
+
severity: z.string().optional(),
|
|
436
|
+
affected_components: z.array(z.string()).optional(),
|
|
437
|
+
evidence: z.string().optional(),
|
|
438
|
+
remediation: z.string().optional()
|
|
439
|
+
});
|
|
440
|
+
var PayloadCritiqueSchema = z.looseObject({
|
|
441
|
+
architecture_score: z.number(),
|
|
442
|
+
security_score: z.number(),
|
|
443
|
+
performance_score: z.number(),
|
|
444
|
+
resilience_score: z.number(),
|
|
445
|
+
data_score: z.number(),
|
|
446
|
+
findings: z.array(CritiqueFindingSchema),
|
|
447
|
+
bottlenecks: z.array(z.string()).optional(),
|
|
448
|
+
risks: z.array(z.string()).optional(),
|
|
449
|
+
recommendations: z.array(z.string()).optional(),
|
|
450
|
+
strengths: z.array(z.string()).optional(),
|
|
451
|
+
compliance_assessed: z.boolean().optional(),
|
|
452
|
+
frameworks_checked: z.array(z.string()).optional(),
|
|
453
|
+
compliance_note: z.string().nullable().optional(),
|
|
454
|
+
exposure_assessed: z.string().optional()
|
|
455
|
+
});
|
|
456
|
+
var ClarifyingQuestionSchema = z.looseObject({
|
|
457
|
+
id: z.string(),
|
|
458
|
+
question: z.string(),
|
|
459
|
+
why: z.string().optional(),
|
|
460
|
+
kind: z.string().optional(),
|
|
461
|
+
options: z.array(z.string()).nullable().optional(),
|
|
462
|
+
decline_options: z.array(z.string()).nullable().optional()
|
|
463
|
+
});
|
|
464
|
+
var IntentDecisionSchema = z.looseObject({
|
|
465
|
+
intent: z.string().optional(),
|
|
466
|
+
reason: z.string().optional(),
|
|
467
|
+
new_project_recommended: z.boolean().optional(),
|
|
468
|
+
clarifying_questions: z.array(ClarifyingQuestionSchema).nullable().optional()
|
|
469
|
+
});
|
|
470
|
+
var GenerateResponseSchema = z.looseObject({
|
|
471
|
+
response_mode: z.string().optional(),
|
|
472
|
+
model_used: z.string().optional(),
|
|
473
|
+
conversation_id: z.string().optional(),
|
|
474
|
+
architecture: ArchitectureJsonSchema.nullable().optional(),
|
|
475
|
+
architecture_id: z.string().nullable().optional(),
|
|
476
|
+
architecture_version: z.number().nullable().optional(),
|
|
477
|
+
assistant_message: z.string().nullable().optional(),
|
|
478
|
+
degradations: z.array(z.string()).optional()
|
|
479
|
+
});
|
|
480
|
+
var DecisionRefusalSchema = z.looseObject({
|
|
481
|
+
kind: z.literal("decision_refusal"),
|
|
482
|
+
code: z.string(),
|
|
483
|
+
response_mode: z.string().optional(),
|
|
484
|
+
intent: z.string().optional(),
|
|
485
|
+
new_project_recommended: z.boolean().optional(),
|
|
486
|
+
clarifying_questions: z.array(ClarifyingQuestionSchema).optional()
|
|
487
|
+
});
|
|
488
|
+
var JobProgressSchema = z.looseObject({
|
|
489
|
+
data: z.looseObject({ failure_detail: z.unknown().optional() }).optional()
|
|
490
|
+
});
|
|
491
|
+
var JobSchema = z.looseObject({
|
|
492
|
+
job_id: z.string(),
|
|
493
|
+
status: z.string(),
|
|
494
|
+
project_id: z.string().nullable().optional(),
|
|
495
|
+
architecture_id: z.string().nullable().optional(),
|
|
496
|
+
conversation_id: z.string().nullable().optional(),
|
|
497
|
+
prompt: z.string().nullable().optional(),
|
|
498
|
+
created_at: z.string().nullable().optional(),
|
|
499
|
+
started_at: z.string().nullable().optional(),
|
|
500
|
+
completed_at: z.string().nullable().optional(),
|
|
501
|
+
error: z.string().nullable().optional(),
|
|
502
|
+
progress: z.unknown().optional(),
|
|
503
|
+
queue_state: z.string().optional()
|
|
504
|
+
});
|
|
505
|
+
var GenerateAsyncSchema = z.looseObject({
|
|
506
|
+
job_id: z.string(),
|
|
507
|
+
status: z.string(),
|
|
508
|
+
job: JobSchema.optional()
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// src/lib/resolve.ts
|
|
512
|
+
var AmbiguousProjectError = class extends Error {
|
|
513
|
+
candidates;
|
|
514
|
+
constructor(query, candidates) {
|
|
515
|
+
super(`"${query}" matches ${candidates.length} SkeletIQ projects.`);
|
|
516
|
+
this.name = "AmbiguousProjectError";
|
|
517
|
+
this.candidates = candidates;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
var PROJECT_PAGE_SIZE = 50;
|
|
521
|
+
var ARCHITECTURE_PAGE_SIZE = 100;
|
|
522
|
+
var DesignResolver = class {
|
|
523
|
+
client;
|
|
524
|
+
versions = /* @__PURE__ */ new Map();
|
|
525
|
+
designs = /* @__PURE__ */ new Map();
|
|
526
|
+
constructor(client) {
|
|
527
|
+
this.client = client;
|
|
528
|
+
}
|
|
529
|
+
async listProjects(query) {
|
|
530
|
+
const body = await this.client.request("/projects/", {
|
|
531
|
+
query: { page_size: PROJECT_PAGE_SIZE, search: query }
|
|
532
|
+
});
|
|
533
|
+
return ProjectListSchema.parse(body).projects;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* A project id, or the candidates that stopped us being sure.
|
|
537
|
+
*
|
|
538
|
+
* An exact (case-insensitive) title match settles it even when other titles contain the same
|
|
539
|
+
* words — "Payments" should not be ambiguous just because "Payments v2" exists.
|
|
540
|
+
*/
|
|
541
|
+
async resolveProject(idOrQuery) {
|
|
542
|
+
if (isUuid(idOrQuery)) {
|
|
543
|
+
const body = await this.client.request(`/projects/${idOrQuery}`);
|
|
544
|
+
return ProjectListSchema.shape.projects.element.parse(body);
|
|
545
|
+
}
|
|
546
|
+
const candidates = await this.listProjects(idOrQuery);
|
|
547
|
+
if (candidates.length === 0) throw new Error(`No SkeletIQ project matches "${idOrQuery}".`);
|
|
548
|
+
if (candidates.length === 1) return candidates[0];
|
|
549
|
+
const exact = candidates.filter((p) => p.title.toLowerCase() === idOrQuery.toLowerCase());
|
|
550
|
+
if (exact.length === 1) return exact[0];
|
|
551
|
+
throw new AmbiguousProjectError(idOrQuery, candidates);
|
|
552
|
+
}
|
|
553
|
+
/** Every version of a project, newest first. Cached: one call serves a whole session. */
|
|
554
|
+
async versionsFor(projectId) {
|
|
555
|
+
const cached = this.versions.get(projectId);
|
|
556
|
+
if (cached) return cached;
|
|
557
|
+
const body = await this.client.request(`/projects/${projectId}/architectures`, {
|
|
558
|
+
query: { limit: ARCHITECTURE_PAGE_SIZE, offset: 0 }
|
|
559
|
+
});
|
|
560
|
+
const items = [...ArchitecturePageSchema.parse(body).items].sort((a, b) => b.version - a.version);
|
|
561
|
+
this.versions.set(projectId, items);
|
|
562
|
+
for (const item of items) this.designs.set(item.id, item);
|
|
563
|
+
return items;
|
|
564
|
+
}
|
|
565
|
+
/** The version rule. */
|
|
566
|
+
async resolveVersion(projectId, requested) {
|
|
567
|
+
const versions = await this.versionsFor(projectId);
|
|
568
|
+
if (versions.length === 0) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
"That SkeletIQ project has no architecture versions yet. Run generate_architecture against it first."
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const latestReleaseVersion = versions.find((v) => v.is_released)?.version;
|
|
574
|
+
let chosen;
|
|
575
|
+
let resolvedBy;
|
|
576
|
+
if (requested !== void 0) {
|
|
577
|
+
chosen = versions.find((v) => v.version === requested);
|
|
578
|
+
resolvedBy = "requested";
|
|
579
|
+
if (!chosen) {
|
|
580
|
+
const available = versions.map((v) => v.version).reverse().join(", ");
|
|
581
|
+
throw new Error(
|
|
582
|
+
`That SkeletIQ project has no version ${requested}. Available versions: ${available}. (On the free plan older unreleased versions are outside the readable window.)`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
} else if (latestReleaseVersion !== void 0) {
|
|
586
|
+
chosen = versions.find((v) => v.version === latestReleaseVersion);
|
|
587
|
+
resolvedBy = "latest_release";
|
|
588
|
+
} else {
|
|
589
|
+
chosen = versions[0];
|
|
590
|
+
resolvedBy = "latest_version";
|
|
591
|
+
}
|
|
592
|
+
const target = chosen;
|
|
593
|
+
return {
|
|
594
|
+
architectureId: target.id,
|
|
595
|
+
version: target.version,
|
|
596
|
+
isReleased: target.is_released === true,
|
|
597
|
+
newerReleaseExists: latestReleaseVersion !== void 0 && latestReleaseVersion > target.version,
|
|
598
|
+
resolvedBy
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The full design for one version.
|
|
603
|
+
*
|
|
604
|
+
* The list response already carries `architecture_json`, so the cache seeded by
|
|
605
|
+
* `versionsFor` usually answers this without a second request.
|
|
606
|
+
*/
|
|
607
|
+
async design(architectureId) {
|
|
608
|
+
const cached = this.designs.get(architectureId);
|
|
609
|
+
if (cached) return cached;
|
|
610
|
+
const body = await this.client.request(`/architectures/${architectureId}`);
|
|
611
|
+
const design = ArchitectureSchema.parse(body);
|
|
612
|
+
this.designs.set(architectureId, design);
|
|
613
|
+
return design;
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
function factsFrom(resolved) {
|
|
617
|
+
return {
|
|
618
|
+
version: resolved.version,
|
|
619
|
+
is_released: resolved.isReleased,
|
|
620
|
+
newer_release_exists: resolved.newerReleaseExists,
|
|
621
|
+
resolved_by: resolved.resolvedBy
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function factsFromRelease(resolved, release) {
|
|
625
|
+
return {
|
|
626
|
+
version: resolved.version,
|
|
627
|
+
is_released: release.is_released,
|
|
628
|
+
newer_release_exists: release.newer_release_exists,
|
|
629
|
+
resolved_by: resolved.resolvedBy
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
633
|
+
function isUuid(value) {
|
|
634
|
+
return UUID.test(value.trim());
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/tools/check-drift.ts
|
|
638
|
+
import * as z2 from "zod/v4";
|
|
639
|
+
|
|
640
|
+
// src/lib/result.ts
|
|
641
|
+
function ok(structured, text) {
|
|
642
|
+
return { content: [{ type: "text", text }], structuredContent: structured };
|
|
643
|
+
}
|
|
644
|
+
function failure(text) {
|
|
645
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
646
|
+
}
|
|
647
|
+
async function guard(run) {
|
|
648
|
+
try {
|
|
649
|
+
return await run();
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (error instanceof SkeletiqApiError) return failure(describeApiError(error));
|
|
652
|
+
if (error instanceof SkeletiqNetworkError) return failure(error.message);
|
|
653
|
+
if (error instanceof AmbiguousProjectError) {
|
|
654
|
+
const lines = [
|
|
655
|
+
error.message,
|
|
656
|
+
"Ask which one, and call again with the id:",
|
|
657
|
+
...error.candidates.map((project) => `- ${project.title} (${project.id})`)
|
|
658
|
+
];
|
|
659
|
+
return failure(lines.join("\n"));
|
|
660
|
+
}
|
|
661
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function versionLine(facts) {
|
|
665
|
+
const state = facts.is_released ? "released" : "DRAFT \u2014 not released, and may change without notice";
|
|
666
|
+
const line = `Version ${facts.version} (${state}).`;
|
|
667
|
+
return facts.newer_release_exists ? `${line} A newer version has since been released \u2014 re-read the design before relying on this.` : line;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/tools/check-drift.ts
|
|
671
|
+
var input = z2.object({
|
|
672
|
+
project_id: z2.string().describe("A SkeletIQ project id, or its exact name."),
|
|
673
|
+
version: z2.number().int().positive().optional().describe(
|
|
674
|
+
"The version you built from \u2014 the `version=` stamped in the AGENTS.md fence. Defaults to the latest released version. Component ids are only meaningful within one version."
|
|
675
|
+
),
|
|
676
|
+
covers: z2.array(z2.string()).max(500).optional().describe(
|
|
677
|
+
"The component ids this repository is responsible for. Omit only when this repo implements the whole design \u2014 otherwise everything you leave out is reported as missing."
|
|
678
|
+
),
|
|
679
|
+
components: z2.array(
|
|
680
|
+
z2.object({
|
|
681
|
+
id: z2.string().max(200).describe("The component id from the design."),
|
|
682
|
+
name: z2.string().max(255).optional().describe("The component name, which survives a regeneration when the id does not."),
|
|
683
|
+
status: z2.enum(["implemented", "partial", "not_started"]),
|
|
684
|
+
note: z2.string().max(2e3).optional()
|
|
685
|
+
})
|
|
686
|
+
).max(500).describe("What you built. Report every component in `covers`, including the ones not started."),
|
|
687
|
+
extra_components: z2.array(
|
|
688
|
+
z2.object({
|
|
689
|
+
name: z2.string().max(255),
|
|
690
|
+
type: z2.string().max(64).optional(),
|
|
691
|
+
technology: z2.string().max(255).optional()
|
|
692
|
+
})
|
|
693
|
+
).max(200).optional().describe("Things in the code that the design does not contain."),
|
|
694
|
+
extra_connections: z2.array(
|
|
695
|
+
z2.object({
|
|
696
|
+
source: z2.string().max(255),
|
|
697
|
+
target: z2.string().max(255),
|
|
698
|
+
protocol: z2.string().max(64).optional()
|
|
699
|
+
})
|
|
700
|
+
).max(500).optional().describe("Edges in the code the design does not have. Endpoints may be component ids or names.")
|
|
701
|
+
});
|
|
702
|
+
var refs = z2.array(z2.object({ id: z2.string(), name: z2.string(), type: z2.string() }));
|
|
703
|
+
var output = z2.object({
|
|
704
|
+
architecture_id: z2.string(),
|
|
705
|
+
version: z2.number(),
|
|
706
|
+
is_released: z2.boolean(),
|
|
707
|
+
newer_release_exists: z2.boolean(),
|
|
708
|
+
resolved_by: z2.string(),
|
|
709
|
+
in_sync: z2.boolean(),
|
|
710
|
+
missing: refs,
|
|
711
|
+
unreported: refs,
|
|
712
|
+
partial: refs,
|
|
713
|
+
elsewhere: refs,
|
|
714
|
+
unknown_ids: z2.array(z2.object({ id: z2.string(), suggestions: refs })),
|
|
715
|
+
counts: z2.record(z2.string(), z2.number())
|
|
716
|
+
});
|
|
717
|
+
function registerCheckDrift(server, client, resolver) {
|
|
718
|
+
server.registerTool(
|
|
719
|
+
"check_drift",
|
|
720
|
+
{
|
|
721
|
+
title: "Report build progress and check drift",
|
|
722
|
+
description: "Tell SkeletIQ which components you have built and get back what is missing, what is only half done, and what exists in the code but not in the design. Declare `covers` when this repository implements only part of the design. This changes nothing in SkeletIQ \u2014 an agent never writes back to a design.",
|
|
723
|
+
inputSchema: input,
|
|
724
|
+
outputSchema: output,
|
|
725
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
726
|
+
},
|
|
727
|
+
async ({ project_id, version, covers, components, extra_components, extra_connections }) => guard(async () => {
|
|
728
|
+
const project = await resolver.resolveProject(project_id);
|
|
729
|
+
const resolved = await resolver.resolveVersion(project.id, version);
|
|
730
|
+
const report = DriftSchema.parse(
|
|
731
|
+
await client.request(`/architectures/${resolved.architectureId}/drift-check`, {
|
|
732
|
+
method: "POST",
|
|
733
|
+
body: {
|
|
734
|
+
covers: covers ?? null,
|
|
735
|
+
components,
|
|
736
|
+
extra_components: extra_components ?? null,
|
|
737
|
+
extra_connections: extra_connections ?? null
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
);
|
|
741
|
+
const facts = factsFromRelease(resolved, report.release);
|
|
742
|
+
const structured = {
|
|
743
|
+
architecture_id: report.architecture_id,
|
|
744
|
+
...facts,
|
|
745
|
+
in_sync: report.in_sync,
|
|
746
|
+
missing: report.missing,
|
|
747
|
+
unreported: report.unreported,
|
|
748
|
+
partial: report.partial,
|
|
749
|
+
elsewhere: report.elsewhere,
|
|
750
|
+
unknown_ids: report.unknown_ids.map((entry) => ({
|
|
751
|
+
id: entry.id,
|
|
752
|
+
suggestions: entry.suggestions ?? []
|
|
753
|
+
})),
|
|
754
|
+
counts: report.counts
|
|
755
|
+
};
|
|
756
|
+
const lines = [];
|
|
757
|
+
lines.push(
|
|
758
|
+
report.in_sync ? "In sync \u2014 everything this repository covers is built as designed." : "Drift found:"
|
|
759
|
+
);
|
|
760
|
+
push(lines, "Not started", report.missing, report.counts.missing);
|
|
761
|
+
push(lines, "Half built", report.partial, report.counts.partial);
|
|
762
|
+
push(lines, "You did not mention", report.unreported, report.counts.unreported);
|
|
763
|
+
if (report.extra_components.length) {
|
|
764
|
+
lines.push(`In the code but not the design: ${report.extra_components.map((c) => c.name).join(", ")}`);
|
|
765
|
+
}
|
|
766
|
+
if (report.extra_connections.length) {
|
|
767
|
+
lines.push(
|
|
768
|
+
`Connections the design does not have: ${report.extra_connections.map((c) => `${c.source} \u2192 ${c.target}`).join(", ")}`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
if (report.unknown_ids.length) {
|
|
772
|
+
lines.push("");
|
|
773
|
+
lines.push(
|
|
774
|
+
`${report.unknown_ids.length} id(s) are not in version ${report.version}. Ids are minted per version, so a regeneration voids them. These are suggestions \u2014 confirm with a person before treating any of them as the same component:`
|
|
775
|
+
);
|
|
776
|
+
for (const entry of report.unknown_ids) {
|
|
777
|
+
const suggestions = (entry.suggestions ?? []).map((s) => `${s.name} (${s.id})`).join(", ");
|
|
778
|
+
lines.push(`- ${entry.id} \u2192 ${suggestions || "nothing similar in this version"}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (report.elsewhere.length) {
|
|
782
|
+
lines.push("");
|
|
783
|
+
lines.push(
|
|
784
|
+
`${report.elsewhere.length} component(s) are outside this repository's declared scope. That is not drift \u2014 they are built elsewhere, or not yet.`
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
lines.push("");
|
|
788
|
+
lines.push(versionLine(facts));
|
|
789
|
+
return ok(structured, lines.join("\n"));
|
|
790
|
+
})
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
function push(lines, label, refs2, total) {
|
|
794
|
+
if (refs2.length === 0) return;
|
|
795
|
+
const shown = refs2.map((ref) => `${ref.name} (${ref.id})`).join(", ");
|
|
796
|
+
const more = total !== void 0 && total > refs2.length ? ` \u2014 and ${total - refs2.length} more` : "";
|
|
797
|
+
lines.push(`${label} (${total ?? refs2.length}): ${shown}${more}`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/tools/critique.ts
|
|
801
|
+
import * as z3 from "zod/v4";
|
|
802
|
+
var input2 = z3.object({
|
|
803
|
+
architecture_json: z3.looseObject({
|
|
804
|
+
title: z3.string(),
|
|
805
|
+
description: z3.string(),
|
|
806
|
+
components: z3.array(z3.looseObject({ id: z3.string(), name: z3.string(), type: z3.string() })),
|
|
807
|
+
connections: z3.array(z3.looseObject({ source: z3.string(), target: z3.string() }))
|
|
808
|
+
}).optional().describe(
|
|
809
|
+
"A design to check. Free and deterministic \u2014 no model call. The shape SkeletIQ uses: title, description, components[{id,name,type,technology?}], connections[{source,target,protocol?}]."
|
|
810
|
+
),
|
|
811
|
+
domain: z3.string().optional().describe(
|
|
812
|
+
"The design's domain \u2014 e-commerce, fintech, healthcare, saas, social, iot, gaming, infrastructure, streaming, logistics, ai-ml, search, data-analytics, content-platform, internal-tool. Worth sending: the domain is what selects the compliance frameworks to check against, and without it none apply, so no compliance finding is possible and the score comes back higher than the SkeletIQ app would show for the same design \u2014 by up to 15 points."
|
|
813
|
+
),
|
|
814
|
+
secondary_domains: z3.array(z3.string()).max(8).optional().describe(
|
|
815
|
+
"Further domains the design spans, when it spans more than one \u2014 a multi-tenant shop that takes payments is e-commerce plus fintech and saas. These select frameworks on top of the primary domain's, so leaving them off is why a design already checked against SOC2 and SOX in the app comes back here checked against neither, and scored higher for it. frameworks_checked says which were actually used."
|
|
816
|
+
),
|
|
817
|
+
exposure: z3.enum(["public_internet", "private_network", "air_gapped"]).optional().describe(
|
|
818
|
+
"Where the design runs. Worth sending: four checks \u2014 CDN, WAF, rate limiting and multi-region \u2014 ask whether traffic arriving from the public internet is handled safely, and they only apply to a system that takes any. Left unset, the design is assessed as internet-facing, which is why an air-gapped design comes back told to add a CDN. exposure_assessed says which exposure was actually used."
|
|
819
|
+
)
|
|
820
|
+
});
|
|
821
|
+
var SCORE_BASIS_FINDINGS_ONLY = "findings_only";
|
|
822
|
+
var SCORE_BASIS_NOTE = "This score is findings only. It is not held to a traceability ceiling, because this tool is given a design and no requirement set to trace it against. The app can show a lower number for the same stored design for exactly that reason.";
|
|
823
|
+
var output2 = z3.object({
|
|
824
|
+
architecture_score: z3.number(),
|
|
825
|
+
score_basis: z3.literal(SCORE_BASIS_FINDINGS_ONLY).describe(SCORE_BASIS_NOTE),
|
|
826
|
+
security_score: z3.number(),
|
|
827
|
+
performance_score: z3.number(),
|
|
828
|
+
resilience_score: z3.number(),
|
|
829
|
+
data_score: z3.number(),
|
|
830
|
+
findings: z3.array(
|
|
831
|
+
z3.object({
|
|
832
|
+
severity: z3.string(),
|
|
833
|
+
category: z3.string(),
|
|
834
|
+
message: z3.string(),
|
|
835
|
+
affected_components: z3.array(z3.string()),
|
|
836
|
+
remediation: z3.string()
|
|
837
|
+
})
|
|
838
|
+
),
|
|
839
|
+
finding_count: z3.number(),
|
|
840
|
+
compliance_assessed: z3.boolean().describe("False means no compliance framework was checked, so no compliance finding was possible."),
|
|
841
|
+
frameworks_checked: z3.array(z3.string()),
|
|
842
|
+
compliance_note: z3.string().nullable(),
|
|
843
|
+
exposure_assessed: z3.string().describe(
|
|
844
|
+
"The exposure this critique was actually run under. An absent or unreadable exposure is assessed as public_internet, so a value the server could not read shows up here rather than arriving silently as four extra security findings."
|
|
845
|
+
)
|
|
846
|
+
});
|
|
847
|
+
var SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
|
|
848
|
+
function registerCritique(server, client) {
|
|
849
|
+
server.registerTool(
|
|
850
|
+
"critique_architecture",
|
|
851
|
+
{
|
|
852
|
+
title: "Critique an architecture",
|
|
853
|
+
description: "Check a design against SkeletIQ's architecture rules and get scored findings back. Deterministic and free \u2014 no model call, no credits, nothing stored. Useful on a design you drafted yourself before committing to it.",
|
|
854
|
+
inputSchema: input2,
|
|
855
|
+
outputSchema: output2,
|
|
856
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
|
|
857
|
+
},
|
|
858
|
+
async ({ architecture_json, domain, secondary_domains, exposure }) => guard(async () => {
|
|
859
|
+
if (!architecture_json) {
|
|
860
|
+
throw new Error(
|
|
861
|
+
"critique_architecture needs an architecture_json to check. If you only have a plan in prose, run generate_architecture first \u2014 that spends credits, so say so before you do it."
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
const critique = PayloadCritiqueSchema.parse(
|
|
865
|
+
await client.request("/handoff/critique", {
|
|
866
|
+
method: "POST",
|
|
867
|
+
body: { architecture_json, domain, secondary_domains, exposure }
|
|
868
|
+
})
|
|
869
|
+
);
|
|
870
|
+
const findings = [...critique.findings].sort((a, b) => rank(a.severity) - rank(b.severity)).map((finding) => ({
|
|
871
|
+
severity: finding.severity ?? "medium",
|
|
872
|
+
category: finding.category,
|
|
873
|
+
message: finding.message,
|
|
874
|
+
affected_components: finding.affected_components ?? [],
|
|
875
|
+
remediation: finding.remediation ?? ""
|
|
876
|
+
}));
|
|
877
|
+
const complianceAssessed = critique.compliance_assessed ?? false;
|
|
878
|
+
const exposureAssessed = critique.exposure_assessed ?? "public_internet";
|
|
879
|
+
const structured = {
|
|
880
|
+
architecture_score: critique.architecture_score,
|
|
881
|
+
score_basis: SCORE_BASIS_FINDINGS_ONLY,
|
|
882
|
+
security_score: critique.security_score,
|
|
883
|
+
performance_score: critique.performance_score,
|
|
884
|
+
resilience_score: critique.resilience_score,
|
|
885
|
+
data_score: critique.data_score,
|
|
886
|
+
findings,
|
|
887
|
+
finding_count: findings.length,
|
|
888
|
+
compliance_assessed: complianceAssessed,
|
|
889
|
+
frameworks_checked: critique.frameworks_checked ?? [],
|
|
890
|
+
compliance_note: critique.compliance_note ?? null,
|
|
891
|
+
exposure_assessed: exposureAssessed
|
|
892
|
+
};
|
|
893
|
+
const lines = [
|
|
894
|
+
`Architecture ${round(critique.architecture_score)}/100 \xB7 security ${round(critique.security_score)} \xB7 performance ${round(critique.performance_score)} \xB7 resilience ${round(critique.resilience_score)} \xB7 data ${round(critique.data_score)}`,
|
|
895
|
+
SCORE_BASIS_NOTE,
|
|
896
|
+
"",
|
|
897
|
+
// An agent that reads only the text has to see this too. Without it, "the app
|
|
898
|
+
// said 59 and the tool said 74" is an unexplained contradiction it will either
|
|
899
|
+
// resolve wrongly or report as a bug.
|
|
900
|
+
complianceAssessed ? `Compliance checked against ${(critique.frameworks_checked ?? []).join(", ")} \u2014 the frameworks systems in this domain are usually held to, inferred from the domain you sent rather than from a regime anyone named. Confirm them.` : critique.compliance_note ?? "Compliance was not assessed.",
|
|
901
|
+
// Only when the exposure used is not the one the agent chose. When a
|
|
902
|
+
// non-public exposure *was* applied the server returns an
|
|
903
|
+
// `exposure_scoped_checks` finding naming exactly which checks it took off
|
|
904
|
+
// the table, and restating that list here would be a second copy to drift.
|
|
905
|
+
...exposureLines(exposure, exposureAssessed),
|
|
906
|
+
"",
|
|
907
|
+
findings.length === 0 ? "No findings." : `${findings.length} finding(s):`,
|
|
908
|
+
...findings.map(
|
|
909
|
+
(finding) => `- [${finding.severity}] ${finding.message}` + (finding.affected_components.length ? ` (${finding.affected_components.join(", ")})` : "") + (finding.remediation ? `
|
|
910
|
+
Fix: ${finding.remediation}` : "")
|
|
911
|
+
)
|
|
912
|
+
];
|
|
913
|
+
return ok(structured, lines.join("\n"));
|
|
914
|
+
})
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
function exposureLines(requested, assessed) {
|
|
918
|
+
if (requested === void 0) {
|
|
919
|
+
return [
|
|
920
|
+
'Assessed as internet-facing, because no exposure was sent. Four checks \u2014 CDN, WAF, rate limiting and multi-region \u2014 only apply to a system that takes traffic from the public internet; send exposure: "private_network" or "air_gapped" if this one does not.'
|
|
921
|
+
];
|
|
922
|
+
}
|
|
923
|
+
if (requested !== assessed) {
|
|
924
|
+
return [
|
|
925
|
+
`You sent exposure "${requested}" but the critique was run as ${assessed} \u2014 this SkeletIQ server did not apply it, which one predating the exposure axis does. The CDN, WAF, rate-limiting and multi-region checks therefore ran as though the design were internet-facing.`
|
|
926
|
+
];
|
|
927
|
+
}
|
|
928
|
+
return [];
|
|
929
|
+
}
|
|
930
|
+
function rank(severity) {
|
|
931
|
+
const index = SEVERITY_ORDER.indexOf((severity ?? "medium").toLowerCase());
|
|
932
|
+
return index === -1 ? SEVERITY_ORDER.length : index;
|
|
933
|
+
}
|
|
934
|
+
function round(value) {
|
|
935
|
+
return Math.round(value * 10) / 10;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/tools/generate.ts
|
|
939
|
+
import { randomUUID } from "crypto";
|
|
940
|
+
import * as z4 from "zod/v4";
|
|
941
|
+
|
|
942
|
+
// src/http/sse.ts
|
|
943
|
+
async function* readSseFrames(response) {
|
|
944
|
+
const body = response.body;
|
|
945
|
+
if (!body) return;
|
|
946
|
+
const decoder = new TextDecoder();
|
|
947
|
+
let buffer = "";
|
|
948
|
+
for await (const chunk of body) {
|
|
949
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
950
|
+
let boundary = buffer.indexOf("\n\n");
|
|
951
|
+
while (boundary !== -1) {
|
|
952
|
+
const record4 = buffer.slice(0, boundary);
|
|
953
|
+
buffer = buffer.slice(boundary + 2);
|
|
954
|
+
const frame = parseRecord(record4);
|
|
955
|
+
if (frame) yield frame;
|
|
956
|
+
boundary = buffer.indexOf("\n\n");
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const trailing = parseRecord(buffer);
|
|
960
|
+
if (trailing) yield trailing;
|
|
961
|
+
}
|
|
962
|
+
function parseRecord(record4) {
|
|
963
|
+
const data = record4.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
964
|
+
if (!data) return null;
|
|
965
|
+
try {
|
|
966
|
+
const parsed = JSON.parse(data);
|
|
967
|
+
if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
|
|
968
|
+
return parsed;
|
|
969
|
+
}
|
|
970
|
+
} catch {
|
|
971
|
+
}
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// src/tools/generate.ts
|
|
976
|
+
var PROGRESS_LABELS = {
|
|
977
|
+
analysis: "Analysing requirements",
|
|
978
|
+
patterns: "Selecting architecture patterns",
|
|
979
|
+
generating: "Generating the design",
|
|
980
|
+
validation: "Validating the design",
|
|
981
|
+
refining: "Refining the design"
|
|
982
|
+
};
|
|
983
|
+
var PROGRESS_TOTAL = Object.keys(PROGRESS_LABELS).length + 1;
|
|
984
|
+
var PROVENANCE_LABELS = {
|
|
985
|
+
user_supplied: "stated",
|
|
986
|
+
llm_inferred: "inferred",
|
|
987
|
+
tier_default: "assumed",
|
|
988
|
+
guardrail_clamped: "adjusted"
|
|
989
|
+
};
|
|
990
|
+
function asRecord2(value) {
|
|
991
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
992
|
+
}
|
|
993
|
+
function asText(value) {
|
|
994
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
995
|
+
return typeof value === "string" && value.trim() && value !== "unknown" ? value.trim() : void 0;
|
|
996
|
+
}
|
|
997
|
+
function frameDetail(frame) {
|
|
998
|
+
const data = asRecord2(frame.pipeline_step);
|
|
999
|
+
if (!data) return void 0;
|
|
1000
|
+
const parts = [];
|
|
1001
|
+
const domain = asText(data.domain);
|
|
1002
|
+
if (domain) parts.push(domain);
|
|
1003
|
+
const rps = asText(data.requests_per_second);
|
|
1004
|
+
const figure = rps ? `${rps} rps` : asText(data.scale);
|
|
1005
|
+
if (figure) {
|
|
1006
|
+
const source = asText(data.scale_source);
|
|
1007
|
+
const label = source ? PROVENANCE_LABELS[source] ?? source : void 0;
|
|
1008
|
+
parts.push(label ? `${figure} (${label})` : figure);
|
|
1009
|
+
}
|
|
1010
|
+
return parts.length > 0 ? parts.join(", ") : void 0;
|
|
1011
|
+
}
|
|
1012
|
+
var constraintsInput = z4.strictObject({
|
|
1013
|
+
budget: z4.enum(["low", "medium", "high", "unlimited"]).optional(),
|
|
1014
|
+
max_latency_ms: z4.number().int().positive().optional(),
|
|
1015
|
+
compliance: z4.array(z4.string()).optional().describe("e.g. HIPAA, SOC2, PCI-DSS, GDPR"),
|
|
1016
|
+
preferred_cloud: z4.enum(["aws", "gcp", "azure", "any"]).optional(),
|
|
1017
|
+
preferred_technologies: z4.array(z4.string()).optional(),
|
|
1018
|
+
max_components: z4.number().int().positive().optional(),
|
|
1019
|
+
read_write_mix: z4.enum(["read_heavy", "balanced", "write_heavy"]).optional().describe("Nothing reads this from the prompt. Unset, the design assumes 80% reads."),
|
|
1020
|
+
burstiness: z4.enum(["steady", "daily_peaks", "spiky"]).optional().describe("Nothing reads this from the prompt. Unset, the design assumes the tier default peak."),
|
|
1021
|
+
data_retention: z4.enum(["days", "months", "years", "indefinite"]).optional(),
|
|
1022
|
+
consistency: z4.enum(["strict", "eventual", "mixed"]).optional(),
|
|
1023
|
+
data_residency: z4.enum(["any", "us", "eu"]).optional()
|
|
1024
|
+
});
|
|
1025
|
+
var generateInput = z4.object({
|
|
1026
|
+
prompt: z4.string().min(1).max(2e4).describe("What to design. Describe the system, its scale and its constraints in prose."),
|
|
1027
|
+
project_id: z4.string().optional().describe("An existing SkeletIQ project id to add a version to. Omit to start a new project."),
|
|
1028
|
+
wait: z4.boolean().optional().describe(
|
|
1029
|
+
"Default true: run inline and return the finished design (this takes several minutes). False queues it as a background job and returns a job_id to poll \u2014 not available on every deployment."
|
|
1030
|
+
),
|
|
1031
|
+
clarification_answers: z4.record(z4.string(), z4.string()).optional().describe(
|
|
1032
|
+
'Answers to the questions a previous call returned with status "clarification_required", keyed by their ids. Treated as authoritative requirements, so send what the user told you \u2014 ask them rather than guessing.'
|
|
1033
|
+
),
|
|
1034
|
+
constraints: constraintsInput.optional().describe(
|
|
1035
|
+
"Facts about the design that the prose does not have to carry. Anything omitted is assumed by SkeletIQ, and the design says which values were assumed."
|
|
1036
|
+
)
|
|
1037
|
+
});
|
|
1038
|
+
var generateOutput = z4.object({
|
|
1039
|
+
status: z4.string().describe("completed | clarification_required, or the queue state on wait: false"),
|
|
1040
|
+
project_id: z4.string().nullable(),
|
|
1041
|
+
architecture_id: z4.string().nullable(),
|
|
1042
|
+
version: z4.number().nullable(),
|
|
1043
|
+
job_id: z4.string().nullable(),
|
|
1044
|
+
title: z4.string().nullable(),
|
|
1045
|
+
component_count: z4.number().nullable(),
|
|
1046
|
+
assistant_message: z4.string().nullable(),
|
|
1047
|
+
degradations: z4.array(z4.string()),
|
|
1048
|
+
/**
|
|
1049
|
+
* Present, and non-empty, only on `status: "clarification_required"`. A refusal is a
|
|
1050
|
+
* *result* here rather than an error: an error result may carry no `structuredContent` at
|
|
1051
|
+
* all (the SDK validates it against the success shape), so reporting it that way would
|
|
1052
|
+
* hand the agent the one thing it cannot act on — a sentence saying detail is missing,
|
|
1053
|
+
* without saying which.
|
|
1054
|
+
*/
|
|
1055
|
+
clarifying_questions: z4.array(
|
|
1056
|
+
z4.object({
|
|
1057
|
+
id: z4.string(),
|
|
1058
|
+
question: z4.string(),
|
|
1059
|
+
why: z4.string(),
|
|
1060
|
+
options: z4.array(z4.string())
|
|
1061
|
+
})
|
|
1062
|
+
)
|
|
1063
|
+
});
|
|
1064
|
+
var statusInput = z4.object({
|
|
1065
|
+
job_id: z4.string().describe("The job_id returned by generate_architecture with wait: false.")
|
|
1066
|
+
});
|
|
1067
|
+
var statusOutput = z4.object({
|
|
1068
|
+
job_id: z4.string(),
|
|
1069
|
+
status: z4.string(),
|
|
1070
|
+
project_id: z4.string().nullable(),
|
|
1071
|
+
architecture_id: z4.string().nullable(),
|
|
1072
|
+
error: z4.string().nullable(),
|
|
1073
|
+
queue_state: z4.string().nullable(),
|
|
1074
|
+
/**
|
|
1075
|
+
* Set when the run was *declined* rather than broken — the same three refusals the inline
|
|
1076
|
+
* path answers with a 409. Nothing ran and nothing was charged, so the next call is a
|
|
1077
|
+
* normal one, not a retry.
|
|
1078
|
+
*/
|
|
1079
|
+
refusal_code: z4.string().nullable(),
|
|
1080
|
+
/** Non-empty only for a `CLARIFICATION_REQUIRED` refusal. */
|
|
1081
|
+
clarifying_questions: z4.array(
|
|
1082
|
+
z4.object({
|
|
1083
|
+
id: z4.string(),
|
|
1084
|
+
question: z4.string(),
|
|
1085
|
+
why: z4.string(),
|
|
1086
|
+
options: z4.array(z4.string())
|
|
1087
|
+
})
|
|
1088
|
+
)
|
|
1089
|
+
});
|
|
1090
|
+
function registerGenerate(server, client, resolver) {
|
|
1091
|
+
server.registerTool(
|
|
1092
|
+
"generate_architecture",
|
|
1093
|
+
{
|
|
1094
|
+
title: "Generate a SkeletIQ architecture",
|
|
1095
|
+
description: "Design a system architecture from a prompt. This spends the account holder's credits and takes several minutes, so do not call it speculatively \u2014 if a design already exists, read it with get_design instead. Which model runs it is the account holder's stored setting; you do not choose it. Describe the system, what it must do, and the scale and constraints it runs under: whatever the prompt leaves out is assumed, and a prompt that names no system at all comes back as clarification_required with the questions to answer.",
|
|
1096
|
+
inputSchema: generateInput,
|
|
1097
|
+
outputSchema: generateOutput,
|
|
1098
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
1099
|
+
},
|
|
1100
|
+
async ({ prompt, project_id, wait, clarification_answers, constraints }, ctx) => guard(async () => {
|
|
1101
|
+
const projectId = project_id ? (await resolver.resolveProject(project_id)).id : void 0;
|
|
1102
|
+
const body = {
|
|
1103
|
+
prompt,
|
|
1104
|
+
...projectId ? { project_id: projectId } : {},
|
|
1105
|
+
...clarification_answers ? { clarification_answers } : {},
|
|
1106
|
+
...constraints ? { constraints } : {}
|
|
1107
|
+
};
|
|
1108
|
+
try {
|
|
1109
|
+
if (wait === false) return await queueGeneration(client, body);
|
|
1110
|
+
return await streamGeneration(client, body, ctx, resolver);
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
const questions = clarifyingQuestions(error);
|
|
1113
|
+
if (!questions) throw error;
|
|
1114
|
+
return clarificationResult(questions);
|
|
1115
|
+
}
|
|
1116
|
+
})
|
|
1117
|
+
);
|
|
1118
|
+
server.registerTool(
|
|
1119
|
+
"get_generation_status",
|
|
1120
|
+
{
|
|
1121
|
+
title: "Check a queued SkeletIQ generation",
|
|
1122
|
+
description: 'Check a background generation started with wait: false. When status is "completed", read the result with get_design.',
|
|
1123
|
+
inputSchema: statusInput,
|
|
1124
|
+
outputSchema: statusOutput,
|
|
1125
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
|
|
1126
|
+
},
|
|
1127
|
+
async ({ job_id }) => guard(async () => {
|
|
1128
|
+
const job = JobSchema.parse(await client.request(`/chat/jobs/${job_id}`));
|
|
1129
|
+
const refusal = decisionRefusal(job.progress);
|
|
1130
|
+
const questions = refusalQuestions(refusal);
|
|
1131
|
+
const structured = {
|
|
1132
|
+
job_id: job.job_id,
|
|
1133
|
+
status: job.status,
|
|
1134
|
+
project_id: job.project_id ?? null,
|
|
1135
|
+
architecture_id: job.architecture_id ?? null,
|
|
1136
|
+
error: job.error ?? null,
|
|
1137
|
+
queue_state: job.queue_state ?? null,
|
|
1138
|
+
refusal_code: refusal?.code ?? null,
|
|
1139
|
+
clarifying_questions: questions
|
|
1140
|
+
};
|
|
1141
|
+
const lines = refusal ? refusalLines(job.job_id, refusal, questions) : [`Generation ${job.job_id} is ${job.status}.`];
|
|
1142
|
+
if (job.status === "completed" && job.project_id) {
|
|
1143
|
+
lines.push(`Read it with get_design(project_id: "${job.project_id}").`);
|
|
1144
|
+
}
|
|
1145
|
+
if (job.error && !refusal) lines.push(job.error);
|
|
1146
|
+
return ok(structured, lines.join("\n"));
|
|
1147
|
+
})
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
function decisionRefusal(progress) {
|
|
1151
|
+
const parsed = JobProgressSchema.safeParse(progress);
|
|
1152
|
+
if (!parsed.success) return void 0;
|
|
1153
|
+
const refusal = DecisionRefusalSchema.safeParse(parsed.data.data?.failure_detail);
|
|
1154
|
+
return refusal.success ? refusal.data : void 0;
|
|
1155
|
+
}
|
|
1156
|
+
function refusalQuestions(refusal) {
|
|
1157
|
+
return (refusal?.clarifying_questions ?? []).map((question) => ({
|
|
1158
|
+
id: question.id,
|
|
1159
|
+
question: question.question,
|
|
1160
|
+
why: question.why ?? "",
|
|
1161
|
+
options: (question.options ?? []).filter(
|
|
1162
|
+
(option) => !(question.decline_options ?? []).includes(option)
|
|
1163
|
+
)
|
|
1164
|
+
}));
|
|
1165
|
+
}
|
|
1166
|
+
function refusalLines(jobId, refusal, questions) {
|
|
1167
|
+
const preamble = `Generation ${jobId} was declined before it ran. Nothing was generated and nothing was charged.`;
|
|
1168
|
+
switch (refusal.code) {
|
|
1169
|
+
case "CLARIFICATION_REQUIRED":
|
|
1170
|
+
return [
|
|
1171
|
+
preamble,
|
|
1172
|
+
"",
|
|
1173
|
+
...questions.flatMap((question) => [
|
|
1174
|
+
`${question.id}: ${question.question}`,
|
|
1175
|
+
...question.why ? [` Why it matters: ${question.why}`] : [],
|
|
1176
|
+
...question.options.length > 0 ? [` For example: ${question.options.join(" \xB7 ")}`] : []
|
|
1177
|
+
]),
|
|
1178
|
+
"",
|
|
1179
|
+
"Call generate_architecture again with the same prompt and clarification_answers keyed by those ids. Take the answers from the user rather than inventing them."
|
|
1180
|
+
];
|
|
1181
|
+
case "ASYNC_RESPONSE_MODE_UNSUPPORTED":
|
|
1182
|
+
return [
|
|
1183
|
+
preamble,
|
|
1184
|
+
"That prompt reads as a question about a design rather than a request to build one, and a queued run can only produce a design. Ask it again with wait: true, or read the existing design with get_design and answer from it."
|
|
1185
|
+
];
|
|
1186
|
+
case "NEW_PROJECT_RECOMMENDED":
|
|
1187
|
+
return [
|
|
1188
|
+
preamble,
|
|
1189
|
+
"That prompt describes a different system from the one in this project. Create a new project for it rather than adding a version here."
|
|
1190
|
+
];
|
|
1191
|
+
default:
|
|
1192
|
+
return [preamble, `Refused as: ${refusal.code}.`];
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function clarifyingQuestions(error) {
|
|
1196
|
+
if (!(error instanceof SkeletiqApiError) || error.status !== 409) return void 0;
|
|
1197
|
+
const decision = IntentDecisionSchema.safeParse(error.detail);
|
|
1198
|
+
const questions = decision.success ? decision.data.clarifying_questions : void 0;
|
|
1199
|
+
return questions && questions.length > 0 ? questions : void 0;
|
|
1200
|
+
}
|
|
1201
|
+
function clarificationResult(questions) {
|
|
1202
|
+
const structured = questions.map((question) => ({
|
|
1203
|
+
id: question.id,
|
|
1204
|
+
question: question.question,
|
|
1205
|
+
why: question.why ?? "",
|
|
1206
|
+
options: (question.options ?? []).filter((option) => !(question.decline_options ?? []).includes(option))
|
|
1207
|
+
}));
|
|
1208
|
+
const lines = [
|
|
1209
|
+
"SkeletIQ will not design from this prompt yet \u2014 it names no system to design. Nothing was generated and nothing was charged.",
|
|
1210
|
+
"",
|
|
1211
|
+
...structured.flatMap((question) => [
|
|
1212
|
+
`${question.id}: ${question.question}`,
|
|
1213
|
+
...question.why ? [` Why it matters: ${question.why}`] : [],
|
|
1214
|
+
...question.options.length > 0 ? [` For example: ${question.options.join(" \xB7 ")}`] : []
|
|
1215
|
+
]),
|
|
1216
|
+
"",
|
|
1217
|
+
"Call generate_architecture again with the same prompt and clarification_answers keyed by those ids. The answers are used as authoritative requirements, so take them from the user rather than inventing them \u2014 and put anything you already know into constraints instead, where it is used as a fact rather than as prose to interpret."
|
|
1218
|
+
];
|
|
1219
|
+
return ok(
|
|
1220
|
+
{
|
|
1221
|
+
status: "clarification_required",
|
|
1222
|
+
project_id: null,
|
|
1223
|
+
architecture_id: null,
|
|
1224
|
+
version: null,
|
|
1225
|
+
job_id: null,
|
|
1226
|
+
title: null,
|
|
1227
|
+
component_count: null,
|
|
1228
|
+
assistant_message: null,
|
|
1229
|
+
degradations: [],
|
|
1230
|
+
clarifying_questions: structured
|
|
1231
|
+
},
|
|
1232
|
+
lines.join("\n")
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
async function queueGeneration(client, body) {
|
|
1236
|
+
const queued = GenerateAsyncSchema.parse(
|
|
1237
|
+
await client.request("/chat/generate-async", { method: "POST", body })
|
|
1238
|
+
);
|
|
1239
|
+
return ok(
|
|
1240
|
+
{
|
|
1241
|
+
status: queued.status,
|
|
1242
|
+
project_id: queued.job?.project_id ?? null,
|
|
1243
|
+
architecture_id: null,
|
|
1244
|
+
version: null,
|
|
1245
|
+
job_id: queued.job_id,
|
|
1246
|
+
title: null,
|
|
1247
|
+
component_count: null,
|
|
1248
|
+
assistant_message: null,
|
|
1249
|
+
degradations: [],
|
|
1250
|
+
clarifying_questions: []
|
|
1251
|
+
},
|
|
1252
|
+
`Queued as job ${queued.job_id}. Poll it with get_generation_status.`
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
async function streamGeneration(client, body, ctx, resolver) {
|
|
1256
|
+
const progressToken = ctx.mcpReq._meta?.progressToken;
|
|
1257
|
+
const response = await client.fetch("/chat/generate/stream", {
|
|
1258
|
+
method: "POST",
|
|
1259
|
+
body,
|
|
1260
|
+
// One key per logical call. A network-level retry of the same generation replays the
|
|
1261
|
+
// first result instead of running — and being charged for — a second one.
|
|
1262
|
+
headers: { "Idempotency-Key": randomUUID(), Accept: "text/event-stream" },
|
|
1263
|
+
signal: ctx.mcpReq.signal
|
|
1264
|
+
});
|
|
1265
|
+
let step = 0;
|
|
1266
|
+
let finished;
|
|
1267
|
+
let failed;
|
|
1268
|
+
for await (const frame of readSseFrames(response)) {
|
|
1269
|
+
const label = PROGRESS_LABELS[frame.type];
|
|
1270
|
+
if (label && progressToken !== void 0) {
|
|
1271
|
+
step += 1;
|
|
1272
|
+
const detail = frameDetail(frame);
|
|
1273
|
+
await ctx.mcpReq.notify({
|
|
1274
|
+
method: "notifications/progress",
|
|
1275
|
+
params: {
|
|
1276
|
+
progressToken,
|
|
1277
|
+
progress: step,
|
|
1278
|
+
total: PROGRESS_TOTAL,
|
|
1279
|
+
message: detail ? `${label} \u2014 ${detail}` : label
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
if (frame.type === "done") finished = frame;
|
|
1284
|
+
if (frame.type === "error") failed = frame;
|
|
1285
|
+
}
|
|
1286
|
+
if (failed) {
|
|
1287
|
+
const message = typeof failed.content === "string" ? failed.content : "The generation failed.";
|
|
1288
|
+
return failure(`SkeletIQ could not complete the generation: ${message}`);
|
|
1289
|
+
}
|
|
1290
|
+
if (!finished) {
|
|
1291
|
+
return failure(
|
|
1292
|
+
"The generation stream ended without a result. Nothing here says whether it completed \u2014 check the project in SkeletIQ before generating again, so you do not pay for it twice."
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
const architectureId = typeof finished.architecture_id === "string" ? finished.architecture_id : null;
|
|
1296
|
+
const version = typeof finished.architecture_version === "number" ? finished.architecture_version : null;
|
|
1297
|
+
const degradations = Array.isArray(finished.degradations) ? finished.degradations.filter((d) => typeof d === "string") : [];
|
|
1298
|
+
const created = architectureId ? await describeCreated(resolver, architectureId) : void 0;
|
|
1299
|
+
return ok(
|
|
1300
|
+
{
|
|
1301
|
+
status: "completed",
|
|
1302
|
+
project_id: created?.projectId ?? null,
|
|
1303
|
+
architecture_id: architectureId,
|
|
1304
|
+
version,
|
|
1305
|
+
job_id: null,
|
|
1306
|
+
title: created?.title ?? null,
|
|
1307
|
+
component_count: created?.componentCount ?? null,
|
|
1308
|
+
assistant_message: null,
|
|
1309
|
+
degradations,
|
|
1310
|
+
clarifying_questions: []
|
|
1311
|
+
},
|
|
1312
|
+
[
|
|
1313
|
+
...architectureId ? [
|
|
1314
|
+
`Design generated as version ${version ?? "?"}.`,
|
|
1315
|
+
created ? `Read it with get_design(project_id: "${created.projectId}").` : "Read it with get_design. This run could not report which project it was written to \u2014 find that with list_projects rather than assuming one.",
|
|
1316
|
+
'Check get_design(mode: "readiness") before building \u2014 a fresh design is a draft, and',
|
|
1317
|
+
"its open questions are the ones worth asking now."
|
|
1318
|
+
] : ["SkeletIQ answered in prose rather than producing a design."],
|
|
1319
|
+
...degradations.map((d) => `Degraded: ${d}`)
|
|
1320
|
+
].join("\n")
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
async function describeCreated(resolver, architectureId) {
|
|
1324
|
+
try {
|
|
1325
|
+
const design = await resolver.design(architectureId);
|
|
1326
|
+
return {
|
|
1327
|
+
projectId: design.project_id,
|
|
1328
|
+
title: design.architecture_json.title ?? null,
|
|
1329
|
+
componentCount: design.architecture_json.components?.length ?? null
|
|
1330
|
+
};
|
|
1331
|
+
} catch {
|
|
1332
|
+
return void 0;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// src/tools/get-design.ts
|
|
1337
|
+
import * as z5 from "zod/v4";
|
|
1338
|
+
|
|
1339
|
+
// src/lib/slice.ts
|
|
1340
|
+
function sliceComponent(design, componentId) {
|
|
1341
|
+
const components = design.components ?? [];
|
|
1342
|
+
const component = components.find((c) => c.id === componentId) ?? components.find((c) => normalize(c.name) === normalize(componentId));
|
|
1343
|
+
if (!component) return null;
|
|
1344
|
+
const connections = design.connections ?? [];
|
|
1345
|
+
return {
|
|
1346
|
+
component,
|
|
1347
|
+
outgoing: connections.filter((c) => c.source === component.id),
|
|
1348
|
+
incoming: connections.filter((c) => c.target === component.id),
|
|
1349
|
+
related_decisions: relatedDecisions(design, component)
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function relatedDecisions(design, component) {
|
|
1353
|
+
const needles = [component.name, component.technology].filter((value) => typeof value === "string" && value.trim().length > 2).map((value) => value.toLowerCase());
|
|
1354
|
+
if (needles.length === 0) return [];
|
|
1355
|
+
return (design.design_decisions ?? []).filter((decision) => {
|
|
1356
|
+
const haystack = decision.toLowerCase();
|
|
1357
|
+
return needles.some((needle) => haystack.includes(needle));
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
function overview(design) {
|
|
1361
|
+
return {
|
|
1362
|
+
title: design.title ?? "Untitled design",
|
|
1363
|
+
description: design.description ?? "",
|
|
1364
|
+
components: design.components ?? [],
|
|
1365
|
+
connections: design.connections ?? [],
|
|
1366
|
+
design_decisions: design.design_decisions ?? [],
|
|
1367
|
+
trade_offs: design.trade_offs ?? []
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
function normalize(value) {
|
|
1371
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/tools/get-design.ts
|
|
1375
|
+
var MODES = ["overview", "component", "brief", "readiness", "build_order", "gaps"];
|
|
1376
|
+
var input3 = z5.object({
|
|
1377
|
+
project_id: z5.string().describe("A SkeletIQ project id, or its exact name."),
|
|
1378
|
+
mode: z5.enum(MODES).describe(
|
|
1379
|
+
"overview: the whole design. component: one component, its connections in both directions, and the decisions that mention it. brief: the fenced markdown block to write into AGENTS.md. readiness: what still has to be decided before this design is worth building. build_order: the order to build components in, and why. gaps: the open questions and unconfirmed assumptions in the design."
|
|
1380
|
+
),
|
|
1381
|
+
component_id: z5.string().optional().describe('Required for mode "component". The component id, or its name.'),
|
|
1382
|
+
version: z5.number().int().positive().optional().describe("A specific version. Defaults to the latest released version, or the latest version if none is released.")
|
|
1383
|
+
});
|
|
1384
|
+
var output3 = z5.object({
|
|
1385
|
+
project_id: z5.string(),
|
|
1386
|
+
architecture_id: z5.string(),
|
|
1387
|
+
version: z5.number(),
|
|
1388
|
+
is_released: z5.boolean(),
|
|
1389
|
+
newer_release_exists: z5.boolean(),
|
|
1390
|
+
resolved_by: z5.string(),
|
|
1391
|
+
mode: z5.string(),
|
|
1392
|
+
/** Mode-shaped. Deliberately loose: six modes cannot share one strict schema honestly. */
|
|
1393
|
+
data: z5.unknown()
|
|
1394
|
+
});
|
|
1395
|
+
function registerGetDesign(server, client, resolver) {
|
|
1396
|
+
server.registerTool(
|
|
1397
|
+
"get_design",
|
|
1398
|
+
{
|
|
1399
|
+
title: "Read a SkeletIQ design",
|
|
1400
|
+
description: 'Read a SkeletIQ architecture. Start with mode "brief" to orient \u2014 it returns a fenced block to write into AGENTS.md \u2014 then "build_order" for the sequence and "component" for each piece as you build it. Every answer states which version it came from and whether that version is a release.',
|
|
1401
|
+
inputSchema: input3,
|
|
1402
|
+
outputSchema: output3,
|
|
1403
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
|
|
1404
|
+
},
|
|
1405
|
+
async ({ project_id, mode, component_id, version }) => guard(async () => {
|
|
1406
|
+
const project = await resolver.resolveProject(project_id);
|
|
1407
|
+
const resolved = await resolver.resolveVersion(project.id, version);
|
|
1408
|
+
const base = {
|
|
1409
|
+
project_id: project.id,
|
|
1410
|
+
architecture_id: resolved.architectureId,
|
|
1411
|
+
mode
|
|
1412
|
+
};
|
|
1413
|
+
switch (mode) {
|
|
1414
|
+
case "overview": {
|
|
1415
|
+
const design = await resolver.design(resolved.architectureId);
|
|
1416
|
+
const data = overview(design.architecture_json);
|
|
1417
|
+
const facts = factsFrom(resolved);
|
|
1418
|
+
return ok({ ...base, ...facts, data }, [
|
|
1419
|
+
`# ${data.title}`,
|
|
1420
|
+
data.description,
|
|
1421
|
+
"",
|
|
1422
|
+
`${data.components.length} component(s), ${data.connections.length} connection(s).`,
|
|
1423
|
+
...data.components.map(
|
|
1424
|
+
(c) => `- ${c.id} \u2014 ${c.name} (${c.type}${c.technology ? `, ${c.technology}` : ""})`
|
|
1425
|
+
),
|
|
1426
|
+
"",
|
|
1427
|
+
versionLine(facts)
|
|
1428
|
+
].join("\n"));
|
|
1429
|
+
}
|
|
1430
|
+
case "component": {
|
|
1431
|
+
if (!component_id) {
|
|
1432
|
+
return failure('mode "component" needs a component_id. Use mode "overview" or "build_order" to see the ids.');
|
|
1433
|
+
}
|
|
1434
|
+
const design = await resolver.design(resolved.architectureId);
|
|
1435
|
+
const slice = sliceComponent(design.architecture_json, component_id);
|
|
1436
|
+
if (!slice) {
|
|
1437
|
+
const known = (design.architecture_json.components ?? []).map((c) => c.id).join(", ");
|
|
1438
|
+
return failure(
|
|
1439
|
+
`Version ${resolved.version} has no component "${component_id}". Component ids are only valid within one version. Known ids: ${known || "none"}.`
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
const facts = factsFrom(resolved);
|
|
1443
|
+
const { component, incoming, outgoing } = slice;
|
|
1444
|
+
return ok({ ...base, ...facts, data: slice }, [
|
|
1445
|
+
`## ${component.name} (${component.id})`,
|
|
1446
|
+
`Type: ${component.type}${component.technology ? ` \xB7 ${component.technology}` : ""}`,
|
|
1447
|
+
component.description ?? "",
|
|
1448
|
+
"",
|
|
1449
|
+
outgoing.length ? `Calls: ${outgoing.map((c) => `${c.target}${c.protocol ? ` (${c.protocol})` : ""}`).join(", ")}` : "Calls nothing.",
|
|
1450
|
+
incoming.length ? `Called by: ${incoming.map((c) => c.source).join(", ")}` : "Called by nothing in the design.",
|
|
1451
|
+
...slice.related_decisions.map((decision) => `- ${decision}`),
|
|
1452
|
+
"",
|
|
1453
|
+
versionLine(facts)
|
|
1454
|
+
].join("\n"));
|
|
1455
|
+
}
|
|
1456
|
+
case "brief": {
|
|
1457
|
+
const body = await client.request(`/architectures/${resolved.architectureId}/brief`);
|
|
1458
|
+
const brief = BriefSchema.parse(body);
|
|
1459
|
+
const facts = factsFromRelease(resolved, brief.release);
|
|
1460
|
+
return ok({ ...base, ...facts, data: { markdown: brief.markdown, is_draft: brief.is_draft } }, [
|
|
1461
|
+
"Write the block below into this repository's AGENTS.md.",
|
|
1462
|
+
"If a `skeletiq:brief` fence is already there, REPLACE it whole \u2014 opening fence to",
|
|
1463
|
+
"closing fence. Never append a second one, and never edit inside it: a refresh",
|
|
1464
|
+
"overwrites the block, so changes made there are lost silently.",
|
|
1465
|
+
brief.is_draft ? "This design is a DRAFT. It has not been released and can change without notice." : "",
|
|
1466
|
+
// Said here as well as inside the block, because a model that acts on
|
|
1467
|
+
// this tool response without re-reading the markdown it just wrote to
|
|
1468
|
+
// disk would otherwise never learn it. `check_drift` defaults to "this
|
|
1469
|
+
// repo is the whole design", and a repo implementing part of one gets
|
|
1470
|
+
// every component built elsewhere reported as missing.
|
|
1471
|
+
"If this repository implements only PART of the design, pass `covers` to",
|
|
1472
|
+
"check_drift with just the component ids you are responsible for. The block",
|
|
1473
|
+
'below lists every id under "What this repo covers".',
|
|
1474
|
+
"",
|
|
1475
|
+
brief.markdown,
|
|
1476
|
+
"",
|
|
1477
|
+
versionLine(facts)
|
|
1478
|
+
].filter(Boolean).join("\n"));
|
|
1479
|
+
}
|
|
1480
|
+
case "readiness": {
|
|
1481
|
+
const body = await client.request(`/architectures/${resolved.architectureId}/readiness`);
|
|
1482
|
+
const readiness = ReadinessSchema.parse(body);
|
|
1483
|
+
const facts = factsFromRelease(resolved, readiness.release);
|
|
1484
|
+
const gates = readiness.rows.filter((row) => row.category === "gate");
|
|
1485
|
+
const open = gates.filter((row) => row.gating);
|
|
1486
|
+
const unanswered = gates.filter((row) => row.state === "unknown");
|
|
1487
|
+
const advisories = readiness.rows.filter((row) => row.category !== "gate");
|
|
1488
|
+
const unrun = new Set(readiness.unrun_checks ?? []);
|
|
1489
|
+
const verdict = readiness.verdict ?? (readiness.ready ? "ready" : "outstanding");
|
|
1490
|
+
const headline = verdict === "ready" ? "Every gate is clear \u2014 this design is ready to build." : verdict === "unanswered" ? `Nothing is undecided, but ${unanswered.length} check(s) could not be answered for this version:` : `${open.length} of ${gates.length} gate(s) still open:`;
|
|
1491
|
+
const listed = verdict === "unanswered" ? unanswered : open;
|
|
1492
|
+
return ok({ ...base, ...facts, data: readiness }, [
|
|
1493
|
+
headline,
|
|
1494
|
+
...listed.map((row) => `- ${row.label}: ${describeRow(row)}`),
|
|
1495
|
+
"",
|
|
1496
|
+
"Advisory checks (they never block, and some need a paid plan to clear):",
|
|
1497
|
+
// An advisory that never ran is marked, not counted. "The advisor has
|
|
1498
|
+
// not looked at this design" is a fact an agent being handed work is
|
|
1499
|
+
// entitled to, and it is invisible in a bare count of open items.
|
|
1500
|
+
...advisories.map(
|
|
1501
|
+
(row) => `- ${row.label}: ${describeRow(row)}${unrun.has(row.key) ? " (never run on this version)" : ""}`
|
|
1502
|
+
),
|
|
1503
|
+
"",
|
|
1504
|
+
"Only a person can clear a gate. Ask; do not decide on their behalf.",
|
|
1505
|
+
versionLine(facts)
|
|
1506
|
+
].join("\n"));
|
|
1507
|
+
}
|
|
1508
|
+
case "build_order": {
|
|
1509
|
+
const body = await client.request(`/architectures/${resolved.architectureId}/build-order`);
|
|
1510
|
+
const buildOrder = BuildOrderSchema.parse(body);
|
|
1511
|
+
const facts = factsFromRelease(resolved, buildOrder.release);
|
|
1512
|
+
return ok({ ...base, ...facts, data: { steps: buildOrder.steps } }, [
|
|
1513
|
+
"Build in this order. It follows the dependency graph \u2014 an arrow means the",
|
|
1514
|
+
"source calls the target, so the target is built first \u2014 and falls back to what",
|
|
1515
|
+
"each component *is* wherever the graph is silent.",
|
|
1516
|
+
...buildOrder.steps.map((step) => {
|
|
1517
|
+
const blocked = new Set(step.blocked_by ?? []);
|
|
1518
|
+
const after = (step.depends_on ?? []).filter((id) => !blocked.has(id));
|
|
1519
|
+
return `${step.order}. ${step.name} (${step.component_id}) \u2014 ${step.reason}` + (after.length ? ` [after: ${after.join(", ")}]` : "") + (blocked.size ? ` [cycle: it calls ${[...blocked].join(", ")}, built later \u2014 stub or defer that edge]` : "");
|
|
1520
|
+
}),
|
|
1521
|
+
"",
|
|
1522
|
+
versionLine(facts)
|
|
1523
|
+
].join("\n"));
|
|
1524
|
+
}
|
|
1525
|
+
case "gaps": {
|
|
1526
|
+
const body = await client.request(`/projects/${project.id}/design-gaps`, {
|
|
1527
|
+
query: { architecture_id: resolved.architectureId }
|
|
1528
|
+
});
|
|
1529
|
+
const gaps = DesignGapListSchema.parse(body);
|
|
1530
|
+
const facts = factsFrom(resolved);
|
|
1531
|
+
const unresolved = gaps.gaps.filter((gap) => !gap.resolved);
|
|
1532
|
+
const settled = gaps.gaps.filter((gap) => gap.resolved && gap.action !== "dismissed");
|
|
1533
|
+
return ok({ ...base, ...facts, data: gaps }, [
|
|
1534
|
+
unresolved.length === 0 ? "Every open question and assumption in this design has been dealt with." : `${unresolved.length} unresolved:`,
|
|
1535
|
+
...unresolved.map((gap) => `- [${gap.kind}] ${gap.text}`),
|
|
1536
|
+
...settled.length > 0 ? [
|
|
1537
|
+
"",
|
|
1538
|
+
`${settled.length} settled during review \u2014 treat these as decided:`,
|
|
1539
|
+
...settled.map((gap) => `- [${gap.kind}] ${gap.text} \u2192 ${settledAnswer(gap)}`)
|
|
1540
|
+
] : [],
|
|
1541
|
+
"",
|
|
1542
|
+
"The unresolved ones are questions for a person, not for you to answer. Raise",
|
|
1543
|
+
"them; do not guess and build on the guess. An API token cannot resolve them \u2014",
|
|
1544
|
+
"that is done in the SkeletIQ app, on purpose.",
|
|
1545
|
+
versionLine(facts)
|
|
1546
|
+
].join("\n"));
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
})
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
function settledAnswer(gap) {
|
|
1553
|
+
const note = (gap.note ?? "").trim();
|
|
1554
|
+
if (gap.adr_id) {
|
|
1555
|
+
const pointer = `recorded as a Decision (${gap.adr_id})`;
|
|
1556
|
+
return note ? `${pointer} \u2014 ${note}` : pointer;
|
|
1557
|
+
}
|
|
1558
|
+
if (note) return note;
|
|
1559
|
+
if (gap.action === "confirmed") return "confirmed as it stands";
|
|
1560
|
+
return "answered, but no wording was recorded \u2014 ask before relying on it";
|
|
1561
|
+
}
|
|
1562
|
+
function describeRow(row) {
|
|
1563
|
+
if (row.state === "unknown") return "not checked";
|
|
1564
|
+
return `${row.count}${row.detail ? ` \u2014 ${row.detail}` : ""}`;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/tools/list-projects.ts
|
|
1568
|
+
import * as z6 from "zod/v4";
|
|
1569
|
+
var input4 = z6.object({
|
|
1570
|
+
query: z6.string().optional().describe("Filter by name or description. Omit to list everything the token can see.")
|
|
1571
|
+
});
|
|
1572
|
+
var output4 = z6.object({
|
|
1573
|
+
projects: z6.array(
|
|
1574
|
+
z6.object({
|
|
1575
|
+
id: z6.string(),
|
|
1576
|
+
title: z6.string(),
|
|
1577
|
+
description: z6.string().nullable(),
|
|
1578
|
+
architecture_count: z6.number(),
|
|
1579
|
+
updated_at: z6.string().nullable()
|
|
1580
|
+
})
|
|
1581
|
+
),
|
|
1582
|
+
total: z6.number()
|
|
1583
|
+
});
|
|
1584
|
+
function registerListProjects(server, resolver) {
|
|
1585
|
+
server.registerTool(
|
|
1586
|
+
"list_projects",
|
|
1587
|
+
{
|
|
1588
|
+
title: "List SkeletIQ projects",
|
|
1589
|
+
description: "List the SkeletIQ projects this token can see, optionally filtered by name. Use this first to turn a project a person named in conversation into an id. If more than one project matches, ask which \u2014 do not pick one.",
|
|
1590
|
+
inputSchema: input4,
|
|
1591
|
+
outputSchema: output4,
|
|
1592
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
|
|
1593
|
+
},
|
|
1594
|
+
async ({ query }) => guard(async () => {
|
|
1595
|
+
const projects = await resolver.listProjects(query);
|
|
1596
|
+
const structured = {
|
|
1597
|
+
projects: projects.map((project) => ({
|
|
1598
|
+
id: project.id,
|
|
1599
|
+
title: project.title,
|
|
1600
|
+
description: project.description ?? null,
|
|
1601
|
+
architecture_count: project.architecture_count ?? 0,
|
|
1602
|
+
updated_at: project.updated_at ?? null
|
|
1603
|
+
})),
|
|
1604
|
+
total: projects.length
|
|
1605
|
+
};
|
|
1606
|
+
if (projects.length === 0) {
|
|
1607
|
+
return ok(
|
|
1608
|
+
structured,
|
|
1609
|
+
query ? `No SkeletIQ project matches "${query}".` : "This token can see no SkeletIQ projects."
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
const lines = projects.map(
|
|
1613
|
+
(project) => `- ${project.title} (${project.id}) \u2014 ${project.architecture_count ?? 0} version(s)`
|
|
1614
|
+
);
|
|
1615
|
+
return ok(structured, [`${projects.length} project(s):`, ...lines].join("\n"));
|
|
1616
|
+
})
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// src/server.ts
|
|
1621
|
+
var INSTRUCTIONS = `SkeletIQ holds the system architecture this codebase is meant to implement: its components, how they connect, what was decided and why, and what is still an open question. It is the design's source of truth, and this server is how you read it.
|
|
1622
|
+
|
|
1623
|
+
Orient before you build. Call get_design with mode "brief" and write the block it returns into this repository's AGENTS.md. The block is fenced with an HTML comment (skeletiq:brief). If one is already there, replace it whole \u2014 opening fence to closing fence. Never append a second, and never edit inside it: a refresh overwrites the block, so anything written there is lost without warning.
|
|
1624
|
+
|
|
1625
|
+
Then build in the order get_design(mode: "build_order") gives you, reading each component with mode "component" as you reach it.
|
|
1626
|
+
|
|
1627
|
+
Four things that are easy to get wrong:
|
|
1628
|
+
|
|
1629
|
+
A DRAFT design has not been approved by anyone and can change under you on any edit. Prefer a released version, and if a tool tells you newer_release_exists, refresh the brief before going further.
|
|
1630
|
+
|
|
1631
|
+
Component ids belong to one version. A regeneration mints new ones, so an id from an older brief may name nothing. When check_drift returns unknown ids with suggestions, they are suggestions \u2014 put them to a person rather than assuming the mapping.
|
|
1632
|
+
|
|
1633
|
+
Open questions are for people. get_design(mode: "gaps") lists what the design has not settled. Raise them; do not answer them yourself and build on the answer. You cannot resolve them through this server, and that is deliberate.
|
|
1634
|
+
|
|
1635
|
+
Report what you build with check_drift, and declare covers when this repository implements only part of the design \u2014 otherwise every component built elsewhere is reported to you as missing.
|
|
1636
|
+
|
|
1637
|
+
Designing costs the account holder credits and takes minutes. Read before you generate.`;
|
|
1638
|
+
function createServerFactory(config) {
|
|
1639
|
+
const client = new SkeletiqClient(config);
|
|
1640
|
+
const resolver = new DesignResolver(client);
|
|
1641
|
+
return () => buildServer(client, resolver);
|
|
1642
|
+
}
|
|
1643
|
+
function buildServer(client, resolver) {
|
|
1644
|
+
const server = new McpServer(
|
|
1645
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
1646
|
+
{ instructions: INSTRUCTIONS, capabilities: { tools: {} } }
|
|
1647
|
+
);
|
|
1648
|
+
registerListProjects(server, resolver);
|
|
1649
|
+
registerGetDesign(server, client, resolver);
|
|
1650
|
+
registerGenerate(server, client, resolver);
|
|
1651
|
+
registerCritique(server, client);
|
|
1652
|
+
registerCheckDrift(server, client, resolver);
|
|
1653
|
+
return server;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// src/index.ts
|
|
1657
|
+
function main() {
|
|
1658
|
+
let config;
|
|
1659
|
+
try {
|
|
1660
|
+
config = readConfig();
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
process.stderr.write(
|
|
1663
|
+
`${error instanceof ConfigError ? error.message : String(error)}
|
|
1664
|
+
`
|
|
1665
|
+
);
|
|
1666
|
+
process.exitCode = 1;
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
serveStdio(createServerFactory(config), {
|
|
1670
|
+
onerror: (error) => process.stderr.write(`skeletiq-mcp: ${error.message}
|
|
1671
|
+
`)
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
main();
|