@irtio/mcp 0.6.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/dist/bin.d.ts +1 -0
- package/dist/bin.js +15 -0
- package/dist/chunk-VFNFDDNZ.js +1122 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +26 -0
- package/package.json +32 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
// src/auth.ts
|
|
2
|
+
import { createApiClientWithToken } from "@irtio/cli/api";
|
|
3
|
+
import { readCredential, resolveControlUrlForUser } from "@irtio/cli/credentials";
|
|
4
|
+
async function resolveAuth(env = process.env, controlUrlFlag) {
|
|
5
|
+
const controlUrl = await resolveControlUrlForUser(controlUrlFlag);
|
|
6
|
+
const credential = await readCredential(controlUrl);
|
|
7
|
+
if (credential) {
|
|
8
|
+
const expiresAt = new Date(credential.expiresAt).getTime();
|
|
9
|
+
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
|
10
|
+
return { ok: false, controlUrl, reason: "expired" };
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
ok: true,
|
|
14
|
+
controlUrl,
|
|
15
|
+
token: credential.token,
|
|
16
|
+
source: "credential-file",
|
|
17
|
+
email: credential.email
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const envToken = env.IRTIO_TOKEN;
|
|
21
|
+
if (envToken !== void 0 && envToken.length > 0) {
|
|
22
|
+
return { ok: true, controlUrl, token: envToken, source: "env", email: void 0 };
|
|
23
|
+
}
|
|
24
|
+
return { ok: false, controlUrl, reason: "no-credential" };
|
|
25
|
+
}
|
|
26
|
+
var NoCredentialError = class extends Error {
|
|
27
|
+
constructor(controlUrl, reason) {
|
|
28
|
+
super(
|
|
29
|
+
reason === "expired" ? `the stored login for ${controlUrl} has expired` : `no stored login for ${controlUrl}`
|
|
30
|
+
);
|
|
31
|
+
this.controlUrl = controlUrl;
|
|
32
|
+
this.reason = reason;
|
|
33
|
+
}
|
|
34
|
+
controlUrl;
|
|
35
|
+
reason;
|
|
36
|
+
name = "NoCredentialError";
|
|
37
|
+
};
|
|
38
|
+
async function requireClient(env = process.env, controlUrlFlag) {
|
|
39
|
+
const auth = await resolveAuth(env, controlUrlFlag);
|
|
40
|
+
if (!auth.ok) throw new NoCredentialError(auth.controlUrl, auth.reason);
|
|
41
|
+
return { client: createApiClientWithToken(auth.controlUrl, auth.token), auth };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/narrate.ts
|
|
45
|
+
import { ApiClientError, NotLoggedInError } from "@irtio/cli/api";
|
|
46
|
+
import { DeployRefusedError } from "@irtio/cli/deploy";
|
|
47
|
+
import { RunNotPerformedError, ScenarioNotRunError } from "@irtio/cli/simulate";
|
|
48
|
+
function narrate(n) {
|
|
49
|
+
const text = `${n.summary}
|
|
50
|
+
|
|
51
|
+
${JSON.stringify(n.data, null, 2)}
|
|
52
|
+
|
|
53
|
+
next: ${n.next}`;
|
|
54
|
+
return { content: [{ type: "text", text }], structuredContent: n.data };
|
|
55
|
+
}
|
|
56
|
+
var LOGIN_NARRATION = "You are not signed in to the irtio control plane, so this tool could not run. Run `irtio login` in a terminal. It opens a browser once and writes a credential that this server reads on the next call. No token belongs in this server's config file.";
|
|
57
|
+
function narrateBreaking(changes, hint) {
|
|
58
|
+
const messages = changes.map(
|
|
59
|
+
(c) => typeof c === "object" && c !== null && "message" in c ? String(c.message) : void 0
|
|
60
|
+
).filter((m) => m !== void 0);
|
|
61
|
+
const detail = messages.length > 0 ? ` ${messages.join("; ")}.` : "";
|
|
62
|
+
return narrate({
|
|
63
|
+
summary: `The deploy was refused: the schema change is breaking, so nothing was deployed.${detail} A breaking change needs a migration, because rooms holding live state cannot be read under the new schema without one.`,
|
|
64
|
+
data: {
|
|
65
|
+
error: "E_BREAKING_SCHEMA",
|
|
66
|
+
breaking: messages.length,
|
|
67
|
+
changes,
|
|
68
|
+
...hint !== void 0 ? { hint } : {}
|
|
69
|
+
},
|
|
70
|
+
next: hint ?? "Write a migration, then deploy again with allowBreaking set."
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function narrateError(err, toolName) {
|
|
74
|
+
const expired = err instanceof NoCredentialError && err.reason === "expired";
|
|
75
|
+
if (err instanceof NoCredentialError || err instanceof NotLoggedInError || err instanceof ApiClientError && err.status === 401) {
|
|
76
|
+
const summary = expired ? "Your irtio login has expired, so this tool could not run. Run `irtio login` in a terminal to sign in again. The server picks the new credential up on the next call." : LOGIN_NARRATION;
|
|
77
|
+
return {
|
|
78
|
+
...narrate({
|
|
79
|
+
summary,
|
|
80
|
+
data: {
|
|
81
|
+
error: expired ? "login_expired" : "not_signed_in",
|
|
82
|
+
tool: toolName,
|
|
83
|
+
...err instanceof NoCredentialError ? { controlUrl: err.controlUrl } : {}
|
|
84
|
+
},
|
|
85
|
+
next: "Run `irtio login`, then call this tool again."
|
|
86
|
+
}),
|
|
87
|
+
isError: true
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (err instanceof DeployRefusedError) {
|
|
91
|
+
return { ...narrateBreaking(err.changes, err.hint), isError: true };
|
|
92
|
+
}
|
|
93
|
+
if (err instanceof ScenarioNotRunError || err instanceof RunNotPerformedError) {
|
|
94
|
+
return {
|
|
95
|
+
...narrate({
|
|
96
|
+
summary: `The scenario did not run, so nothing was measured and this says nothing about the room. ${err.message}`,
|
|
97
|
+
data: { error: "scenario_not_run", tool: toolName, message: err.message },
|
|
98
|
+
next: "Start the room with `irtio dev` if it is not running, fix the path or the scenario file the message names, then call this tool again."
|
|
99
|
+
}),
|
|
100
|
+
isError: true
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (err instanceof ApiClientError) {
|
|
104
|
+
if (err.status === 409 && err.code === "E_BREAKING_SCHEMA") {
|
|
105
|
+
return { ...narrateBreaking(err.changes ?? [], err.hint), isError: true };
|
|
106
|
+
}
|
|
107
|
+
if (err.status === 404) {
|
|
108
|
+
return {
|
|
109
|
+
...narrate({
|
|
110
|
+
summary: `Not found in your organisation: ${err.message}. The control plane answers the same way for a project that does not exist and one that belongs to somebody else, so check the project id.`,
|
|
111
|
+
data: { error: err.code, status: err.status, message: err.message },
|
|
112
|
+
next: "Call `project_list` to see the projects this account can reach."
|
|
113
|
+
}),
|
|
114
|
+
isError: true
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...narrate({
|
|
119
|
+
summary: `The control plane refused the request: ${err.message}.`,
|
|
120
|
+
data: {
|
|
121
|
+
error: err.code,
|
|
122
|
+
status: err.status,
|
|
123
|
+
message: err.message,
|
|
124
|
+
...err.hint !== void 0 ? { hint: err.hint } : {}
|
|
125
|
+
},
|
|
126
|
+
next: err.hint ?? "Fix the request and call the tool again."
|
|
127
|
+
}),
|
|
128
|
+
isError: true
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
132
|
+
return {
|
|
133
|
+
...narrate({
|
|
134
|
+
summary: `\`${toolName}\` failed before it reached the control plane: ${message}.`,
|
|
135
|
+
data: { error: "local_failure", tool: toolName, message },
|
|
136
|
+
next: "Check the message above. If it names a file or a path, that is the thing to fix."
|
|
137
|
+
}),
|
|
138
|
+
isError: true
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function plural(count, one, many = `${one}s`) {
|
|
142
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/tools.ts
|
|
146
|
+
import { NotLoggedInError as NotLoggedInError2 } from "@irtio/cli/api";
|
|
147
|
+
import { runDeploy } from "@irtio/cli/deploy";
|
|
148
|
+
import { runSimulation } from "@irtio/cli/simulate";
|
|
149
|
+
function api(ctx) {
|
|
150
|
+
if (ctx.client === void 0) throw new NotLoggedInError2(ctx.controlUrl);
|
|
151
|
+
return ctx.client;
|
|
152
|
+
}
|
|
153
|
+
function str(args, key) {
|
|
154
|
+
const value = args[key];
|
|
155
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
156
|
+
throw new Error(`\`${key}\` is required and must be a non-empty string`);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
function optStr(args, key) {
|
|
161
|
+
const value = args[key];
|
|
162
|
+
if (value === void 0 || value === null) return void 0;
|
|
163
|
+
if (typeof value !== "string") throw new Error(`\`${key}\` must be a string`);
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
function optInt(args, key) {
|
|
167
|
+
const value = args[key];
|
|
168
|
+
if (value === void 0 || value === null) return void 0;
|
|
169
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
170
|
+
throw new Error(`\`${key}\` must be a whole number`);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
var PROJECT_ARG = {
|
|
175
|
+
type: "string",
|
|
176
|
+
description: "The project id, like p_0123456789abcdef. Call project_list if you do not have it."
|
|
177
|
+
};
|
|
178
|
+
function projectSchema(extra = {}, required = []) {
|
|
179
|
+
return {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: { project: PROJECT_ARG, ...extra },
|
|
182
|
+
required: ["project", ...required],
|
|
183
|
+
additionalProperties: false
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
var whoami = {
|
|
187
|
+
name: "whoami",
|
|
188
|
+
description: "Read the signed-in account and the control plane it is signed in to. Read-only and cheap. Call this first when you are not sure whether a credential exists, because every other tool needs one.",
|
|
189
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
190
|
+
async run(ctx) {
|
|
191
|
+
const me = await api(ctx).get("/v1/me");
|
|
192
|
+
return {
|
|
193
|
+
summary: `Signed in to ${ctx.controlUrl} as ${me.email}.`,
|
|
194
|
+
data: { id: me.id, orgId: me.orgId, email: me.email, controlUrl: me.controlUrl },
|
|
195
|
+
next: "Call `project_list` to see what this account can reach."
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var projectList = {
|
|
200
|
+
name: "project_list",
|
|
201
|
+
description: "List every project in your organisation. Read-only and cheap. Returns ids, names and regions, which is where you get the project id every other tool needs.",
|
|
202
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
203
|
+
async run(ctx) {
|
|
204
|
+
const rows = await api(ctx).get("/v1/projects");
|
|
205
|
+
if (rows.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
summary: "This organisation has no projects yet.",
|
|
208
|
+
data: { projects: [], count: 0 },
|
|
209
|
+
next: "Call `project_create` with a name to make one."
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const names = rows.map((r) => `${r.name} (${r.id}, ${r.region})`).join(", ");
|
|
213
|
+
return {
|
|
214
|
+
summary: `${plural(rows.length, "project")}: ${names}.`,
|
|
215
|
+
data: { count: rows.length, projects: rows },
|
|
216
|
+
next: "Call `project_get` with one of these ids for its settings and origins."
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
var projectGet = {
|
|
221
|
+
name: "project_get",
|
|
222
|
+
description: 'Read one project: its name, region, project key, per-IP connection limit, tenant VM size and allowed origins. Read-only and cheap. The project id IS the public project key a game client joins with, so this is the tool that answers "what key does my client use".',
|
|
223
|
+
inputSchema: projectSchema(),
|
|
224
|
+
async run(ctx, args) {
|
|
225
|
+
const id = str(args, "project");
|
|
226
|
+
const project = await api(ctx).get(`/v1/projects/${id}`);
|
|
227
|
+
const origins = await api(ctx).get(`/v1/projects/${id}/origins`);
|
|
228
|
+
const limit = project.connectionsPerIpPerMin === null ? "the tenant default" : `${project.connectionsPerIpPerMin} per minute`;
|
|
229
|
+
const vmMem = project.vmMemMib === null ? "sized automatically" : `${project.vmMemMib} MiB`;
|
|
230
|
+
const shards = typeof project.shards === "number" ? project.shards : 1;
|
|
231
|
+
const machines = shards === 1 ? "one machine" : `${shards} machines (sharded)`;
|
|
232
|
+
const originList2 = origins.length === 0 ? "No browser origins are allowed yet, so a hosted page cannot connect." : `Allowed origins: ${origins.map((o) => o.origin).join(", ")}.`;
|
|
233
|
+
return {
|
|
234
|
+
summary: `${project.name} (${project.id}) in ${project.region}, running as ${machines}. Per-IP connection limit: ${limit}. Tenant VM: ${vmMem}. ${originList2} The project key your client joins with is ${project.id}.`,
|
|
235
|
+
data: {
|
|
236
|
+
project: {
|
|
237
|
+
id: project.id,
|
|
238
|
+
name: project.name,
|
|
239
|
+
region: project.region,
|
|
240
|
+
createdAt: project.createdAt,
|
|
241
|
+
connectionsPerIpPerMin: project.connectionsPerIpPerMin,
|
|
242
|
+
vmMemMib: project.vmMemMib,
|
|
243
|
+
shards
|
|
244
|
+
},
|
|
245
|
+
projectKey: project.id,
|
|
246
|
+
origins: origins.map((o) => o.origin)
|
|
247
|
+
},
|
|
248
|
+
next: origins.length === 0 ? "Call `origin_add` with the site that will host your client, or `deploy` to ship a room." : "Call `rooms` to see what is running, or `deploy` to ship a new version."
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
var originList = {
|
|
253
|
+
name: "origin_list",
|
|
254
|
+
description: "List the browser origins allowed to open a socket to this project. Read-only and cheap. An empty list means no hosted page can connect, which is the usual cause of a client that works locally and fails once deployed.",
|
|
255
|
+
inputSchema: projectSchema(),
|
|
256
|
+
async run(ctx, args) {
|
|
257
|
+
const id = str(args, "project");
|
|
258
|
+
const rows = await api(ctx).get(`/v1/projects/${id}/origins`);
|
|
259
|
+
return {
|
|
260
|
+
summary: rows.length === 0 ? `${id} allows no browser origins, so a hosted page cannot connect to it.` : `${id} allows ${plural(rows.length, "origin")}: ${rows.map((o) => o.origin).join(", ")}.`,
|
|
261
|
+
data: { project: id, count: rows.length, origins: rows.map((o) => o.origin) },
|
|
262
|
+
next: rows.length === 0 ? "Call `origin_add` with the origin that serves your client, like https://example.com." : "Call `origin_add` to allow another, or `origin_remove` to withdraw one."
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
var rooms = {
|
|
267
|
+
name: "rooms",
|
|
268
|
+
description: "List the rooms the control plane knows about for a project, with their status and when each was last seen. Read-only and cheap. A room appears here once it has been joined at least once, so an empty list on a fresh deploy is normal.",
|
|
269
|
+
inputSchema: projectSchema(),
|
|
270
|
+
async run(ctx, args) {
|
|
271
|
+
const id = str(args, "project");
|
|
272
|
+
const rows = await api(ctx).get(`/v1/projects/${id}/rooms`);
|
|
273
|
+
if (rows.length === 0) {
|
|
274
|
+
return {
|
|
275
|
+
summary: `${id} has no rooms on record. Nothing has joined one yet.`,
|
|
276
|
+
data: { project: id, count: 0, rooms: [] },
|
|
277
|
+
next: "Join a room from your client, or run `irtio simulate` to drive one, then call `rooms` again."
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
281
|
+
for (const r of rows) byStatus.set(r.status, (byStatus.get(r.status) ?? 0) + 1);
|
|
282
|
+
const breakdown = [...byStatus].map(([s, n]) => `${n} ${s}`).join(", ");
|
|
283
|
+
return {
|
|
284
|
+
summary: `${plural(rows.length, "room")} in ${id}: ${breakdown}.`,
|
|
285
|
+
data: { project: id, count: rows.length, rooms: rows },
|
|
286
|
+
next: "Call `logs` with a `room` filter for one room, or `save_list` to see its save generations."
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
var logs = {
|
|
291
|
+
name: "logs",
|
|
292
|
+
description: "Read a project's log lines, oldest first. Read-only and cheap. Pass `since` with the `at` of the last line you read to page forward, and `room` to filter to one room. The room id `__tenant` is the tenant process itself rather than any single room, which is where startup and placement failures show up.",
|
|
293
|
+
inputSchema: projectSchema({
|
|
294
|
+
since: {
|
|
295
|
+
type: "string",
|
|
296
|
+
description: "Cursor: the `at` timestamp of the last line you already read."
|
|
297
|
+
},
|
|
298
|
+
room: {
|
|
299
|
+
type: "string",
|
|
300
|
+
description: "Filter to one room id, or `__tenant` for the tenant process itself."
|
|
301
|
+
},
|
|
302
|
+
limit: { type: "number", description: "How many lines, 1 to 1000. Defaults to 100." }
|
|
303
|
+
}),
|
|
304
|
+
async run(ctx, args) {
|
|
305
|
+
const id = str(args, "project");
|
|
306
|
+
const since = optStr(args, "since");
|
|
307
|
+
const room = optStr(args, "room");
|
|
308
|
+
const limit = optInt(args, "limit");
|
|
309
|
+
const rows = await api(ctx).get(`/v1/projects/${id}/logs`, {
|
|
310
|
+
...since !== void 0 ? { since } : {},
|
|
311
|
+
...room !== void 0 ? { room } : {},
|
|
312
|
+
...limit !== void 0 ? { limit: String(limit) } : {}
|
|
313
|
+
});
|
|
314
|
+
const scope = room !== void 0 ? ` for room ${room}` : "";
|
|
315
|
+
if (rows.length === 0) {
|
|
316
|
+
return {
|
|
317
|
+
summary: `No log lines${scope}${since !== void 0 ? " after that cursor" : ""}.`,
|
|
318
|
+
data: { project: id, count: 0, entries: [], ...since !== void 0 ? { since } : {} },
|
|
319
|
+
next: "Nothing new. Call `logs` again with the same `since` after the room has done something."
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const errors = rows.filter((r) => r.level === "error").length;
|
|
323
|
+
const warnings = rows.filter((r) => r.level === "warn").length;
|
|
324
|
+
const cursor = rows[rows.length - 1]?.at;
|
|
325
|
+
const health = errors > 0 ? ` ${plural(errors, "line")} at error level.` : warnings > 0 ? ` ${plural(warnings, "line")} at warn level, none at error.` : " Nothing at warn or error level.";
|
|
326
|
+
return {
|
|
327
|
+
summary: `${plural(rows.length, "log line")}${scope}, oldest first.${health}`,
|
|
328
|
+
data: {
|
|
329
|
+
project: id,
|
|
330
|
+
count: rows.length,
|
|
331
|
+
errors,
|
|
332
|
+
warnings,
|
|
333
|
+
entries: rows,
|
|
334
|
+
...cursor !== void 0 ? { nextSince: cursor } : {}
|
|
335
|
+
},
|
|
336
|
+
next: cursor !== void 0 ? `Call \`logs\` again with since="${cursor}" to read what comes after this.` : "Call `logs` again to poll for more."
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
function tickHealth(snapshots) {
|
|
341
|
+
const perRoom = {};
|
|
342
|
+
for (const snapshot of snapshots) {
|
|
343
|
+
const entry = {};
|
|
344
|
+
for (const counter of ["overruns", "maxTickMs", "ticks"]) {
|
|
345
|
+
const value = snapshot.metrics[counter];
|
|
346
|
+
if (typeof value === "number") entry[counter] = value;
|
|
347
|
+
}
|
|
348
|
+
perRoom[snapshot.roomId] = entry;
|
|
349
|
+
}
|
|
350
|
+
const ids = Object.keys(perRoom);
|
|
351
|
+
if (ids.length === 0) {
|
|
352
|
+
return {
|
|
353
|
+
sentence: "No tick-health counters have arrived yet. They appear once a room has run and the host has shipped a metrics poll.",
|
|
354
|
+
perRoom
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const troubled = ids.filter((r) => (perRoom[r]?.overruns ?? 0) > 0);
|
|
358
|
+
if (troubled.length === 0) {
|
|
359
|
+
const worst = Math.max(...ids.map((r) => perRoom[r]?.maxTickMs ?? 0));
|
|
360
|
+
return {
|
|
361
|
+
sentence: `Tick health is clean across ${plural(ids.length, "room")}: zero overruns, worst single tick ${worst} ms.`,
|
|
362
|
+
perRoom
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const detail = troubled.map((r) => `room ${r}: ${plural(perRoom[r]?.overruns ?? 0, "overrun")}`).join("; ");
|
|
366
|
+
return {
|
|
367
|
+
sentence: `Tick overruns recorded. ${detail}. An overrun is a tick the loop fell behind on and dropped.`,
|
|
368
|
+
perRoom
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
var metrics = {
|
|
372
|
+
name: "metrics",
|
|
373
|
+
description: "Read a project's metric rollups, including per-room tick health (overruns and worst tick). Read-only and cheap. This is how you tell whether a deployed room is keeping up with its own tick rate, which no other tool reports for a deployed tenant.",
|
|
374
|
+
inputSchema: projectSchema(),
|
|
375
|
+
async run(ctx, args) {
|
|
376
|
+
const id = str(args, "project");
|
|
377
|
+
const [rows, roomSnapshots] = await Promise.all([
|
|
378
|
+
api(ctx).get(`/v1/projects/${id}/metrics`),
|
|
379
|
+
api(ctx).get(`/v1/projects/${id}/metrics/rooms`)
|
|
380
|
+
]);
|
|
381
|
+
if (rows.length === 0 && roomSnapshots.length === 0) {
|
|
382
|
+
return {
|
|
383
|
+
summary: `No metrics for ${id} yet. The host ships a rollup only once a tenant is running.`,
|
|
384
|
+
data: { project: id, count: 0, metrics: [] },
|
|
385
|
+
next: "Deploy a room and join it, then call `metrics` again."
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const health = tickHealth(roomSnapshots);
|
|
389
|
+
const latest = /* @__PURE__ */ new Map();
|
|
390
|
+
const seenAt = /* @__PURE__ */ new Map();
|
|
391
|
+
for (const row of rows) {
|
|
392
|
+
const at = seenAt.get(row.name);
|
|
393
|
+
if (at === void 0 || row.at >= at) {
|
|
394
|
+
latest.set(row.name, row.value);
|
|
395
|
+
seenAt.set(row.name, row.at);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
summary: `${plural(latest.size, "metric")} for ${id}, latest reading of each. ${health.sentence}`,
|
|
400
|
+
data: {
|
|
401
|
+
project: id,
|
|
402
|
+
count: rows.length,
|
|
403
|
+
latest: Object.fromEntries(latest),
|
|
404
|
+
tickHealth: health.perRoom
|
|
405
|
+
},
|
|
406
|
+
next: Object.keys(health.perRoom).length > 0 && Object.values(health.perRoom).some((r) => (r.overruns ?? 0) > 0) ? "Call `logs` to see what the room was doing, then reduce per-tick work or lower the tick rate." : "Call `logs` for the same project if you are chasing a specific event."
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
function formatBytes(bytes) {
|
|
411
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
412
|
+
let value = bytes;
|
|
413
|
+
let unit = 0;
|
|
414
|
+
while (value >= 1e3 && unit < units.length - 1) {
|
|
415
|
+
value /= 1e3;
|
|
416
|
+
unit++;
|
|
417
|
+
}
|
|
418
|
+
return `${unit === 0 ? String(Math.round(value)) : value.toFixed(2)} ${units[unit]}`;
|
|
419
|
+
}
|
|
420
|
+
var usage = {
|
|
421
|
+
name: "usage",
|
|
422
|
+
description: "Read a project's measured usage for the current billing period: room hours (with the physics engine each was run under), data out, voice minutes, and storage. Read-only and cheap. Each meter carries its signed rate and card-plan allowance, but these are measurements, not an invoice: only card plans bill overage (free plans stop at their caps), and meters nothing has reported come back as absent rather than as zero.",
|
|
423
|
+
inputSchema: projectSchema(),
|
|
424
|
+
async run(ctx, args) {
|
|
425
|
+
const id = str(args, "project");
|
|
426
|
+
const body = await api(ctx).get(`/v1/projects/${id}/usage`);
|
|
427
|
+
const reported = body.meters.filter((m) => m.present);
|
|
428
|
+
if (reported.length === 0) {
|
|
429
|
+
return {
|
|
430
|
+
summary: `No usage reported for ${id} in ${body.period.label}. Usage arrives once a host ships a window for a running tenant, so a project that has not been played is blank rather than zero.`,
|
|
431
|
+
data: { project: id, period: body.period, meters: body.meters },
|
|
432
|
+
next: "Deploy a room and join it, then call `usage` again."
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const parts = reported.map((m) => {
|
|
436
|
+
if (m.unit === "bytes") return `${m.label.toLowerCase()} ${formatBytes(m.used)}`;
|
|
437
|
+
if (m.unit === "hours") return `${m.used.toFixed(3)} room hours`;
|
|
438
|
+
return `${m.used.toFixed(2)} ${m.unit}`;
|
|
439
|
+
});
|
|
440
|
+
const absent = body.meters.filter((m) => !m.present).map((m) => m.label.toLowerCase());
|
|
441
|
+
return {
|
|
442
|
+
summary: `${id} in ${body.period.label}: ${parts.join(", ")}.` + (absent.length > 0 ? ` Not reported: ${absent.join(", ")}.` : "") + (body.plan === "card" ? " These are measurements; overage past the included bundle is billed at the rates in each meter's pricing block." : " These are measurements, not charges: the free plan stops at its caps instead of billing."),
|
|
443
|
+
data: {
|
|
444
|
+
project: id,
|
|
445
|
+
period: body.period,
|
|
446
|
+
meters: body.meters,
|
|
447
|
+
windowCount: body.windows.length
|
|
448
|
+
},
|
|
449
|
+
next: "Call `metrics` for tick health on the same project, or `usage` again after more play to watch a meter move."
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
var leaderboard = {
|
|
454
|
+
name: "leaderboard",
|
|
455
|
+
description: "Read a project's leaderboards: every board with its row count, the top N of one board, or the rows around one player with their rank. Read-only and cheap. Scores can only be posted by room code (room.leaderboard.submit), and deleting a board or a row is deliberately not available here: it is a destructive act that belongs to a human running `irtio leaderboard delete --yes` after reading what would go.",
|
|
456
|
+
inputSchema: projectSchema(
|
|
457
|
+
{
|
|
458
|
+
board: {
|
|
459
|
+
type: "string",
|
|
460
|
+
description: 'Board name, e.g. "scores". Omit it to list every board on the project with its row count and direction.'
|
|
461
|
+
},
|
|
462
|
+
limit: {
|
|
463
|
+
type: "integer",
|
|
464
|
+
description: "How many rows for a top-N read. Default 10, max 100."
|
|
465
|
+
},
|
|
466
|
+
cursor: {
|
|
467
|
+
type: "string",
|
|
468
|
+
description: "Continue a top-N read from a previous call\u2019s `nextCursor`. Opaque: pass it back exactly as it was given, and do not construct one."
|
|
469
|
+
},
|
|
470
|
+
around: {
|
|
471
|
+
type: "string",
|
|
472
|
+
description: "A player id. Given, the read returns that player\u2019s neighbours and absolute rank instead of the top of the board."
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
[]
|
|
476
|
+
),
|
|
477
|
+
async run(ctx, args) {
|
|
478
|
+
const id = str(args, "project");
|
|
479
|
+
const around = typeof args.around === "string" ? args.around : void 0;
|
|
480
|
+
const limit = typeof args.limit === "number" ? args.limit : void 0;
|
|
481
|
+
const cursor = typeof args.cursor === "string" && args.cursor !== "" ? args.cursor : void 0;
|
|
482
|
+
if (typeof args.board !== "string" || args.board === "") {
|
|
483
|
+
const listed = await api(ctx).get(
|
|
484
|
+
`/v1/projects/${id}/leaderboards`
|
|
485
|
+
);
|
|
486
|
+
if (listed.boards.length === 0) {
|
|
487
|
+
return {
|
|
488
|
+
summary: `${id} has no leaderboards yet.`,
|
|
489
|
+
data: { project: id, boards: [] },
|
|
490
|
+
next: "A board appears when room code calls room.leaderboard.submit; nothing else can write to one."
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
summary: `${id} has ${listed.boards.length} board(s): ` + listed.boards.map((b) => `${b.board} (${b.rows} row(s), ${b.direction})`).join(", "),
|
|
495
|
+
data: { project: id, boards: listed.boards },
|
|
496
|
+
next: "Call `leaderboard` again with `board` set to read one of them."
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
const board = args.board;
|
|
500
|
+
const query = new URLSearchParams();
|
|
501
|
+
if (limit !== void 0) query.set("limit", String(limit));
|
|
502
|
+
if (cursor !== void 0) query.set("cursor", cursor);
|
|
503
|
+
const q = query.toString();
|
|
504
|
+
const path = around !== void 0 ? `/v1/leaderboard/${id}/${encodeURIComponent(board)}/around/${encodeURIComponent(around)}` : `/v1/leaderboard/${id}/${encodeURIComponent(board)}/top${q === "" ? "" : `?${q}`}`;
|
|
505
|
+
const body = await api(ctx).get(path);
|
|
506
|
+
if (body.entries.length === 0) {
|
|
507
|
+
return {
|
|
508
|
+
summary: around !== void 0 ? `${around} has no score on ${board} in ${id}.` : `${board} in ${id} has no scores yet. A board fills up when room code calls room.leaderboard.submit; nothing else can write to it.`,
|
|
509
|
+
data: { project: id, board, direction: body.direction, entries: [] },
|
|
510
|
+
next: "Play a round that posts a score, then call `leaderboard` again."
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const leader = body.entries[0];
|
|
514
|
+
return {
|
|
515
|
+
summary: `${board} in ${id} (${body.direction} is better): ${body.entries.length} row(s), leading with ${leader?.playerId} on ${leader?.score}.` + (around !== void 0 ? ` ${around} is ranked ${String(body.rank)}.` : ""),
|
|
516
|
+
data: {
|
|
517
|
+
project: id,
|
|
518
|
+
board,
|
|
519
|
+
direction: body.direction,
|
|
520
|
+
rank: body.rank ?? null,
|
|
521
|
+
entries: body.entries,
|
|
522
|
+
// Opaque, and passed straight back through `cursor` to read the next page. Absent means
|
|
523
|
+
// this is the last page.
|
|
524
|
+
nextCursor: body.nextCursor ?? null
|
|
525
|
+
},
|
|
526
|
+
next: body.nextCursor !== void 0 ? "Call `leaderboard` again with `cursor` set to `nextCursor` for the next page." : "Call `leaderboard` with `around` set to a player id to see where they sit, or `usage` for what the project is costing."
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
var ratings = {
|
|
531
|
+
name: "ratings",
|
|
532
|
+
description: "Read a project's skill ratings: every queue with its row count, or one queue's ranking best first. Read-only and cheap. Ratings move only from room code (room.ratings.report), which is what makes them worth anything, and removing one is deliberately not available here: it is a destructive act that belongs to a human running `irtio ratings delete --yes`.",
|
|
533
|
+
inputSchema: projectSchema({
|
|
534
|
+
queue: {
|
|
535
|
+
type: "string",
|
|
536
|
+
description: 'Queue name, e.g. "ranked". Omit it to list every queue with ratings on the project.'
|
|
537
|
+
},
|
|
538
|
+
limit: { type: "integer", description: "How many rows. Default 20, max 100." },
|
|
539
|
+
cursor: {
|
|
540
|
+
type: "string",
|
|
541
|
+
description: "Continue from a previous call\u2019s `nextCursor`. Opaque: pass it back exactly as it was given, and do not construct one."
|
|
542
|
+
}
|
|
543
|
+
}),
|
|
544
|
+
async run(ctx, args) {
|
|
545
|
+
const id = str(args, "project");
|
|
546
|
+
if (typeof args.queue !== "string" || args.queue === "") {
|
|
547
|
+
const listed = await api(ctx).get(
|
|
548
|
+
`/v1/projects/${id}/ratings`
|
|
549
|
+
);
|
|
550
|
+
if (listed.queues.length === 0) {
|
|
551
|
+
return {
|
|
552
|
+
summary: `${id} has no rating queues yet.`,
|
|
553
|
+
data: { project: id, queues: [] },
|
|
554
|
+
next: "A rating appears when room code calls room.ratings.report; nothing else can write one."
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
summary: `${id} has ${listed.queues.length} queue(s): ` + listed.queues.map((q2) => `${q2.queue} (${q2.rows} rated, ${q2.skill ? "skill" : "fifo"})`).join(", "),
|
|
559
|
+
data: { project: id, queues: listed.queues },
|
|
560
|
+
next: "Call `ratings` again with `queue` set to read one of them."
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
const queue = args.queue;
|
|
564
|
+
const query = new URLSearchParams();
|
|
565
|
+
if (typeof args.limit === "number") query.set("limit", String(args.limit));
|
|
566
|
+
if (typeof args.cursor === "string" && args.cursor !== "") query.set("cursor", args.cursor);
|
|
567
|
+
const q = query.toString();
|
|
568
|
+
const body = await api(ctx).get(
|
|
569
|
+
`/v1/projects/${id}/ratings/${encodeURIComponent(queue)}${q === "" ? "" : `?${q}`}`
|
|
570
|
+
);
|
|
571
|
+
if (body.entries.length === 0) {
|
|
572
|
+
return {
|
|
573
|
+
summary: `${queue} in ${id} has no ratings yet.`,
|
|
574
|
+
data: { project: id, queue, entries: [] },
|
|
575
|
+
next: "Play a rated match that calls room.ratings.report, then call `ratings` again."
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const top = body.entries[0];
|
|
579
|
+
return {
|
|
580
|
+
summary: `${queue} in ${id}: ${body.entries.length} row(s), led by ${top?.playerId} on ${top?.rating.toFixed(0)} (deviation ${top?.deviation.toFixed(0)}, ${top?.games} game(s)).`,
|
|
581
|
+
data: { project: id, queue, entries: body.entries, nextCursor: body.nextCursor },
|
|
582
|
+
next: 'A deviation near 350 is a player nobody has measured yet, which is usually the answer to "why did the matchmaker pair those two". `match_status` shows who is queueing now.'
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
var matchStatus = {
|
|
587
|
+
name: "match_status",
|
|
588
|
+
description: "How many players are waiting in a project's quick-match queue right now, and how many it takes to fill. Read-only and cheap. This does NOT join the queue: queueing is a long poll that ends in a room code, which is the player's call to make.",
|
|
589
|
+
inputSchema: projectSchema({
|
|
590
|
+
queue: {
|
|
591
|
+
type: "string",
|
|
592
|
+
description: "Queue name. Omitted reads the project's default queue."
|
|
593
|
+
}
|
|
594
|
+
}),
|
|
595
|
+
async run(ctx, args) {
|
|
596
|
+
const id = str(args, "project");
|
|
597
|
+
const queue = typeof args.queue === "string" ? args.queue : void 0;
|
|
598
|
+
const body = await api(ctx).get(
|
|
599
|
+
`/match/status?project=${encodeURIComponent(id)}${queue !== void 0 ? `&queue=${encodeURIComponent(queue)}` : ""}`
|
|
600
|
+
);
|
|
601
|
+
return {
|
|
602
|
+
summary: `${body.waiting} of ${body.size} waiting in the ${body.queue} queue on ${id}` + (body.waiting === 0 ? " (nobody is queueing)." : "."),
|
|
603
|
+
data: { project: id, ...body },
|
|
604
|
+
next: "A queue that never fills is usually a game with no players rather than a broken matcher; `usage` says whether anyone has been playing at all."
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
var deploymentList = {
|
|
609
|
+
name: "deployment_list",
|
|
610
|
+
description: "List a project's deployments, newest first, with the schema hash and whether each was rolled back. Read-only and cheap. This is where you find the version number `rollback` takes.",
|
|
611
|
+
inputSchema: projectSchema(),
|
|
612
|
+
async run(ctx, args) {
|
|
613
|
+
const id = str(args, "project");
|
|
614
|
+
const rows = await api(ctx).get(`/v1/projects/${id}/deployments`);
|
|
615
|
+
if (rows.length === 0) {
|
|
616
|
+
return {
|
|
617
|
+
summary: `${id} has never been deployed.`,
|
|
618
|
+
data: { project: id, count: 0, deployments: [] },
|
|
619
|
+
next: "Call `deploy` from a directory holding an irtio.json and a room file."
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const serving = rows.find((d) => d.rolledBackAt == null);
|
|
623
|
+
const rolledBack = rows.filter((d) => d.rolledBackAt != null).length;
|
|
624
|
+
return {
|
|
625
|
+
summary: `${plural(rows.length, "deployment")} for ${id}, newest first. Serving v${serving?.version ?? rows[0]?.version}.` + (rolledBack > 0 ? ` ${plural(rolledBack, "version")} rolled back.` : ""),
|
|
626
|
+
data: {
|
|
627
|
+
project: id,
|
|
628
|
+
count: rows.length,
|
|
629
|
+
serving: serving?.version ?? null,
|
|
630
|
+
deployments: rows
|
|
631
|
+
},
|
|
632
|
+
next: "Call `rollback` with a version to re-promote it, or `deploy` to ship a new one."
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
var saveList = {
|
|
637
|
+
name: "save_list",
|
|
638
|
+
description: "List the save generations of one room, newest first, with their sizes. Read-only and cheap. Saves are listed from object storage rather than from the room, so this works even when the tenant is stopped, which is the case it is most useful in.",
|
|
639
|
+
inputSchema: projectSchema(
|
|
640
|
+
{ room: { type: "string", description: "The room id, as it appears in `rooms`." } },
|
|
641
|
+
["room"]
|
|
642
|
+
),
|
|
643
|
+
async run(ctx, args) {
|
|
644
|
+
const id = str(args, "project");
|
|
645
|
+
const room = str(args, "room");
|
|
646
|
+
const rows = await api(ctx).get(
|
|
647
|
+
`/v1/projects/${id}/rooms/${encodeURIComponent(room)}/saves`
|
|
648
|
+
);
|
|
649
|
+
if (rows.length === 0) {
|
|
650
|
+
return {
|
|
651
|
+
summary: `Room ${room} has no save generations.`,
|
|
652
|
+
data: { project: id, room, count: 0, saves: [] },
|
|
653
|
+
next: "Nothing to restore. Check the room id with `rooms`."
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
const newest = rows[0];
|
|
657
|
+
return {
|
|
658
|
+
summary: `${plural(rows.length, "save generation")} for room ${room}, newest first. The newest is ${newest?.saveId} from ${newest?.createdAt} at v${newest?.version}, ${newest?.bytes} bytes.`,
|
|
659
|
+
data: { project: id, room, count: rows.length, saves: rows },
|
|
660
|
+
next: "Call `save_restore` with one of these saveId values to put the room back to it."
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
var projectCreate = {
|
|
665
|
+
name: "project_create",
|
|
666
|
+
description: "Create a project. This writes: it registers a new project in your organisation and returns its id, which is also the public project key a client joins with. Cheap, and the first step before any deploy.",
|
|
667
|
+
inputSchema: {
|
|
668
|
+
type: "object",
|
|
669
|
+
properties: {
|
|
670
|
+
name: { type: "string", description: "A human-readable name. Not the id." },
|
|
671
|
+
region: {
|
|
672
|
+
type: "string",
|
|
673
|
+
description: "Placement region. Defaults to the control plane's own region."
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
required: ["name"],
|
|
677
|
+
additionalProperties: false
|
|
678
|
+
},
|
|
679
|
+
async run(ctx, args) {
|
|
680
|
+
const name = str(args, "name");
|
|
681
|
+
const region = optStr(args, "region");
|
|
682
|
+
const project = await api(ctx).post("/v1/projects", {
|
|
683
|
+
name,
|
|
684
|
+
...region !== void 0 ? { region } : {}
|
|
685
|
+
});
|
|
686
|
+
return {
|
|
687
|
+
summary: `Created ${project.name} in ${project.region}. Its id is ${project.id}, which is also the project key your client joins with. No origins are allowed yet, so a hosted page cannot connect until you add one.`,
|
|
688
|
+
data: {
|
|
689
|
+
project: project.id,
|
|
690
|
+
projectKey: project.id,
|
|
691
|
+
name: project.name,
|
|
692
|
+
region: project.region
|
|
693
|
+
},
|
|
694
|
+
next: `Call \`origin_add\` with the site that will serve your client, then \`deploy\` with project="${project.id}".`
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
var projectUpdate = {
|
|
699
|
+
name: "project_update",
|
|
700
|
+
description: "Change a project's name, its per-IP connection limit, or its tenant VM memory size. This writes. The limit caps how many connections one IP may open per minute; pass null to go back to the tenant default. Raising it is what a load test needs. The VM size is how much memory the tenant gets, in MiB; pass null for automatic sizing. Neither takes effect immediately: both apply at the tenant's next start.",
|
|
701
|
+
inputSchema: projectSchema({
|
|
702
|
+
name: { type: "string", description: "New name. Omit to leave it alone." },
|
|
703
|
+
connectionsPerIpPerMin: {
|
|
704
|
+
type: ["number", "null"],
|
|
705
|
+
description: "Connections per IP per minute, at least 1. null resets to the tenant default."
|
|
706
|
+
},
|
|
707
|
+
vmMemMib: {
|
|
708
|
+
type: ["number", "null"],
|
|
709
|
+
description: "Tenant VM memory in MiB, between 128 and 4096. null returns the project to automatic sizing (128 MiB, or 256 MiB when the schema declares physics). Raise it for a project whose tenant is being killed for running out of memory."
|
|
710
|
+
}
|
|
711
|
+
}),
|
|
712
|
+
async run(ctx, args) {
|
|
713
|
+
const id = str(args, "project");
|
|
714
|
+
const current = await api(ctx).get(`/v1/projects/${id}`);
|
|
715
|
+
const name = optStr(args, "name") ?? current.name;
|
|
716
|
+
const limitGiven = "connectionsPerIpPerMin" in args;
|
|
717
|
+
const limit = limitGiven ? args.connectionsPerIpPerMin : void 0;
|
|
718
|
+
const memGiven = "vmMemMib" in args;
|
|
719
|
+
const mem = memGiven ? args.vmMemMib : void 0;
|
|
720
|
+
const updated = await api(ctx).patch(`/v1/projects/${id}`, {
|
|
721
|
+
name,
|
|
722
|
+
...limitGiven ? { connectionsPerIpPerMin: limit } : {},
|
|
723
|
+
...memGiven ? { vmMemMib: mem } : {}
|
|
724
|
+
});
|
|
725
|
+
const changed = [];
|
|
726
|
+
if (updated.name !== current.name) changed.push(`name is now ${updated.name}`);
|
|
727
|
+
if (limitGiven) {
|
|
728
|
+
changed.push(
|
|
729
|
+
updated.connectionsPerIpPerMin === null ? "the per-IP connection limit is back to the tenant default" : `the per-IP connection limit is ${updated.connectionsPerIpPerMin} per minute`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
if (memGiven) {
|
|
733
|
+
changed.push(
|
|
734
|
+
updated.vmMemMib === null ? "the tenant VM is sized automatically again from its next cold boot" : `the tenant VM gets ${updated.vmMemMib} MiB from its next cold boot`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const timing = limitGiven && memGiven ? " Both apply at the next tenant start; a running tenant keeps its current limit and size until it is placed again." : limitGiven ? " The limit is carried in the tenant environment, so it applies from the next tenant start." : memGiven ? " A running tenant keeps its current size until it is placed again, because a VM cannot be resized underneath a live guest." : "";
|
|
738
|
+
return {
|
|
739
|
+
summary: changed.length === 0 ? `Nothing changed on ${id}.` : `Updated ${id}: ${changed.join(", ")}.` + timing,
|
|
740
|
+
data: {
|
|
741
|
+
project: updated.id,
|
|
742
|
+
name: updated.name,
|
|
743
|
+
connectionsPerIpPerMin: updated.connectionsPerIpPerMin,
|
|
744
|
+
vmMemMib: updated.vmMemMib,
|
|
745
|
+
changed
|
|
746
|
+
},
|
|
747
|
+
next: limitGiven || memGiven ? "Call `deploy` to restart the tenant so the new settings are in force." : "Call `project_get` to confirm the settings."
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
var originAdd = {
|
|
752
|
+
name: "origin_add",
|
|
753
|
+
description: "Allow one browser origin to connect to this project. This writes. An origin is a scheme and host, like https://example.com, with no path. Until at least one is allowed, a hosted page cannot open a socket.",
|
|
754
|
+
inputSchema: projectSchema(
|
|
755
|
+
{ origin: { type: "string", description: "Scheme and host, like https://example.com." } },
|
|
756
|
+
["origin"]
|
|
757
|
+
),
|
|
758
|
+
async run(ctx, args) {
|
|
759
|
+
const id = str(args, "project");
|
|
760
|
+
const origin = str(args, "origin");
|
|
761
|
+
const rows = await api(ctx).post(`/v1/projects/${id}/origins`, {
|
|
762
|
+
origin
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
summary: `${origin} may now connect to ${id}. ${plural(rows.length, "origin")} allowed in total.`,
|
|
766
|
+
data: { project: id, added: origin, origins: rows.map((o) => o.origin) },
|
|
767
|
+
next: "Call `deploy` to ship your room, or `origin_add` again for another site."
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
var originRemove = {
|
|
772
|
+
name: "origin_remove",
|
|
773
|
+
description: "Withdraw one browser origin. This writes, and it takes effect for new connections. Removing the last origin leaves the project unreachable from any hosted page.",
|
|
774
|
+
inputSchema: projectSchema(
|
|
775
|
+
{ origin: { type: "string", description: "The exact origin string to remove." } },
|
|
776
|
+
["origin"]
|
|
777
|
+
),
|
|
778
|
+
async run(ctx, args) {
|
|
779
|
+
const id = str(args, "project");
|
|
780
|
+
const origin = str(args, "origin");
|
|
781
|
+
const rows = await api(ctx).del(`/v1/projects/${id}/origins`, {
|
|
782
|
+
origin
|
|
783
|
+
});
|
|
784
|
+
return {
|
|
785
|
+
summary: `${origin} may no longer connect to ${id}. ` + (rows.length === 0 ? "No origins remain, so no hosted page can connect to this project." : `${plural(rows.length, "origin")} still allowed.`),
|
|
786
|
+
data: { project: id, removed: origin, origins: rows.map((o) => o.origin) },
|
|
787
|
+
next: rows.length === 0 ? "Call `origin_add` if that was not intended." : "Call `origin_list` to confirm what remains."
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
var jwtSecretMint = {
|
|
792
|
+
name: "jwt_secret_mint",
|
|
793
|
+
description: "Mint a JWT signing secret for a project. This writes, and it returns the plaintext secret exactly once: there is no route that reads one back, so store it now or mint another. At most two are active per issuer at a time, which is what makes a rotation safe.",
|
|
794
|
+
inputSchema: projectSchema({
|
|
795
|
+
issuer: {
|
|
796
|
+
type: "string",
|
|
797
|
+
description: "Issuer name, defaults to `main`. It becomes the namespace half of player ids."
|
|
798
|
+
}
|
|
799
|
+
}),
|
|
800
|
+
async run(ctx, args) {
|
|
801
|
+
const id = str(args, "project");
|
|
802
|
+
const issuer = optStr(args, "issuer");
|
|
803
|
+
const result = await api(ctx).post(
|
|
804
|
+
`/v1/projects/${id}/jwt-secret`,
|
|
805
|
+
{ ...issuer !== void 0 ? { issuer } : {} }
|
|
806
|
+
);
|
|
807
|
+
return {
|
|
808
|
+
summary: `Minted a signing secret for issuer ${result.issuer} on ${id}. ${result.active} of a possible 2 are now active. This plaintext is shown once and cannot be read back. Put it in your token server's environment, not in a config file you commit.`,
|
|
809
|
+
data: { project: id, issuer: result.issuer, active: result.active, secret: result.secret },
|
|
810
|
+
next: result.active === 2 ? "Sign new tokens with this secret, then call `jwt_secret_retire` to end the overlap." : "Sign your player tokens with this secret."
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
var jwtSecretRetire = {
|
|
815
|
+
name: "jwt_secret_retire",
|
|
816
|
+
description: "Retire the oldest active JWT signing secret for an issuer. This writes, and it is not reversible: tokens signed with the retired secret stop verifying. Run it only after every token issuer has moved to the newer secret.",
|
|
817
|
+
inputSchema: projectSchema({
|
|
818
|
+
issuer: { type: "string", description: "Issuer name, defaults to `main`." }
|
|
819
|
+
}),
|
|
820
|
+
async run(ctx, args) {
|
|
821
|
+
const id = str(args, "project");
|
|
822
|
+
const issuer = optStr(args, "issuer");
|
|
823
|
+
const result = await api(ctx).post(
|
|
824
|
+
`/v1/projects/${id}/jwt-secret/retire`,
|
|
825
|
+
{ ...issuer !== void 0 ? { issuer } : {} }
|
|
826
|
+
);
|
|
827
|
+
return {
|
|
828
|
+
summary: `Retired the oldest signing secret for issuer ${issuer ?? "main"} on ${id}. ${result.active} still active. Tokens signed with the retired secret no longer verify.`,
|
|
829
|
+
data: { project: id, issuer: issuer ?? "main", active: result.active },
|
|
830
|
+
next: "The rotation is finished. Call `jwt_secret_mint` when you next need to rotate."
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
var saveRestore = {
|
|
835
|
+
name: "save_restore",
|
|
836
|
+
description: "Put one room back to a named save generation. This writes and it is disruptive: the project's tenant is stopped without snapshotting, so EVERY room in the project drops its connections, not just this one. The others come back from their own saves unchanged. The room's current state is discarded, which is the point.",
|
|
837
|
+
inputSchema: projectSchema(
|
|
838
|
+
{
|
|
839
|
+
room: { type: "string", description: "The room id to restore." },
|
|
840
|
+
saveId: { type: "string", description: "A saveId from `save_list`." }
|
|
841
|
+
},
|
|
842
|
+
["room", "saveId"]
|
|
843
|
+
),
|
|
844
|
+
async run(ctx, args) {
|
|
845
|
+
const id = str(args, "project");
|
|
846
|
+
const room = str(args, "room");
|
|
847
|
+
const saveId = str(args, "saveId");
|
|
848
|
+
const result = await api(ctx).post(
|
|
849
|
+
`/v1/projects/${id}/rooms/${encodeURIComponent(room)}/restore`,
|
|
850
|
+
{ saveId }
|
|
851
|
+
);
|
|
852
|
+
const applied = result.applied === "stopped" ? "The tenant was running and has been stopped, so every room in this project dropped its connections. The restore lands on the next join." : "No tenant was running, so nothing was disconnected. The restore lands on the next placement.";
|
|
853
|
+
return {
|
|
854
|
+
summary: `Room ${room} in ${id} will come back from save ${saveId}. ${applied}`,
|
|
855
|
+
data: { project: id, room, saveId, applied: result.applied },
|
|
856
|
+
next: "Join the room, or run `irtio simulate`, to bring it up on the restored state, then call `logs` to confirm."
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
var rollback = {
|
|
861
|
+
name: "rollback",
|
|
862
|
+
description: "Re-promote an older deployment and bring every room that migrated past it back to its pre-migration state. This writes and it is disruptive: the tenant is stopped, so every room in the project drops its connections. Rooms with no pre-migration save are reported skipped by name rather than silently left alone.",
|
|
863
|
+
inputSchema: projectSchema(
|
|
864
|
+
{ version: { type: "number", description: "The deployment version to go back to." } },
|
|
865
|
+
["version"]
|
|
866
|
+
),
|
|
867
|
+
async run(ctx, args) {
|
|
868
|
+
const id = str(args, "project");
|
|
869
|
+
const version = optInt(args, "version");
|
|
870
|
+
if (version === void 0) throw new Error("`version` is required and must be a whole number");
|
|
871
|
+
const result = await api(ctx).post(`/v1/projects/${id}/rollback`, { version });
|
|
872
|
+
const restored = result.rooms.filter((r) => r.action === "restored");
|
|
873
|
+
const skipped = result.rooms.filter((r) => r.action === "skipped");
|
|
874
|
+
const skipNote = skipped.length > 0 ? ` ${plural(skipped.length, "room")} skipped (${skipped.map((r) => r.roomId).join(", ")}): no pre-migration state at that version.` : "";
|
|
875
|
+
return {
|
|
876
|
+
summary: `${id} is back on v${result.version}. ${plural(result.rolledBack, "newer deployment")} marked rolled back. ${plural(restored.length, "room")} restored to pre-migration state.${skipNote} ` + (result.applied === "stopped" ? "The tenant was stopped, so every room dropped its connections." : "No tenant was running, so nothing was disconnected."),
|
|
877
|
+
data: {
|
|
878
|
+
project: id,
|
|
879
|
+
version: result.version,
|
|
880
|
+
rolledBack: result.rolledBack,
|
|
881
|
+
applied: result.applied,
|
|
882
|
+
restored: restored.map((r) => r.roomId),
|
|
883
|
+
skipped: skipped.map((r) => ({ roomId: r.roomId, reason: r.reason }))
|
|
884
|
+
},
|
|
885
|
+
next: "Call `deployment_list` to confirm what is serving, then `logs` once a room has come back up."
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
var deploy = {
|
|
890
|
+
name: "deploy",
|
|
891
|
+
description: "Bundle the room in the working directory and ship it. This writes, it is the most expensive tool here, and it is disruptive: it builds a bundle locally, uploads it, and drains or migrates live rooms. The control plane decides authoritatively whether the schema change is additive or breaking; a breaking one is refused unless you supply a migration. Needs an irtio.json and a room file in `cwd`.",
|
|
892
|
+
inputSchema: {
|
|
893
|
+
type: "object",
|
|
894
|
+
properties: {
|
|
895
|
+
project: {
|
|
896
|
+
type: "string",
|
|
897
|
+
description: "Project id. Defaults to the one in irtio.json."
|
|
898
|
+
},
|
|
899
|
+
cwd: {
|
|
900
|
+
type: "string",
|
|
901
|
+
description: "Directory holding irtio.json and the room file. Defaults to the server's."
|
|
902
|
+
},
|
|
903
|
+
room: { type: "string", description: "Path to the room file, if it is not the default." },
|
|
904
|
+
strategy: {
|
|
905
|
+
type: "string",
|
|
906
|
+
enum: ["drain", "migrate"],
|
|
907
|
+
description: "drain closes rooms and lets them come back; migrate carries state forward."
|
|
908
|
+
},
|
|
909
|
+
allowBreaking: {
|
|
910
|
+
type: "boolean",
|
|
911
|
+
description: "Permit a breaking schema change. Only meaningful with a migration present."
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
additionalProperties: false
|
|
915
|
+
},
|
|
916
|
+
async run(ctx, args) {
|
|
917
|
+
const project = optStr(args, "project");
|
|
918
|
+
const cwd = optStr(args, "cwd") ?? ctx.cwd;
|
|
919
|
+
const room = optStr(args, "room");
|
|
920
|
+
const strategy = optStr(args, "strategy");
|
|
921
|
+
if (strategy !== void 0 && strategy !== "drain" && strategy !== "migrate") {
|
|
922
|
+
throw new Error('`strategy` must be "drain" or "migrate"');
|
|
923
|
+
}
|
|
924
|
+
const allowBreaking = args.allowBreaking === true;
|
|
925
|
+
const lines = [];
|
|
926
|
+
const result = await runDeploy({
|
|
927
|
+
cwd,
|
|
928
|
+
client: api(ctx),
|
|
929
|
+
log: (line) => lines.push(stripAnsi(line)),
|
|
930
|
+
// A deploy must never block on a prompt here: there is no human at this end of the pipe.
|
|
931
|
+
ask: async () => "",
|
|
932
|
+
...project !== void 0 ? { project } : {},
|
|
933
|
+
...room !== void 0 ? { room } : {},
|
|
934
|
+
...strategy !== void 0 ? { strategy } : {},
|
|
935
|
+
...allowBreaking ? { allowBreaking: true } : {}
|
|
936
|
+
});
|
|
937
|
+
const drained = result.rooms ?? [];
|
|
938
|
+
const applied = result.applied ?? "pending";
|
|
939
|
+
const appliedNote = applied === "live" ? drained.length === 0 ? "The running tenant took the new version, and no rooms were live to move." : `The running tenant took the new version. ${plural(drained.length, "room")} moved.` : "No tenant was running, so the new version is picked up on the next placement.";
|
|
940
|
+
return {
|
|
941
|
+
summary: `Deployed v${result.version} of ${result.project}. ${appliedNote} Playable at ${result.url}.`,
|
|
942
|
+
data: {
|
|
943
|
+
project: result.project,
|
|
944
|
+
version: result.version,
|
|
945
|
+
url: result.url,
|
|
946
|
+
applied,
|
|
947
|
+
draining: result.draining,
|
|
948
|
+
rooms: drained,
|
|
949
|
+
...result.site !== void 0 ? { site: result.site } : {},
|
|
950
|
+
output: lines
|
|
951
|
+
},
|
|
952
|
+
// D41: the verification step is now a scenario, which asserts against the server's own
|
|
953
|
+
// recorded timeline rather than reporting what the bots happened to see.
|
|
954
|
+
next: "Verify it: write a scenario and call `scenario_run` against `irtio dev`, then call `logs` and `metrics` for this project."
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
var DEFAULT_DEV_URL = "ws://localhost:7070";
|
|
959
|
+
var scenarioRun = {
|
|
960
|
+
name: "scenario_run",
|
|
961
|
+
local: true,
|
|
962
|
+
description: "Run a scenario file against a local `irtio dev` room and report every assertion against the recorded authoritative timeline. This is the verification step after a deploy or a room change. It runs against a dev server on this machine (ws://localhost:7070 by default), not against a deployed tenant, because only `irtio dev` exposes the authoritative timeline. It opens real sockets and plays the room for a few seconds, so it is not a cheap read. The built-in invariants run underneath and still gate the verdict.",
|
|
963
|
+
inputSchema: {
|
|
964
|
+
type: "object",
|
|
965
|
+
properties: {
|
|
966
|
+
scenario: {
|
|
967
|
+
type: "string",
|
|
968
|
+
description: "Path to the scenario module, relative to `cwd`. Its default export is defineScenario({ bots, script, assert }) from @irtio/bots."
|
|
969
|
+
},
|
|
970
|
+
cwd: {
|
|
971
|
+
type: "string",
|
|
972
|
+
description: "Project directory holding irtio/schema.ts. Defaults to the server cwd."
|
|
973
|
+
},
|
|
974
|
+
url: {
|
|
975
|
+
type: "string",
|
|
976
|
+
description: `The dev server socket URL. Default ${DEFAULT_DEV_URL}.`
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
required: ["scenario"],
|
|
980
|
+
additionalProperties: false
|
|
981
|
+
},
|
|
982
|
+
async run(ctx, args) {
|
|
983
|
+
const scenario = str(args, "scenario");
|
|
984
|
+
const cwd = optStr(args, "cwd") ?? ctx.cwd;
|
|
985
|
+
const url = optStr(args, "url") ?? DEFAULT_DEV_URL;
|
|
986
|
+
const lines = [];
|
|
987
|
+
const run = await runSimulation({
|
|
988
|
+
cwd,
|
|
989
|
+
scenario,
|
|
990
|
+
url,
|
|
991
|
+
log: (line) => lines.push(stripAnsi(line))
|
|
992
|
+
});
|
|
993
|
+
const section = run.scenario;
|
|
994
|
+
const assertions = section?.assertions ?? [];
|
|
995
|
+
const failed = assertions.filter((a) => !a.ok);
|
|
996
|
+
const brokenInvariants = run.report.invariants.filter((i) => !i.ok);
|
|
997
|
+
const verdict = failed.length === 0 && brokenInvariants.length === 0 ? `The scenario held. ${plural(assertions.length, "assertion")} passed against ${plural(section?.ticks ?? 0, "recorded tick")}, and every built-in invariant held.` : "The scenario failed. " + (failed.length > 0 ? `${plural(failed.length, "assertion")} did not hold: ${failed.map(
|
|
998
|
+
(a) => `${a.name}${a.tick !== void 0 ? ` at tick ${a.tick}` : ""}: ${a.detail}`
|
|
999
|
+
).join("; ")}. ` : "") + (brokenInvariants.length > 0 ? `Built-in invariants failed too: ${brokenInvariants.map((i) => i.name).join(", ")}.` : "");
|
|
1000
|
+
return {
|
|
1001
|
+
summary: verdict.trim(),
|
|
1002
|
+
data: {
|
|
1003
|
+
scenario: section?.file ?? scenario,
|
|
1004
|
+
room: run.report.roomId,
|
|
1005
|
+
url,
|
|
1006
|
+
exitCode: run.exitCode,
|
|
1007
|
+
ok: run.exitCode === 0,
|
|
1008
|
+
recordedTicks: section?.ticks ?? 0,
|
|
1009
|
+
droppedTicks: section?.dropped ?? 0,
|
|
1010
|
+
assertions: assertions.map((a) => ({
|
|
1011
|
+
name: a.name,
|
|
1012
|
+
ok: a.ok,
|
|
1013
|
+
...a.tick !== void 0 ? { tick: a.tick } : {},
|
|
1014
|
+
detail: a.detail
|
|
1015
|
+
})),
|
|
1016
|
+
invariants: run.report.invariants.map((i) => ({
|
|
1017
|
+
name: i.name,
|
|
1018
|
+
state: i.state,
|
|
1019
|
+
detail: i.detail
|
|
1020
|
+
})),
|
|
1021
|
+
...section !== void 0 ? { timeline: section.timelinePath } : {},
|
|
1022
|
+
trace: run.tracePath,
|
|
1023
|
+
output: lines
|
|
1024
|
+
},
|
|
1025
|
+
next: failed.length === 0 && brokenInvariants.length === 0 ? "Deploy it with `deploy`, or widen the scenario: add a bot, or assert on another tick." : `Read the recorded timeline at ${section?.timelinePath ?? run.tracePath} and the frame trace at ${run.tracePath}, then fix the room handler the failing assertion names.`
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
var ANSI = /\u001b\[[0-9;]*m/g;
|
|
1030
|
+
function stripAnsi(line) {
|
|
1031
|
+
return line.replace(ANSI, "");
|
|
1032
|
+
}
|
|
1033
|
+
var TOOLS = [
|
|
1034
|
+
whoami,
|
|
1035
|
+
projectList,
|
|
1036
|
+
projectGet,
|
|
1037
|
+
originList,
|
|
1038
|
+
rooms,
|
|
1039
|
+
logs,
|
|
1040
|
+
metrics,
|
|
1041
|
+
usage,
|
|
1042
|
+
leaderboard,
|
|
1043
|
+
matchStatus,
|
|
1044
|
+
ratings,
|
|
1045
|
+
deploymentList,
|
|
1046
|
+
saveList,
|
|
1047
|
+
projectCreate,
|
|
1048
|
+
projectUpdate,
|
|
1049
|
+
originAdd,
|
|
1050
|
+
originRemove,
|
|
1051
|
+
jwtSecretMint,
|
|
1052
|
+
jwtSecretRetire,
|
|
1053
|
+
saveRestore,
|
|
1054
|
+
rollback,
|
|
1055
|
+
deploy,
|
|
1056
|
+
scenarioRun
|
|
1057
|
+
];
|
|
1058
|
+
function toolByName(name) {
|
|
1059
|
+
return TOOLS.find((t) => t.name === name);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// src/server.ts
|
|
1063
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1064
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
1065
|
+
var SERVER_NAME = "irtio";
|
|
1066
|
+
var SERVER_VERSION = "0.5.2";
|
|
1067
|
+
var INSTRUCTIONS = "irtio hosts multiplayer rooms for web games. These tools reach the irtio control plane: create projects, allow browser origins, deploy rooms, and read rooms, logs, metrics and saves. Every tool needs a signed-in account. If a tool answers that you are not signed in, run `irtio login` in a terminal and call it again. Start with `whoami` or `project_list`.";
|
|
1068
|
+
function createMcpServer(options = {}) {
|
|
1069
|
+
const server = new Server(
|
|
1070
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
1071
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS }
|
|
1072
|
+
);
|
|
1073
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1074
|
+
tools: TOOLS.map((t) => ({
|
|
1075
|
+
name: t.name,
|
|
1076
|
+
description: t.description,
|
|
1077
|
+
inputSchema: t.inputSchema
|
|
1078
|
+
}))
|
|
1079
|
+
}));
|
|
1080
|
+
const asResult = (r) => ({ ...r });
|
|
1081
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1082
|
+
const name = request.params.name;
|
|
1083
|
+
const tool = toolByName(name);
|
|
1084
|
+
if (!tool) {
|
|
1085
|
+
return asResult({
|
|
1086
|
+
...narrate({
|
|
1087
|
+
summary: `There is no tool called \`${name}\` on this server.`,
|
|
1088
|
+
data: { error: "unknown_tool", tool: name, available: TOOLS.map((t) => t.name) },
|
|
1089
|
+
next: "Pick one of the tool names listed above."
|
|
1090
|
+
}),
|
|
1091
|
+
isError: true
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
try {
|
|
1095
|
+
const resolved = tool.local ? void 0 : await requireClient(options.env, options.controlUrl);
|
|
1096
|
+
const ctx = {
|
|
1097
|
+
client: resolved?.client,
|
|
1098
|
+
controlUrl: resolved?.auth.controlUrl ?? options.controlUrl ?? "",
|
|
1099
|
+
cwd: options.cwd ?? process.cwd()
|
|
1100
|
+
};
|
|
1101
|
+
const args = request.params.arguments ?? {};
|
|
1102
|
+
return asResult(narrate(await tool.run(ctx, args)));
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
return asResult(narrateError(err, name));
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
return server;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export {
|
|
1111
|
+
resolveAuth,
|
|
1112
|
+
NoCredentialError,
|
|
1113
|
+
requireClient,
|
|
1114
|
+
narrate,
|
|
1115
|
+
LOGIN_NARRATION,
|
|
1116
|
+
narrateError,
|
|
1117
|
+
TOOLS,
|
|
1118
|
+
toolByName,
|
|
1119
|
+
SERVER_NAME,
|
|
1120
|
+
SERVER_VERSION,
|
|
1121
|
+
createMcpServer
|
|
1122
|
+
};
|