@timetick/mcp 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,28 +4,696 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
 
7
- // src/tools/context.ts
7
+ // src/auth/credentials.ts
8
+ import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
9
+ import { homedir } from "os";
10
+ import { dirname, join } from "path";
11
+ var DEFAULT_API_ENDPOINT = "https://app.timetick.io/graphql";
12
+ function apiEndpoint() {
13
+ return process.env.TIMETICK_API_URL ?? DEFAULT_API_ENDPOINT;
14
+ }
15
+ function credentialFilePath() {
16
+ return process.env.TIMETICK_CREDENTIALS_PATH ?? join(homedir(), ".timetick", "credentials.json");
17
+ }
18
+ var cached;
19
+ async function loadCredentials(reload = false) {
20
+ if (cached !== void 0 && !reload) return cached;
21
+ const text = await readFile(credentialFilePath(), "utf8").catch(() => null);
22
+ const parsed = text ? safeParse(text) : null;
23
+ cached = parsed?.apiKey && parsed.endpoint === apiEndpoint() ? parsed : null;
24
+ return cached;
25
+ }
26
+ function safeParse(text) {
27
+ try {
28
+ return JSON.parse(text);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ function peekCredentials() {
34
+ return cached ?? null;
35
+ }
36
+ async function saveCredentials(credentials) {
37
+ const path = credentialFilePath();
38
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
39
+ await writeFile(path, `${JSON.stringify(credentials, null, 2)}
40
+ `, { mode: 384 });
41
+ await chmod(path, 384).catch(() => {
42
+ });
43
+ cached = credentials;
44
+ }
45
+ async function clearCredentials() {
46
+ cached = null;
47
+ await rm(credentialFilePath(), { force: true });
48
+ }
49
+
50
+ // src/instructions.ts
51
+ var SERVER_INSTRUCTIONS = `Timetick simulates EV chargers (OCPP 1.6 / 2.0.1) and runs tests against a CSMS: the user's own or Timetick's. Day-to-day work runs through these tools. The web portal is still needed for billing, API keys on Google or LinkedIn accounts, resetting a password, and security profiles (Basic Auth or certificates towards a CSMS).
52
+
53
+ Sign-in: call whoami. If not logged in, call login, which opens a local browser page for sign-in or sign-up. Never ask for passwords or API keys in chat.
54
+
55
+ Pick the setup from what is under test:
56
+ - The user's CSMS: simulators connect to it. create_endpoint type EXTERNAL with its public ws(s):// URL; Timetick connects from the cloud, so localhost or private addresses cannot work, and the CSMS must accept the simulator name as the charge point identity. Simulators perform charger-side steps automatically. A Remote: step waits up to 10 minutes for the user's CSMS to send that command, so tell the user what to send and when. It passes or fails on its own from expected results; without them use complete_test_step or fail_test_step.
57
+ - The user's real charger: hardware connects to Timetick's CSMS. create_hardware with a TIMETICK endpoint and a model, get_hardware shows the URL to configure on the charger. Timetick sends Remote: steps automatically; a person performs charger-side steps at the charger.
58
+
59
+ Flow: (1) establish protocol, target, scale and scenario, asking only what you cannot discover. (2) Check get_context: it says if the project is read-only and what to do. List before creating: project, endpoint, simulator config, OCPP config, manufacturer, model, simulators. New projects already have TIMETICK endpoints. Then power_on_simulator and check networkStatus with get_simulator. (3) Prefer list_test_templates + create_test_from_template for protocol scenarios, otherwise list_actions + create_test. Run with run_test, run_stress_test for load, or create_test_plan + launch_test_plan. (4) Follow with get_test_run; on failure read get_test_run_details and get_simulator_logs, form a hypothesis, fix, rerun. (5) Report what ran, pass or fail, failed steps with reasons and OCPP evidence, and the recommended fix.
60
+
61
+ A simulator or hardware runs one test at a time. Confirm before delete_* and before stopping a run you did not start. Never ask for ids you can look up with list_* tools.`;
62
+
63
+ // src/network.ts
64
+ import net from "net";
65
+ var CONNECT_ATTEMPT_TIMEOUT_MS = 2500;
66
+ function allowSlowConnections() {
67
+ net.setDefaultAutoSelectFamilyAttemptTimeout?.(CONNECT_ATTEMPT_TIMEOUT_MS);
68
+ }
69
+
70
+ // src/tools/auth.ts
8
71
  import { z } from "zod";
72
+ import { TimetickError as TimetickError3 } from "@timetick/sdk";
73
+
74
+ // src/auth/browser-login.ts
75
+ import { createServer } from "http";
76
+ import { spawn } from "child_process";
77
+ import { randomBytes } from "crypto";
78
+
79
+ // src/result.ts
80
+ import { TimetickError } from "@timetick/sdk";
81
+
82
+ // src/version.ts
83
+ import { readFileSync } from "fs";
84
+ var pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
85
+ var VERSION = pkg.version;
86
+ var CLIENT_HEADER = { "x-timetick-client": `mcp/${VERSION}` };
87
+ var REGISTRY_URL = "https://registry.npmjs.org/@timetick/mcp/latest";
88
+ var REGISTRY_TIMEOUT_MS = 2e3;
89
+ function isNewerVersion(candidate, current) {
90
+ const parts = (version) => version.split(".").map((part) => Number.parseInt(part, 10) || 0);
91
+ const a = parts(candidate);
92
+ const b = parts(current);
93
+ for (let i = 0; i < 3; i++) {
94
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
95
+ }
96
+ return false;
97
+ }
98
+ var latest;
99
+ function latestVersion() {
100
+ latest ??= fetch(REGISTRY_URL, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) }).then((res) => res.ok ? res.json() : null).then((body) => body?.version ?? null).catch(() => null);
101
+ return latest;
102
+ }
103
+ var UPDATE_HOW_TO = "To update, make sure the client config runs `npx -y @timetick/mcp@latest` and restart the client.";
104
+ async function updateNotice() {
105
+ const newest = await latestVersion();
106
+ if (!newest || !isNewerVersion(newest, VERSION)) return null;
107
+ return `A newer Timetick MCP server is available: ${newest} (running ${VERSION}). ${UPDATE_HOW_TO}`;
108
+ }
109
+
110
+ // src/result.ts
111
+ var KEY_RENAMES = {
112
+ chargerActions: "steps",
113
+ scenarioAction: "step",
114
+ scenarioOperationSubmissions: "stepSettings",
115
+ actionOperationId: "stepOperationId",
116
+ groupId: "stressTestId",
117
+ deviceMode: "mode"
118
+ };
119
+ var DROPPED_KEYS = /* @__PURE__ */ new Set(["__typename", "deviceType"]);
120
+ var ENTITY_TYPES = {
121
+ SCENARIO: "TEST",
122
+ SCENARIO_RUN: "TEST_RUN",
123
+ SCENARIO_ACTION: "TEST_STEP",
124
+ CHARGER: "SIMULATOR",
125
+ CHARGER_MODEL: "MODEL",
126
+ CHARGER_CONFIG: "SIMULATOR_CONFIG",
127
+ DEVICE: "HARDWARE"
128
+ };
129
+ var LIMIT_NAMES = {
130
+ Assets: "simulators",
131
+ "Asset Models": "models",
132
+ Endpoints: "endpoints",
133
+ "Project Users": "team members"
134
+ };
135
+ var VERBATIM_KEYS = /* @__PURE__ */ new Set([
136
+ "args",
137
+ "values",
138
+ "data",
139
+ "preset",
140
+ "schema",
141
+ "uiSchema",
142
+ "request",
143
+ "response",
144
+ "requestPayload",
145
+ "responsePayload",
146
+ "expectedRequest",
147
+ "expectedResponse",
148
+ "actualRequest",
149
+ "actualResponse",
150
+ "remoteStart",
151
+ "remoteStop",
152
+ "remoteStartV2x",
153
+ "commandsStrategies"
154
+ ]);
155
+ function publicKey(key) {
156
+ return KEY_RENAMES[key] ?? key.replace(/scenarioRunCharger/g, "testRunSimulator").replace(/ScenarioRunCharger/g, "TestRunSimulator").replace(/scenario/g, "test").replace(/Scenario/g, "Test").replace(/charger/g, "simulator").replace(/Charger/g, "Simulator").replace(/device/g, "hardware").replace(/Device/g, "Hardware");
157
+ }
158
+ var NOT_A_NAME = "(?![-_\\w])";
159
+ var MESSAGE_WORDS = [
160
+ [new RegExp(`\\bscenario runs?${NOT_A_NAME}`, "g"), "test run"],
161
+ [new RegExp(`\\bScenario runs?${NOT_A_NAME}`, "g"), "Test run"],
162
+ [new RegExp(`\\bscenario(s?)${NOT_A_NAME}`, "g"), "test$1"],
163
+ [new RegExp(`\\bScenario(s?)${NOT_A_NAME}`, "g"), "Test$1"],
164
+ [new RegExp(`\\brequirement(s?)${NOT_A_NAME}`, "g"), "slot$1"],
165
+ [new RegExp(`\\bRequirement(s?)${NOT_A_NAME}`, "g"), "Slot$1"],
166
+ [new RegExp(`(?<!(?:On|Off|Reset) )\\bCharger(s?)${NOT_A_NAME}`, "g"), "Simulator$1"],
167
+ [new RegExp(`\\bcharger(s?)${NOT_A_NAME}`, "g"), "simulator$1"],
168
+ [new RegExp(`\\bDevices?${NOT_A_NAME}`, "g"), "Hardware"],
169
+ [new RegExp(`\\bdevices?${NOT_A_NAME}`, "g"), "hardware"]
170
+ ];
171
+ function publicMessage(message) {
172
+ return MESSAGE_WORDS.reduce((text, [pattern, word]) => text.replace(pattern, word), message);
173
+ }
174
+ function cleanMarkup(text) {
175
+ if (typeof text !== "string") return "";
176
+ return text.replace(/<a\b[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, "$2 ($1)").replace(/<br\s*\/?>/gi, "\n").replace(/<\/?[a-zA-Z][^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
177
+ }
178
+ function listLines(items, style, start = 1, depth = 0) {
179
+ if (!Array.isArray(items)) return [];
180
+ const indent = " ".repeat(depth);
181
+ return items.flatMap((item, index) => {
182
+ const marker = style === "ordered" ? `${start + index}.` : "-";
183
+ if (typeof item === "string") return [`${indent}${marker} ${cleanMarkup(item)}`];
184
+ const check = style === "checklist" ? item?.meta?.checked ? "[x]" : "[ ]" : marker;
185
+ return [`${indent}${check} ${cleanMarkup(item?.content)}`, ...listLines(item?.items, style, 1, depth + 1)];
186
+ });
187
+ }
188
+ function blockLines({ type, data = {} }) {
189
+ switch (type) {
190
+ case "list":
191
+ return listLines(data.items, data.style, Number(data.meta?.start) || 1);
192
+ case "table":
193
+ return Array.isArray(data.content) ? data.content.map((row) => Array.isArray(row) ? row.map(cleanMarkup).join(" | ") : "") : [];
194
+ case "code":
195
+ return typeof data.code === "string" ? [data.code] : [];
196
+ case "raw":
197
+ return [cleanMarkup(data.html)];
198
+ case "warning":
199
+ return [data.title, data.message].map(cleanMarkup);
200
+ case "alert":
201
+ return [cleanMarkup(data.message)];
202
+ case "quote":
203
+ return [data.text, data.caption].map(cleanMarkup);
204
+ case "image":
205
+ return [cleanMarkup(data.caption) || "[image]"];
206
+ case "paragraph":
207
+ case "header":
208
+ return [cleanMarkup(data.text)];
209
+ default:
210
+ return [cleanMarkup(data.text) || `[${String(type)}]`];
211
+ }
212
+ }
213
+ function plainDescription(text) {
214
+ if (!text.startsWith("{")) return text;
215
+ try {
216
+ const { blocks } = JSON.parse(text);
217
+ if (!Array.isArray(blocks) || !blocks.every((block) => typeof block?.type === "string")) return text;
218
+ return blocks.flatMap(blockLines).filter((line) => line.length > 0).join("\n").trim();
219
+ } catch {
220
+ return text;
221
+ }
222
+ }
223
+ function toPublicVocabulary(value) {
224
+ if (Array.isArray(value)) return value.map(toPublicVocabulary);
225
+ if (value === null || typeof value !== "object" || value instanceof Date) return value;
226
+ return Object.fromEntries(
227
+ Object.entries(value).filter(([key]) => !DROPPED_KEYS.has(key)).map(([key, entry]) => {
228
+ if (key === "description" && typeof entry === "string") return [key, plainDescription(entry)];
229
+ if (key === "entityType" && typeof entry === "string") return [key, ENTITY_TYPES[entry] ?? entry];
230
+ return [publicKey(key), VERBATIM_KEYS.has(key) ? entry : toPublicVocabulary(entry)];
231
+ })
232
+ );
233
+ }
234
+ function ok(text, data) {
235
+ const body = data === void 0 ? text : `${text}
236
+
237
+ ${JSON.stringify(toPublicVocabulary(data), null, 2)}`;
238
+ return { content: [{ type: "text", text: body }] };
239
+ }
240
+ function fail(message) {
241
+ return { content: [{ type: "text", text: message }], isError: true };
242
+ }
243
+ function isContractError(err) {
244
+ if (err.code === "GRAPHQL_VALIDATION_FAILED") return true;
245
+ return err.code === "BAD_USER_INPUT" && /is not defined by type|was not provided/.test(err.message);
246
+ }
247
+ function describeScheduleConflict(err) {
248
+ const conflict = err.errors[0]?.extensions?.scheduleConflict ?? {};
249
+ const busy = (conflict.devices ?? []).filter((device) => device.isBusy).map((device) => device.name);
250
+ const runs = (conflict.conflictingRuns ?? []).map((run) => `${run.scenarioRunId} ("${run.name}")`);
251
+ const options = ["wait", "stop_test_run"];
252
+ if (conflict.availableResolutions?.includes("RESCHEDULE")) {
253
+ options.push(
254
+ `if this tool has onConflict: RESCHEDULE${conflict.firstFreeSlot ? ` (first free ${conflict.firstFreeSlot})` : ""}`
255
+ );
256
+ }
257
+ if (conflict.availableResolutions?.includes("FORCE_RUN")) {
258
+ options.push("if this tool has onConflict: FORCE_RUN, which stops those runs, so ask the user first");
259
+ }
260
+ return `Busy${busy.length ? `: ${busy.join(", ")}` : ""}, so nothing was started.` + (runs.length ? ` Held by test run ${runs.join(", ")}.` : "") + ` Options: ${options.join("; ")}.`;
261
+ }
262
+ function describeApiError(err) {
263
+ if (err.code === "SCHEDULE_CONFLICT") return describeScheduleConflict(err);
264
+ if (isContractError(err)) {
265
+ return `This version of the Timetick MCP server (${VERSION}) no longer matches the Timetick API, so the request was rejected before anything ran. ${UPDATE_HOW_TO} If it is already the newest version, report this to support@timetick.io. Details: ${err.message}`;
266
+ }
267
+ if (/not authenticated/i.test(err.message)) {
268
+ return process.env.TIMETICK_API_TOKEN ? "Timetick rejected the API key in TIMETICK_API_TOKEN (revoked, expired or disabled). Replace it in the client config." : "Timetick rejected the API key (revoked, expired or disabled). Call `whoami`, which picks up a key stored by another session; if it is still rejected, call `login`.";
269
+ }
270
+ if (/trial is not available/i.test(err.message)) {
271
+ return "This account has used its free trial, so this needs a paid plan, which is set up in the web portal.";
272
+ }
273
+ const limit = /^Project limit of (.+) is reached$/.exec(err.message);
274
+ if (limit) {
275
+ return `This project's plan has reached its limit of ${LIMIT_NAMES[limit[1]] ?? limit[1].toLowerCase()}. Remove some, or change the plan in the web portal.`;
276
+ }
277
+ if (/you do not have permission/i.test(err.message)) {
278
+ return "Timetick refused this: you do not have permission. Your role may not allow it, or the project may have no active plan, which get_context would report along with what to do.";
279
+ }
280
+ return `Timetick API error: ${publicMessage(err.message)}`;
281
+ }
282
+ var NETWORK_FAILURE = /fetch failed|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|socket hang up/i;
283
+ function isNetworkFailure(err) {
284
+ return !(err instanceof TimetickError) && NETWORK_FAILURE.test(err instanceof Error ? err.message : String(err));
285
+ }
286
+ function describeError(err) {
287
+ if (err instanceof TimetickError) return describeApiError(err);
288
+ const message = err instanceof Error ? err.message : String(err);
289
+ if (isNetworkFailure(err)) {
290
+ return `Could not reach Timetick (${message}). The request may or may not have been applied: if it created or started something, check with a list_* tool before retrying.`;
291
+ }
292
+ return message;
293
+ }
294
+ async function guard(fn) {
295
+ try {
296
+ return await fn();
297
+ } catch (err) {
298
+ return fail(describeError(err));
299
+ }
300
+ }
301
+
302
+ // src/auth/platform.ts
303
+ import { hostname } from "os";
304
+ import { TimetickError as TimetickError2 } from "@timetick/sdk";
305
+ async function request(operationName, query, variables, headers = {}) {
306
+ const res = await fetch(apiEndpoint(), {
307
+ method: "POST",
308
+ headers: { "content-type": "application/json", ...CLIENT_HEADER, ...headers },
309
+ body: JSON.stringify({ operationName, query, variables })
310
+ });
311
+ const body = await res.json().catch(() => null);
312
+ if (body?.errors?.length) {
313
+ throw new TimetickError2(body.errors);
314
+ }
315
+ if (!body?.data) {
316
+ throw new Error(
317
+ res.status >= 500 ? `Timetick is not responding properly (HTTP ${res.status}). Try again shortly.` : `${apiEndpoint()} did not answer like the Timetick API (HTTP ${res.status}). Check TIMETICK_API_URL.`
318
+ );
319
+ }
320
+ return { data: body.data, setCookie: res.headers.getSetCookie?.() ?? [] };
321
+ }
322
+ var SIGN_IN_MUTATION = `
323
+ mutation SignIn($email: String!, $password: String!) {
324
+ signIn(email: $email, password: $password) {
325
+ id
326
+ email
327
+ firstName
328
+ lastName
329
+ }
330
+ }
331
+ `;
332
+ var SIGN_UP_MUTATION = `
333
+ mutation SignUp($email: String!, $password: String!, $firstName: String, $lastName: String, $companyName: String) {
334
+ signUp(email: $email, password: $password, firstName: $firstName, lastName: $lastName, companyName: $companyName) {
335
+ id
336
+ }
337
+ }
338
+ `;
339
+ var CREATE_API_KEY_MUTATION = `
340
+ mutation CreateApiKey($name: String!) {
341
+ createApiKey(name: $name, enabled: true) {
342
+ apiKey { id name createdAt }
343
+ secretKey
344
+ }
345
+ }
346
+ `;
347
+ var DELETE_API_KEY_MUTATION = `
348
+ mutation DeleteApiKey($id: ID!) {
349
+ deleteApiKey(id: $id)
350
+ }
351
+ `;
352
+ var SESSION_USER_QUERY = `
353
+ query SessionUser {
354
+ currentUser {
355
+ id
356
+ email
357
+ firstName
358
+ lastName
359
+ companyName
360
+ }
361
+ }
362
+ `;
363
+ function extractSessionCookie(setCookie) {
364
+ for (const cookie of setCookie) {
365
+ const [pair] = cookie.split(";");
366
+ const [name, ...rest] = pair.split("=");
367
+ if (name?.trim() === "sessionID") {
368
+ const value = rest.join("=");
369
+ return value ? `sessionID=${value}` : null;
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+ async function signIn(email, password) {
375
+ const { data, setCookie } = await request("SignIn", SIGN_IN_MUTATION, { email, password });
376
+ if (!data.signIn) throw new Error("Sign-in failed: wrong email or password.");
377
+ const sessionCookie = extractSessionCookie(setCookie);
378
+ if (!sessionCookie) {
379
+ throw new Error("Signed in but the platform did not return a session cookie.");
380
+ }
381
+ return { sessionCookie, user: data.signIn };
382
+ }
383
+ async function signUp(input) {
384
+ await request("SignUp", SIGN_UP_MUTATION, input);
385
+ }
386
+ async function createApiKey(sessionCookie, name) {
387
+ const { data } = await request("CreateApiKey", CREATE_API_KEY_MUTATION, { name }, { cookie: sessionCookie });
388
+ const response = data.createApiKey;
389
+ if (!response?.secretKey || !response.apiKey?.id) {
390
+ throw new Error("The platform accepted the login but did not return an API key.");
391
+ }
392
+ return { id: response.apiKey.id, secretKey: response.secretKey };
393
+ }
394
+ async function deleteApiKey(apiKey, id) {
395
+ const { data } = await request("DeleteApiKey", DELETE_API_KEY_MUTATION, { id }, { "x-api-key": apiKey });
396
+ if (data.deleteApiKey === "ERROR") throw new Error("The platform could not delete the key.");
397
+ }
398
+ async function fetchSessionUser(apiKey) {
399
+ const { data } = await request("SessionUser", SESSION_USER_QUERY, {}, { "x-api-key": apiKey });
400
+ return data.currentUser ?? null;
401
+ }
402
+ function displayName(user) {
403
+ return [user.firstName, user.lastName].filter(Boolean).join(" ") || void 0;
404
+ }
405
+ async function revokeReplaced(previous, newKey) {
406
+ if (!previous?.keyId || previous.apiKey === newKey) return;
407
+ await deleteApiKey(previous.apiKey, previous.keyId).catch((err) => {
408
+ console.error(`Timetick MCP: could not revoke the replaced API key ${previous.keyId}: ${String(err)}`);
409
+ });
410
+ }
411
+ async function loginWithCredentials(email, password) {
412
+ const { sessionCookie, user } = await signIn(email, password);
413
+ const key = await createApiKey(sessionCookie, `MCP (${hostname()})`);
414
+ const previous = await loadCredentials(true);
415
+ const credentials = {
416
+ apiKey: key.secretKey,
417
+ endpoint: apiEndpoint(),
418
+ keyId: key.id,
419
+ email: user.email ?? email,
420
+ userName: displayName(user),
421
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
422
+ };
423
+ await saveCredentials(credentials);
424
+ await revokeReplaced(previous, credentials.apiKey);
425
+ return { credentials, keyId: key.id };
426
+ }
427
+ async function loginWithApiKey(apiKey) {
428
+ const user = await fetchSessionUser(apiKey).catch((err) => {
429
+ if (err instanceof TimetickError2 && /not authenticated/i.test(err.message)) return null;
430
+ throw err;
431
+ });
432
+ if (!user?.id) throw new Error("Timetick did not accept that API key.");
433
+ const previous = await loadCredentials(true);
434
+ const credentials = {
435
+ apiKey,
436
+ endpoint: apiEndpoint(),
437
+ email: user.email ?? void 0,
438
+ userName: displayName(user),
439
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
440
+ };
441
+ await saveCredentials(credentials);
442
+ await revokeReplaced(previous, apiKey);
443
+ return { credentials };
444
+ }
445
+ function portalUrl(path = "/") {
446
+ return new URL(path, apiEndpoint()).toString();
447
+ }
448
+
449
+ // src/auth/browser-login.ts
450
+ var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
451
+ var MAX_BODY_BYTES = 64 * 1024;
452
+ var TERMS_URL = "https://timetick.io/terms/";
453
+ var PRIVACY_URL = "https://timetick.io/privacy-policy/";
454
+ var EMAIL_RULE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(\.[a-zA-Z]{2,63})+$/;
455
+ var PASSWORD_RULE = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)\S{8,}$/;
456
+ var STYLE = `
457
+ body { font-family: -apple-system, "Segoe UI", sans-serif; background: #f6f7f9; display: flex;
458
+ justify-content: center; padding-top: 8vh; margin: 0; }
459
+ .card { background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.08);
460
+ padding: 2.2rem; width: 24rem; }
461
+ h1 { font-size: 1.2rem; margin: 0 0 .3rem; }
462
+ summary { cursor: pointer; font-weight: 600; font-size: .95rem; padding: .7rem 0 .3rem; }
463
+ p.sub, p.hint { color: #667; font-size: .8rem; margin: .2rem 0 .8rem; }
464
+ label { display: block; font-size: .78rem; color: #445; margin: .7rem 0 .25rem; }
465
+ input { width: 100%; box-sizing: border-box; padding: .55rem .7rem; font-size: .95rem;
466
+ border: 1px solid #ccd; border-radius: 8px; }
467
+ input[type=checkbox] { width: auto; margin-right: .4rem; }
468
+ button { margin-top: 1.1rem; width: 100%; padding: .6rem; font-size: .95rem; color: #fff;
469
+ background: #2563eb; border: 0; border-radius: 8px; cursor: pointer; }
470
+ .error { background: #fee2e2; color: #991b1b; border-radius: 8px; padding: .6rem .8rem; font-size: .85rem; }
471
+ .notice { background: #dcfce7; color: #166534; border-radius: 8px; padding: .6rem .8rem; font-size: .85rem; }
472
+ `;
473
+ function shell(title, body) {
474
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><style>${STYLE}</style></head>
475
+ <body><div class="card">${body}</div></body></html>`;
476
+ }
477
+ function escapeHtml(text) {
478
+ return text.replace(
479
+ /[&<>"']/g,
480
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
481
+ );
482
+ }
483
+ function page(state, tab, message) {
484
+ return shell(
485
+ "Timetick \u2014 sign in",
486
+ `<h1>Connect your AI client to Timetick</h1>
487
+ <p class="sub">This page runs on your computer. What you type goes to the Timetick MCP server on this
488
+ machine and from there to Timetick. It is not shared with the AI client or its conversation.</p>
489
+ ${message ? `<div class="${message.kind}">${escapeHtml(message.text)}</div>` : ""}
490
+ <details ${tab === "signin" ? "open" : ""}><summary>Sign in</summary>
491
+ <form method="post" action="/${state}/signin">
492
+ <label for="email">Email</label><input id="email" name="email" type="email" required>
493
+ <label for="password">Password</label><input id="password" name="password" type="password" required>
494
+ <button type="submit">Sign in</button>
495
+ </form>
496
+ <p class="hint"><a href="${escapeHtml(portalUrl("/reset-password"))}" target="_blank" rel="noreferrer">Forgot your password?</a></p>
497
+ </details>
498
+ <details ${tab === "signup" ? "open" : ""}><summary>Create an account</summary>
499
+ <form method="post" action="/${state}/signup">
500
+ <label for="new-email">Work email</label><input id="new-email" name="email" type="email" required>
501
+ <label for="new-password">Password (8+ characters with upper and lower case and a digit)</label>
502
+ <input id="new-password" name="password" type="password" required>
503
+ <label for="passwordConfirm">Confirm password</label>
504
+ <input id="passwordConfirm" name="passwordConfirm" type="password" required>
505
+ <label for="firstName">First name</label><input id="firstName" name="firstName" required minlength="2" maxlength="50">
506
+ <label for="lastName">Last name</label><input id="lastName" name="lastName" required minlength="2" maxlength="50">
507
+ <label for="companyName">Company</label><input id="companyName" name="companyName" required minlength="2" maxlength="50">
508
+ <label><input name="agree" type="checkbox" required>I agree with the
509
+ <a href="${TERMS_URL}" target="_blank" rel="noreferrer">Terms and Conditions</a> and the
510
+ <a href="${PRIVACY_URL}" target="_blank" rel="noreferrer">Privacy Policy</a></label>
511
+ <button type="submit">Create account</button>
512
+ </form>
513
+ </details>
514
+ <details ${tab === "apikey" ? "open" : ""}><summary>Signed up with Google or LinkedIn</summary>
515
+ <p class="hint">Sign in to the <a href="${escapeHtml(portalUrl())}" target="_blank" rel="noreferrer">
516
+ Timetick portal</a>, open your project, go to API keys, create one and paste it here.</p>
517
+ <form method="post" action="/${state}/apikey">
518
+ <label for="apiKey">API key</label><input id="apiKey" name="apiKey" type="password" required>
519
+ <button type="submit">Use this key</button>
520
+ </form>
521
+ </details>`
522
+ );
523
+ }
524
+ var donePage = (title, message) => shell("Timetick", `<h1>${title}</h1><p class="sub">${message}</p>`);
525
+ function openBrowser(url) {
526
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
527
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
528
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
529
+ child.on("error", () => {
530
+ });
531
+ child.unref();
532
+ }
533
+ async function readForm(req) {
534
+ const chunks = [];
535
+ let size = 0;
536
+ for await (const chunk of req) {
537
+ size += chunk.length;
538
+ if (size > MAX_BODY_BYTES) throw new Error("The form is too large.");
539
+ chunks.push(chunk);
540
+ }
541
+ const out = {};
542
+ for (const [key, value] of new URLSearchParams(Buffer.concat(chunks).toString("utf8"))) {
543
+ out[key] = key === "password" || key === "passwordConfirm" ? value : value.trim();
544
+ }
545
+ return out;
546
+ }
547
+ function sendHtml(res, status, html) {
548
+ res.writeHead(status, {
549
+ "content-type": "text/html; charset=utf-8",
550
+ "cache-control": "no-store",
551
+ "referrer-policy": "no-referrer",
552
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'"
553
+ });
554
+ res.end(html);
555
+ }
556
+ function validateSignUp(form) {
557
+ if (!EMAIL_RULE.test(form.email ?? "")) throw new Error("Check the email address.");
558
+ if (!PASSWORD_RULE.test(form.password ?? "")) {
559
+ throw new Error("The password needs 8 or more characters with upper and lower case and a digit, and no spaces.");
560
+ }
561
+ if (form.password !== form.passwordConfirm) throw new Error("The passwords do not match.");
562
+ const named = (value, label) => {
563
+ if (!value || value.length < 2 || value.length > 50) throw new Error(`${label} must be 2 to 50 characters.`);
564
+ return value;
565
+ };
566
+ const account = {
567
+ email: form.email,
568
+ password: form.password,
569
+ firstName: named(form.firstName, "First name"),
570
+ lastName: named(form.lastName, "Last name"),
571
+ companyName: named(form.companyName, "Company")
572
+ };
573
+ if (form.agree !== "on") throw new Error("Please agree with the Terms and Conditions and the Privacy Policy.");
574
+ return account;
575
+ }
576
+ var pending = null;
577
+ async function startBrowserLogin(onLogin) {
578
+ if (pending) {
579
+ const login = await pending;
580
+ openBrowser(login.url);
581
+ return login;
582
+ }
583
+ pending = openLoginPage(onLogin).catch((err) => {
584
+ pending = null;
585
+ throw err;
586
+ });
587
+ return pending;
588
+ }
589
+ async function openLoginPage(onLogin) {
590
+ const state = randomBytes(16).toString("hex");
591
+ let resolveDone;
592
+ let rejectDone;
593
+ const done = new Promise((resolve, reject) => {
594
+ resolveDone = resolve;
595
+ rejectDone = reject;
596
+ });
597
+ done.catch(() => {
598
+ });
599
+ let host = "";
600
+ const server = createServer(async (req, res) => {
601
+ const [, token, action] = (req.url ?? "/").split("?")[0].split("/");
602
+ if (req.headers.host !== host || token !== state) {
603
+ return sendHtml(res, 404, donePage("Page expired", "Call the login tool again."));
604
+ }
605
+ if (req.method === "GET" && !action) return sendHtml(res, 200, page(state, "signin"));
606
+ if (req.method !== "POST") return sendHtml(res, 405, donePage("Not allowed", "Unsupported request."));
607
+ const tab = action === "signup" ? "signup" : action === "apikey" ? "apikey" : "signin";
608
+ try {
609
+ const form = await readForm(req);
610
+ if (action === "signup") {
611
+ await signUp(validateSignUp(form));
612
+ return sendHtml(res, 200, page(state, "signin", {
613
+ kind: "notice",
614
+ text: "Account created. Open the confirmation link we emailed you, then sign in here."
615
+ }));
616
+ }
617
+ let result;
618
+ if (action === "apikey") {
619
+ if (!form.apiKey) throw new Error("Paste an API key.");
620
+ result = await loginWithApiKey(form.apiKey);
621
+ } else {
622
+ if (!form.email || !form.password) throw new Error("Email and password are required.");
623
+ result = await loginWithCredentials(form.email, form.password);
624
+ }
625
+ sendHtml(res, 200, donePage("Connected", "You can close this window and return to your AI client."));
626
+ finish();
627
+ onLogin(result);
628
+ resolveDone(result);
629
+ } catch (err) {
630
+ sendHtml(res, 400, page(state, tab, {
631
+ kind: "error",
632
+ text: isNetworkFailure(err) ? "Could not reach Timetick. Check your connection and try again." : describeError(err)
633
+ }));
634
+ }
635
+ });
636
+ const timer = setTimeout(() => {
637
+ finish();
638
+ rejectDone(new Error("Sign-in timed out after 10 minutes. Call the login tool to try again."));
639
+ }, LOGIN_TIMEOUT_MS);
640
+ timer.unref();
641
+ function finish() {
642
+ clearTimeout(timer);
643
+ server.close();
644
+ server.closeIdleConnections?.();
645
+ pending = null;
646
+ }
647
+ await new Promise((resolve, reject) => {
648
+ server.once("error", (err) => {
649
+ clearTimeout(timer);
650
+ reject(new Error(`Could not start the local sign-in page: ${err.message}`));
651
+ });
652
+ server.listen(0, "127.0.0.1", resolve);
653
+ });
654
+ server.unref();
655
+ const address = server.address();
656
+ if (!address || typeof address === "string") {
657
+ finish();
658
+ throw new Error("Could not determine the local sign-in page address.");
659
+ }
660
+ host = `127.0.0.1:${address.port}`;
661
+ const url = `http://${host}/${state}`;
662
+ openBrowser(url);
663
+ console.error(`Timetick MCP: waiting for sign-in at ${url}`);
664
+ return { url, done };
665
+ }
9
666
 
10
667
  // src/sdk.ts
11
668
  import { createClient } from "@timetick/sdk";
669
+ var NOT_LOGGED_IN_MESSAGE = "Not logged in. Call the `login` tool: it opens a sign-in page in the local browser. On a machine without a browser, set the TIMETICK_API_TOKEN environment variable instead.";
670
+ function authStatus() {
671
+ const envKey = process.env.TIMETICK_API_TOKEN;
672
+ if (envKey) return { source: "env", apiKey: envKey };
673
+ const stored = peekCredentials();
674
+ if (stored) return { source: "stored", apiKey: stored.apiKey, email: stored.email };
675
+ return { source: "none", apiKey: null };
676
+ }
12
677
  var client = null;
13
678
  function sdk() {
14
679
  if (client) return client;
15
- const apiKey = process.env.TIMETICK_API_TOKEN;
16
- if (!apiKey) {
17
- throw new Error(
18
- "TIMETICK_API_TOKEN is not set. Generate an API key in the Timetick portal (Account \u2192 API keys) and expose it to the MCP server as TIMETICK_API_TOKEN."
19
- );
20
- }
21
- client = createClient({ apiKey, endpoint: process.env.TIMETICK_API_URL });
680
+ const status = authStatus();
681
+ if (!status.apiKey) throw new Error(NOT_LOGGED_IN_MESSAGE);
682
+ client = createClient({
683
+ apiKey: status.apiKey,
684
+ endpoint: process.env.TIMETICK_API_URL,
685
+ headers: CLIENT_HEADER
686
+ });
22
687
  return client;
23
688
  }
689
+ function resetSdk() {
690
+ client = null;
691
+ }
24
692
 
25
693
  // src/context.ts
26
694
  var ContextError = class extends Error {
27
695
  };
28
- var cached = null;
696
+ var cached2 = null;
29
697
  async function buildContext(projectId, projectName, source) {
30
698
  const worlds = await sdk().simWorlds.list(projectId);
31
699
  const world = worlds?.[0];
@@ -51,10 +719,16 @@ function formatProjects(projects) {
51
719
  }
52
720
  async function findProject(project) {
53
721
  const projects = await sdk().projects.list();
54
- const needle = project.toLowerCase();
55
- const match = projects?.find(
56
- (p) => p?.id === project || p?.name?.toLowerCase() === needle
57
- );
722
+ const needle = project.trim().toLowerCase();
723
+ const exact = projects?.filter((p) => p?.name?.trim() === project.trim()) ?? [];
724
+ const named = exact.length === 1 ? exact : projects?.filter((p) => p?.name?.trim().toLowerCase() === needle) ?? [];
725
+ if (named.length > 1 && !projects?.some((p) => p?.id === project.trim())) {
726
+ throw new ContextError(
727
+ `${named.length} projects are named "${project}". Use the id instead:
728
+ ${formatProjects(named)}`
729
+ );
730
+ }
731
+ const match = projects?.find((p) => p?.id === project.trim()) ?? named[0];
58
732
  if (!match?.id) {
59
733
  throw new ContextError(
60
734
  `No project matching "${project}". Available:
@@ -64,10 +738,10 @@ ${formatProjects(projects)}`
64
738
  return { id: match.id, name: match.name ?? "(unnamed)" };
65
739
  }
66
740
  async function getContext() {
67
- if (cached) return cached;
741
+ if (cached2) return cached2;
68
742
  const projects = await sdk().projects.list();
69
743
  if (!projects || projects.length === 0) {
70
- throw new ContextError("No projects are available for this API key.");
744
+ throw new ContextError("This account has no projects yet. Create one with create_project.");
71
745
  }
72
746
  if (projects.length > 1) {
73
747
  throw new ContextError(
@@ -76,56 +750,180 @@ ${formatProjects(projects)}`
76
750
  );
77
751
  }
78
752
  const only = projects[0];
79
- cached = await buildContext(only.id, only.name ?? "(unnamed)", "single-project");
80
- return cached;
753
+ cached2 = await buildContext(only.id, only.name ?? "(unnamed)", "single-project");
754
+ return cached2;
81
755
  }
82
756
  async function useProject(input) {
83
757
  const match = await findProject(input.project);
84
- cached = await buildContext(match.id, match.name, "explicit");
85
- return cached;
758
+ cached2 = await buildContext(match.id, match.name, "explicit");
759
+ return cached2;
86
760
  }
87
761
  async function lookupProject(project) {
88
762
  const match = await findProject(project);
89
763
  return buildContext(match.id, match.name, "explicit");
90
764
  }
765
+ function resetContext() {
766
+ cached2 = null;
767
+ }
91
768
  function peekContext() {
92
- return cached;
769
+ return cached2;
93
770
  }
94
771
 
95
- // src/result.ts
96
- import { TimetickError } from "@timetick/sdk";
97
- function ok(text, data) {
98
- const body = data === void 0 ? text : `${text}
99
-
100
- ${JSON.stringify(data, null, 2)}`;
101
- return { content: [{ type: "text", text: body }] };
772
+ // src/tools/auth.ts
773
+ var LOGIN_WAIT_MS = 45 * 1e3;
774
+ function afterAuthChange() {
775
+ resetSdk();
776
+ resetContext();
102
777
  }
103
- function fail(message) {
104
- return { content: [{ type: "text", text: message }], isError: true };
778
+ function loggedInMessage({ credentials }) {
779
+ const who = credentials.userName ?? credentials.email ?? "unknown user";
780
+ return `Logged in as ${who}. The API key is stored at ${credentialFilePath()}; every tool now works.`;
105
781
  }
106
- async function guard(fn) {
107
- try {
108
- return await fn();
109
- } catch (err) {
110
- if (err instanceof TimetickError) return fail(`Timetick API error: ${err.message}`);
111
- return fail(err instanceof Error ? err.message : String(err));
112
- }
782
+ function registerAuthTools(server, target = server) {
783
+ server.registerTool(
784
+ "login",
785
+ {
786
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
787
+ title: "Log in to Timetick",
788
+ description: "Sign in or create a Timetick account and store an API key locally, so every other tool works without the web portal. Opens a one-time page in the local browser, where the user signs in, signs up or pastes an API key. Credentials never pass through this conversation, so never ask for them in chat. If sign-in is not finished within 45 seconds this returns the page address: show it to the user, wait until they say they are done, then call whoami. Needed once per machine, or again to switch to another account, which replaces the stored key.",
789
+ inputSchema: {}
790
+ },
791
+ async () => guard(async () => {
792
+ if (process.env.TIMETICK_API_TOKEN) {
793
+ return fail(
794
+ "TIMETICK_API_TOKEN is set in the client config and always wins, so a login here would have no effect. Remove the variable and restart the client to use login."
795
+ );
796
+ }
797
+ const login = await startBrowserLogin(afterAuthChange);
798
+ const finished = await Promise.race([
799
+ login.done,
800
+ new Promise((resolve) => setTimeout(() => resolve(null), LOGIN_WAIT_MS).unref())
801
+ ]);
802
+ if (finished) return ok(loggedInMessage(finished));
803
+ return ok(
804
+ `The sign-in page is open at ${login.url} (only reachable from this machine; open it manually if no browser window appeared). It stays open for 10 minutes. Ask the user to finish there, then call whoami to confirm.`
805
+ );
806
+ })
807
+ );
808
+ server.registerTool(
809
+ "logout",
810
+ {
811
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
812
+ title: "Log out of Timetick",
813
+ description: "Revoke the locally stored API key on the Timetick platform and delete it from this machine. Does nothing to a key provided via the TIMETICK_API_TOKEN environment variable.",
814
+ inputSchema: {}
815
+ },
816
+ async () => guard(async () => {
817
+ await loadCredentials(true);
818
+ const status = authStatus();
819
+ if (status.source === "none") return fail("Already logged out: no stored credentials.");
820
+ if (status.source === "env") {
821
+ return fail(
822
+ "Authentication comes from the TIMETICK_API_TOKEN environment variable, so logout can't revoke it. Remove the variable from the client config instead."
823
+ );
824
+ }
825
+ const stored = await loadCredentials();
826
+ let revocation = "The key was pasted in by hand, so it stays active until you delete it in the portal.";
827
+ if (stored?.keyId) {
828
+ revocation = await deleteApiKey(stored.apiKey, stored.keyId).then(
829
+ () => "The API key was revoked on the platform.",
830
+ (err) => `The API key could NOT be revoked (${err instanceof Error ? err.message : String(err)}); delete key ${stored.keyId} in the portal.`
831
+ );
832
+ }
833
+ await clearCredentials();
834
+ afterAuthChange();
835
+ return ok(`Logged out and removed the key from ${credentialFilePath()}. ${revocation}`);
836
+ })
837
+ );
838
+ server.registerTool(
839
+ "whoami",
840
+ {
841
+ annotations: { readOnlyHint: true },
842
+ title: "Show authentication status",
843
+ description: "Show who the MCP server is authenticated as, where the API key came from, the server version, and whether a newer version is available. Also picks up a login finished in the browser.",
844
+ inputSchema: {}
845
+ },
846
+ async () => guard(async () => {
847
+ const before = authStatus().apiKey;
848
+ await loadCredentials(true);
849
+ const status = authStatus();
850
+ if (status.apiKey !== before) afterAuthChange();
851
+ const notice = await updateNotice();
852
+ const footer = `Timetick MCP ${VERSION}.${notice ? ` ${notice}` : ""}`;
853
+ if (!status.apiKey) {
854
+ return ok(
855
+ `Not logged in: no API key found (looked at TIMETICK_API_TOKEN and ${credentialFilePath()}). Call the login tool to authenticate.
856
+ ${footer}`
857
+ );
858
+ }
859
+ const user = await fetchSessionUser(status.apiKey).catch((err) => {
860
+ if (err instanceof TimetickError3 && /not authenticated/i.test(err.message)) return null;
861
+ throw err;
862
+ });
863
+ if (!user?.id) {
864
+ const next = status.source === "env" ? "Replace TIMETICK_API_TOKEN in the client config." : "Call the login tool to sign in again.";
865
+ return fail(`The API key was not accepted (revoked, expired or disabled). ${next}
866
+ ${footer}`);
867
+ }
868
+ const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email || "unknown user";
869
+ const company = `${user.email && user.email !== name ? ` <${user.email}>` : ""}${user.companyName ? `, ${user.companyName}` : ""}`;
870
+ const source = status.source === "env" ? "TIMETICK_API_TOKEN environment variable" : "stored credentials";
871
+ return ok(`Authenticated as ${name}${company} (key from ${source}).
872
+ ${footer}`, { user, source: status.source });
873
+ })
874
+ );
875
+ target.registerTool(
876
+ "update_profile",
877
+ {
878
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
879
+ title: "Update profile",
880
+ description: "Update your Timetick account profile (name, company, phone). Changing the sign-in email is done in the web portal.",
881
+ inputSchema: {
882
+ firstName: z.string().optional(),
883
+ lastName: z.string().optional(),
884
+ companyName: z.string().optional(),
885
+ companyWebsite: z.string().optional(),
886
+ phoneNumber: z.string().optional(),
887
+ countryCode: z.string().optional().describe("Two-letter country code, e.g. 'NL'")
888
+ }
889
+ },
890
+ async (input) => guard(async () => {
891
+ const me = await sdk().users.getCurrent();
892
+ if (!me?.id) return fail("Could not resolve the current user id.");
893
+ const { id } = me;
894
+ const values = Object.fromEntries(
895
+ Object.entries(input).filter(([, v]) => v !== void 0)
896
+ );
897
+ const updated = await sdk().users.updateProfile({ id, ...values });
898
+ return ok(`Updated profile.`, updated);
899
+ })
900
+ );
113
901
  }
114
902
 
115
903
  // src/tools/context.ts
904
+ import { z as z2 } from "zod";
905
+ async function planNote(projectId) {
906
+ const project = await sdk().projects.get(projectId).catch(() => null);
907
+ if (!project) return "";
908
+ if (project.projectPlan) return ` Plan: ${project.projectPlan.plan?.name ?? "unknown"}.`;
909
+ if (project.onboardingStatus === "NOT_STARTED" || project.onboardingStatus === "IN_PROGRESS") {
910
+ return " This project's trial has not started, so it is read-only: its owner can call start_trial.";
911
+ }
912
+ return " This project has no active plan, so it is read-only: a paid plan is set up in the web portal.";
913
+ }
116
914
  function registerContextTools(server) {
117
915
  server.registerTool(
118
916
  "get_context",
119
917
  {
120
918
  annotations: { readOnlyHint: true },
121
919
  title: "Get active context",
122
- description: "Show the project and sim world the MCP server is currently operating on. If no project is selected yet and you have only one project, it is auto-selected.",
920
+ description: "Show the project and sim world the MCP server is currently operating on. If no project is selected yet and you have only one project, it is auto-selected. Also shows the project's plan, or that it has none and is read-only.",
123
921
  inputSchema: {}
124
922
  },
125
923
  async () => guard(async () => {
126
924
  const ctx = peekContext() ?? await getContext();
127
925
  return ok(
128
- `Active project: ${ctx.projectName} (id: ${ctx.projectId}); sim world: ${ctx.simWorldId} [${ctx.source}]`,
926
+ `Active project: ${ctx.projectName} (id: ${ctx.projectId}); sim world: ${ctx.simWorldId} [${ctx.source}].` + await planNote(ctx.projectId),
129
927
  ctx
130
928
  );
131
929
  })
@@ -137,18 +935,19 @@ function registerContextTools(server) {
137
935
  title: "Select project",
138
936
  description: "Switch the active project by name or id. Only needed if you have more than one project. Caches the project's sim world for subsequent tools.",
139
937
  inputSchema: {
140
- project: z.string().describe("Project name or id to make active")
938
+ project: z2.string().describe("Project name or id to make active")
141
939
  }
142
940
  },
143
941
  async ({ project }) => guard(async () => {
144
942
  const ctx = await useProject({ project });
145
- return ok(`Active project is now ${ctx.projectName} (id: ${ctx.projectId}).`, ctx);
943
+ return ok(`Active project is now ${ctx.projectName} (id: ${ctx.projectId}).${await planNote(ctx.projectId)}`, ctx);
146
944
  })
147
945
  );
148
946
  }
149
947
 
150
948
  // src/tools/data.ts
151
- import { z as z3 } from "zod";
949
+ import { z as z4 } from "zod";
950
+ import { describeStepArgs } from "@timetick/sdk";
152
951
 
153
952
  // src/links.ts
154
953
  var SIMULATOR_PATH = /\/projects\/([^/?#&|>\s]+)\/simulators\/([^/?#&|>\s]+)/;
@@ -174,11 +973,27 @@ async function resolveSimWorld(project) {
174
973
  if (project) return lookupProject(project);
175
974
  return getContext();
176
975
  }
976
+ var RESOLVE_PAGE_SIZE = 200;
977
+ var CANDIDATE_LIMIT = 10;
177
978
  async function resolveSimulator(simWorldId, simulator) {
178
979
  const needle = simulator.toLowerCase();
179
- const page = await sdk().simulators.list({ simWorldId, searchValue: simulator });
180
- const records = page?.records ?? [];
181
- const exact = records.find((r) => r?.id === simulator) ?? records.find((r) => r?.baseName?.toLowerCase() === needle) ?? (records.length === 1 ? records[0] : void 0);
980
+ const records = [];
981
+ for (let page2 = 1; ; page2++) {
982
+ const result = await sdk().simulators.list({
983
+ simWorldId,
984
+ searchValue: simulator,
985
+ pagination: { page: page2, pageSize: RESOLVE_PAGE_SIZE }
986
+ });
987
+ records.push(...result?.records ?? []);
988
+ if (!result?.records?.length || !result.pageInfo?.hasNextPage) break;
989
+ }
990
+ const named = records.filter((r) => r?.baseName?.toLowerCase() === needle);
991
+ if (named.length > 1) {
992
+ const candidates2 = named.map((r) => ` - ${r?.baseName} (id: ${r?.id}, connects to ${r?.endpoint?.name ?? r?.endpoint?.uri ?? "?"})`).join("\n");
993
+ throw new Error(`Several simulators are named "${simulator}". Pass the id of one:
994
+ ${candidates2}`);
995
+ }
996
+ const exact = records.find((r) => r?.id === simulator) ?? named[0];
182
997
  if (exact?.id) {
183
998
  return { id: exact.id, baseName: exact.baseName ?? "", protocol: String(exact.protocol) };
184
999
  }
@@ -186,11 +1001,11 @@ async function resolveSimulator(simWorldId, simulator) {
186
1001
  if (byId?.id) {
187
1002
  return { id: byId.id, baseName: byId.baseName ?? "", protocol: String(byId.protocol) };
188
1003
  }
189
- const candidates = records.map((r) => ` - ${r?.baseName} (id: ${r?.id})`).join("\n");
1004
+ const candidates = records.slice(0, CANDIDATE_LIMIT).map((r) => ` - ${r?.baseName} (id: ${r?.id})`).join("\n");
190
1005
  throw new Error(
191
- `No simulator matching "${simulator}".` + (candidates ? `
192
- Candidates:
193
- ${candidates}` : " No simulators found in this sim world.")
1006
+ `No simulator named "${simulator}".` + (candidates ? `
1007
+ Similar names:
1008
+ ${candidates}` : " No simulators found in this project.")
194
1009
  );
195
1010
  }
196
1011
  async function resolveSimulatorTarget(simulator, project) {
@@ -207,36 +1022,65 @@ async function resolveSimulatorById(id) {
207
1022
  if (!byId?.id) throw new Error(`No simulator with id ${id} from the portal link.`);
208
1023
  return { id: byId.id, baseName: byId.baseName ?? "", protocol: String(byId.protocol) };
209
1024
  }
210
- async function resolveActionId(simWorldId, protocol, actionName) {
211
- const page = await sdk().actions.list({
1025
+ async function resolveHardware(ctx, hardware) {
1026
+ const page2 = await sdk().hardware.list({ deviceScopeId: ctx.deviceScopeId, searchValue: hardware });
1027
+ const records = page2?.records ?? [];
1028
+ const match = records.find((r) => r?.id === hardware) ?? records.find((r) => r?.serialNumber?.toLowerCase() === hardware.toLowerCase());
1029
+ if (match?.id) return { id: match.id, name: match.serialNumber ?? match.id, protocol: String(match.protocol) };
1030
+ const byId = await sdk().hardware.get(hardware).catch(() => null);
1031
+ if (byId?.id) return { id: byId.id, name: byId.serialNumber ?? byId.id, protocol: String(byId.protocol) };
1032
+ const candidates = records.map((r) => ` - ${r?.serialNumber} (id: ${r?.id})`).join("\n");
1033
+ throw new Error(
1034
+ `No hardware matching "${hardware}".` + (candidates ? `
1035
+ Candidates:
1036
+ ${candidates}` : "")
1037
+ );
1038
+ }
1039
+ async function resolveRunTarget(input) {
1040
+ if (Boolean(input.simulator) === Boolean(input.hardware)) {
1041
+ throw new Error("Pass exactly one of `simulator` or `hardware`.");
1042
+ }
1043
+ if (input.simulator) {
1044
+ const { ctx: ctx2, sim } = await resolveSimulatorTarget(input.simulator, input.project);
1045
+ return { ctx: ctx2, kind: "SIMULATOR", id: sim.id, name: sim.baseName, protocol: sim.protocol };
1046
+ }
1047
+ const ctx = await resolveSimWorld(input.project);
1048
+ const device = await resolveHardware(ctx, input.hardware);
1049
+ return { ctx, kind: "HARDWARE", ...device };
1050
+ }
1051
+ var CATALOG_PAGE_SIZE = 200;
1052
+ async function loadActionCatalog(simWorldId, protocol) {
1053
+ const page2 = await sdk().actions.list({
212
1054
  simWorldId,
213
- searchValue: actionName,
214
- filters: { isSystem: true, protocol, deviceType: "CHARGER" },
215
- pagination: { page: 1, pageSize: 50 }
1055
+ filters: { protocol },
1056
+ pagination: { page: 1, pageSize: CATALOG_PAGE_SIZE }
216
1057
  });
217
- const records = page?.records ?? [];
218
- const needle = actionName.toLowerCase();
219
- const match = records.find((a) => a?.name?.toLowerCase() === needle) ?? records.find((a) => a?.name?.toLowerCase().includes(needle));
220
- if (!match?.id) {
221
- const available = records.map((a) => ` - ${a?.name}`).join("\n");
222
- throw new Error(
223
- `Action "${actionName}" not found for ${protocol}.` + (available ? `
224
- Available system actions matching your search:
225
- ${available}` : "")
226
- );
227
- }
228
- return match.id;
1058
+ return (page2?.records ?? []).filter((action) => action != null);
1059
+ }
1060
+ function findAction(catalog, protocol, actionName) {
1061
+ const needle = actionName.trim().toLowerCase();
1062
+ const exact = catalog.find((a) => a.name.toLowerCase() === needle);
1063
+ if (exact) return exact;
1064
+ const partial = catalog.filter((a) => a.name.toLowerCase().includes(needle));
1065
+ if (partial.length === 1) return partial[0];
1066
+ const names = (partial.length > 1 ? partial : catalog).map((a) => a.name).join(", ");
1067
+ throw new Error(
1068
+ partial.length > 1 ? `Step "${actionName}" is ambiguous for ${protocol}. Use one of: ${names}.` : `No step "${actionName}" for ${protocol}. Available: ${names}.`
1069
+ );
1070
+ }
1071
+ async function resolveAction(simWorldId, protocol, actionName) {
1072
+ return findAction(await loadActionCatalog(simWorldId, protocol), protocol, actionName);
229
1073
  }
230
1074
 
231
1075
  // src/pagination.ts
232
- import { z as z2 } from "zod";
1076
+ import { z as z3 } from "zod";
233
1077
  var DEFAULT_PAGE_SIZE = 20;
234
1078
  var paginationArgs = {
235
- page: z2.number().int().min(1).optional().describe("Page number, starting at 1 (default 1)"),
236
- pageSize: z2.number().int().min(1).max(200).optional().describe(`Results per page (default ${DEFAULT_PAGE_SIZE})`)
1079
+ page: z3.number().int().min(1).optional().describe("Page number, starting at 1 (default 1)"),
1080
+ pageSize: z3.number().int().min(1).max(200).optional().describe(`Results per page (default ${DEFAULT_PAGE_SIZE})`)
237
1081
  };
238
- function pagination(page, pageSize, defaultPageSize = DEFAULT_PAGE_SIZE) {
239
- return { page: page ?? 1, pageSize: pageSize ?? defaultPageSize };
1082
+ function pagination(page2, pageSize, defaultPageSize = DEFAULT_PAGE_SIZE) {
1083
+ return { page: page2 ?? 1, pageSize: pageSize ?? defaultPageSize };
240
1084
  }
241
1085
  function pageSummary(noun, count, pageInfo) {
242
1086
  const total = pageInfo?.totalCount;
@@ -245,19 +1089,18 @@ function pageSummary(noun, count, pageInfo) {
245
1089
  }
246
1090
 
247
1091
  // src/tools/data.ts
248
- var protocolEnum = z3.enum(["OCPP_1_6", "OCPP_2_0_1"]);
249
- function summarizeArgs(params) {
250
- if (!params || typeof params !== "object") return void 0;
251
- const summary = {};
252
- for (const [name, spec] of Object.entries(params)) {
253
- if (!spec || typeof spec !== "object") continue;
254
- const { type, allowNull, defaultValue, visible } = spec;
255
- if (visible === false) continue;
256
- const parts = [String(type ?? "unknown"), allowNull === false ? "required" : "optional"];
257
- if (defaultValue !== void 0) parts.push(`default ${JSON.stringify(defaultValue)}`);
258
- summary[name] = parts.join(", ");
259
- }
260
- return Object.keys(summary).length > 0 ? summary : void 0;
1092
+ var protocolEnum = z4.enum(["OCPP_1_6", "OCPP_2_0_1"]);
1093
+ function summarizeAction(action) {
1094
+ return {
1095
+ name: action.name,
1096
+ description: action.description ?? void 0,
1097
+ kind: action.deviceType === "CSMS" ? "remote" : "charger-side",
1098
+ custom: action.isSystem ? void 0 : true,
1099
+ args: Object.fromEntries(Object.entries(describeStepArgs(action)).filter(([name]) => name !== "onFail")),
1100
+ defaultExpectedResults: (action.actionOperations ?? []).flatMap(
1101
+ (operation) => (operation.expectedResults ?? []).map((result) => result.commandName)
1102
+ )
1103
+ };
261
1104
  }
262
1105
  function registerDataTools(server) {
263
1106
  server.registerTool(
@@ -280,19 +1123,19 @@ function registerDataTools(server) {
280
1123
  title: "List simulators",
281
1124
  description: "List simulators (virtual chargers) in the active project's sim world. Optionally filter by name search or protocol.",
282
1125
  inputSchema: {
283
- search: z3.string().optional().describe("Filter by simulator base name"),
1126
+ search: z4.string().optional().describe("Filter by simulator base name"),
284
1127
  protocol: protocolEnum.optional(),
285
1128
  ...paginationArgs,
286
- project: z3.string().optional().describe("Project name or id (defaults to active project)")
1129
+ project: z4.string().optional().describe("Project name or id (defaults to active project)")
287
1130
  }
288
1131
  },
289
- async ({ search, protocol, page, pageSize, project }) => guard(async () => {
1132
+ async ({ search, protocol, page: page2, pageSize, project }) => guard(async () => {
290
1133
  const ctx = await resolveSimWorld(project);
291
1134
  const result = await sdk().simulators.list({
292
1135
  simWorldId: ctx.simWorldId,
293
1136
  searchValue: search,
294
1137
  filters: protocol ? { protocol } : void 0,
295
- pagination: pagination(page, pageSize)
1138
+ pagination: pagination(page2, pageSize)
296
1139
  });
297
1140
  return ok(
298
1141
  pageSummary("simulator(s)", result?.records?.length ?? 0, result?.pageInfo),
@@ -307,8 +1150,8 @@ function registerDataTools(server) {
307
1150
  title: "Get simulator",
308
1151
  description: "Get a simulator's details and live status (network status, launched/power status, connectors) by id or base name.",
309
1152
  inputSchema: {
310
- simulator: z3.string().describe(SIMULATOR_ARG_DESCRIPTION),
311
- project: z3.string().optional().describe("Project name or id (defaults to active project)")
1153
+ simulator: z4.string().describe(SIMULATOR_ARG_DESCRIPTION),
1154
+ project: z4.string().optional().describe("Project name or id (defaults to active project)")
312
1155
  }
313
1156
  },
314
1157
  async ({ simulator, project }) => guard(async () => {
@@ -322,70 +1165,402 @@ function registerDataTools(server) {
322
1165
  {
323
1166
  annotations: { readOnlyHint: true },
324
1167
  title: "List hardware",
325
- description: "List physical OCPP hardware devices in the active project's sim world.",
1168
+ description: "List real chargers registered as hardware in the active project's sim world.",
326
1169
  inputSchema: {
327
- search: z3.string().optional().describe("Filter by serial number / model"),
1170
+ search: z4.string().optional().describe("Filter by serial number / model"),
328
1171
  ...paginationArgs,
329
- project: z3.string().optional().describe("Project name or id (defaults to active project)")
1172
+ project: z4.string().optional().describe("Project name or id (defaults to active project)")
330
1173
  }
331
1174
  },
332
- async ({ search, page, pageSize, project }) => guard(async () => {
1175
+ async ({ search, page: page2, pageSize, project }) => guard(async () => {
333
1176
  const ctx = await resolveSimWorld(project);
334
1177
  const result = await sdk().hardware.list({
335
1178
  deviceScopeId: ctx.deviceScopeId,
336
1179
  searchValue: search,
337
- pagination: pagination(page, pageSize)
1180
+ pagination: pagination(page2, pageSize)
338
1181
  });
339
1182
  return ok(
340
- pageSummary("hardware device(s)", result?.records?.length ?? 0, result?.pageInfo),
1183
+ pageSummary("hardware record(s)", result?.records?.length ?? 0, result?.pageInfo),
341
1184
  result
342
1185
  );
343
1186
  })
344
1187
  );
1188
+ server.registerTool(
1189
+ "get_hardware",
1190
+ {
1191
+ annotations: { readOnlyHint: true },
1192
+ title: "Get hardware",
1193
+ description: "Get hardware details and live status. endpoint.uri is the Timetick CSMS URL to configure on the physical charger; networkStatus shows whether it has connected.",
1194
+ inputSchema: {
1195
+ hardwareId: z4.string().describe("Hardware id (see list_hardware)")
1196
+ }
1197
+ },
1198
+ async ({ hardwareId }) => guard(async () => {
1199
+ const device = await sdk().hardware.get(hardwareId);
1200
+ if (!device) return ok(`No hardware found with id ${hardwareId}.`);
1201
+ return ok(`Hardware ${device.serialNumber} (id: ${hardwareId}).`, device);
1202
+ })
1203
+ );
345
1204
  server.registerTool(
346
1205
  "list_actions",
347
1206
  {
348
1207
  annotations: { readOnlyHint: true },
349
- title: "List actions (test steps)",
350
- description: "List available actions (steps) you can use to build a test \u2014 e.g. 'Power On Charger', 'Plug In Connector', 'Charge a Vehicle'. Filter by protocol. Each action lists its arguments: the name, whether it is required (allowNull false), and any default.",
1208
+ title: "List test steps",
1209
+ description: "List the steps you can use to build a test, for one protocol. kind 'charger-side' is something that happens at the charger, such as 'Plug In Connector'; 'remote' is a Remote: step, a command sent by the CSMS. Each step lists its arguments with type, required flag and default. get_action shows nested argument details.",
351
1210
  inputSchema: {
352
- protocol: protocolEnum.optional(),
353
- search: z3.string().optional().describe("Filter by action name"),
1211
+ protocol: protocolEnum,
1212
+ search: z4.string().optional().describe("Filter by step name"),
354
1213
  ...paginationArgs,
355
- project: z3.string().optional().describe("Project name or id (defaults to active project)")
1214
+ project: z4.string().optional().describe("Project name or id (defaults to active project)")
356
1215
  }
357
1216
  },
358
- async ({ protocol, search, page, pageSize, project }) => guard(async () => {
1217
+ async ({ protocol, search, page: page2, pageSize, project }) => guard(async () => {
359
1218
  const ctx = await resolveSimWorld(project);
360
1219
  const result = await sdk().actions.list({
361
1220
  simWorldId: ctx.simWorldId,
362
1221
  searchValue: search,
363
- filters: { isSystem: true, deviceType: "CHARGER", ...protocol ? { protocol } : {} },
364
- pagination: pagination(page, pageSize, 100)
1222
+ filters: { protocol },
1223
+ pagination: pagination(page2, pageSize, 100)
365
1224
  });
366
- const actions = (result?.records ?? []).map((a) => ({
367
- name: a?.name,
368
- description: a?.description ?? void 0,
369
- args: summarizeArgs(a?.params)
370
- }));
371
- return ok(pageSummary("action(s)", actions.length, result?.pageInfo), actions);
1225
+ const actions = (result?.records ?? []).filter((a) => a != null).map(summarizeAction);
1226
+ return ok(pageSummary("step(s)", actions.length, result?.pageInfo), actions);
372
1227
  })
373
1228
  );
374
- }
1229
+ server.registerTool(
1230
+ "get_action",
1231
+ {
1232
+ annotations: { readOnlyHint: true },
1233
+ title: "Get test step",
1234
+ description: "Full definition of one step: the argument schema of each of its operations, with nested fields such as chargingProfile, its preset values and its default expected results.",
1235
+ inputSchema: {
1236
+ actionName: z4.string().describe("Step name, e.g. 'Remote: Set Charging Profile'"),
1237
+ protocol: protocolEnum,
1238
+ project: z4.string().optional().describe("Project name or id (defaults to active project)")
1239
+ }
1240
+ },
1241
+ async ({ actionName, protocol, project }) => guard(async () => {
1242
+ const ctx = await resolveSimWorld(project);
1243
+ const action = findAction(await loadActionCatalog(ctx.simWorldId, protocol), protocol, actionName);
1244
+ return ok(`Step "${action.name}".`, {
1245
+ ...summarizeAction(action),
1246
+ operations: (action.actionOperations ?? []).map((operation) => ({
1247
+ preset: operation.preset,
1248
+ schema: operation.formTemplate?.schema ?? operation.operation?.formTemplate?.schema ?? null,
1249
+ expectedResults: operation.expectedResults
1250
+ }))
1251
+ });
1252
+ })
1253
+ );
1254
+ server.registerTool(
1255
+ "list_expected_result_commands",
1256
+ {
1257
+ annotations: { readOnlyHint: true },
1258
+ title: "List expected-result commands",
1259
+ description: "List the OCPP messages a step's expectedResults can check. Pass `command` to get the request and response fields of one message.",
1260
+ inputSchema: {
1261
+ protocol: protocolEnum,
1262
+ command: z4.string().optional().describe("OCPP message name, e.g. 'Authorize'")
1263
+ }
1264
+ },
1265
+ async ({ protocol, command }) => guard(async () => {
1266
+ const configs = await sdk().actions.listExpectedResultConfigs(protocol) ?? [];
1267
+ if (!command) {
1268
+ return ok(`${configs.length} command(s) for ${protocol}: ${configs.map((c) => c.name).join(", ")}.`);
1269
+ }
1270
+ const config = configs.find((c) => c.name.toLowerCase() === command.toLowerCase());
1271
+ if (!config) return fail(`No expected-result command "${command}" for ${protocol}.`);
1272
+ return ok(`Fields of ${config.name}.`, { request: config.request.schema, response: config.response.schema });
1273
+ })
1274
+ );
1275
+ server.registerTool(
1276
+ "create_hardware",
1277
+ {
1278
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1279
+ title: "Create hardware",
1280
+ description: "Register a real charger under test. Hardware connects to Timetick's CSMS, so give it a TIMETICK endpoint; get_hardware then shows the URL to configure on the charger. Run tests on it with run_test's `hardware` argument.",
1281
+ inputSchema: {
1282
+ serialNumber: z4.string().describe("Serial number; the charge point identity on Timetick's CSMS"),
1283
+ model: z4.string().describe("Model name"),
1284
+ manufacturer: z4.string().describe("Manufacturer name"),
1285
+ firmwareVersion: z4.string(),
1286
+ protocol: z4.enum(["OCPP_1_6", "OCPP_2_0_1"]),
1287
+ year: z4.number().int().optional().describe("Manufacturing year"),
1288
+ month: z4.number().int().min(1).max(12).optional().describe("Manufacturing month"),
1289
+ endpointId: z4.string().describe("TIMETICK endpoint it connects to (see list_endpoints)"),
1290
+ modelId: z4.string().describe("Model with the charger's connectors (see list_models)"),
1291
+ project: z4.string().optional().describe("Project name or id (defaults to active project)")
1292
+ }
1293
+ },
1294
+ async ({ serialNumber, model, manufacturer, firmwareVersion, protocol, year, month, endpointId, modelId, project }) => guard(async () => {
1295
+ const ctx = await resolveSimWorld(project);
1296
+ const device = await sdk().hardware.create({
1297
+ deviceScopeId: ctx.deviceScopeId,
1298
+ serialNumber,
1299
+ model,
1300
+ manufacturer,
1301
+ firmwareVersion,
1302
+ type: "CHARGER",
1303
+ protocol,
1304
+ year,
1305
+ month,
1306
+ endpointId,
1307
+ chargerModelId: modelId
1308
+ });
1309
+ return ok(
1310
+ `Created hardware (id: ${device?.id}). Configure the charger to connect to ${device?.endpoint?.uri ?? "its endpoint (see get_hardware)"}.`,
1311
+ device
1312
+ );
1313
+ })
1314
+ );
1315
+ server.registerTool(
1316
+ "update_hardware",
1317
+ {
1318
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1319
+ title: "Update hardware",
1320
+ description: "Update hardware details or its endpoint.",
1321
+ inputSchema: {
1322
+ hardwareId: z4.string().describe("Hardware id (see list_hardware)"),
1323
+ serialNumber: z4.string().optional(),
1324
+ model: z4.string().optional(),
1325
+ manufacturer: z4.string().optional(),
1326
+ firmwareVersion: z4.string().optional(),
1327
+ endpointId: z4.string().optional(),
1328
+ project: z4.string().optional().describe("Project name or id (defaults to active project)")
1329
+ }
1330
+ },
1331
+ async ({ hardwareId, serialNumber, model, manufacturer, firmwareVersion, endpointId, project }) => guard(async () => {
1332
+ const ctx = await resolveSimWorld(project);
1333
+ const current = await sdk().hardware.get(hardwareId);
1334
+ if (!current) return fail(`No hardware found with id ${hardwareId}.`);
1335
+ const device = await sdk().hardware.update({
1336
+ id: hardwareId,
1337
+ tags: (current.tags ?? []).map(({ id, name, color }) => ({ id, name, color })),
1338
+ deviceScopeId: ctx.deviceScopeId,
1339
+ serialNumber,
1340
+ model,
1341
+ manufacturer,
1342
+ firmwareVersion,
1343
+ endpointId
1344
+ });
1345
+ return ok(`Updated hardware (id: ${hardwareId}).`, device);
1346
+ })
1347
+ );
1348
+ server.registerTool(
1349
+ "delete_hardware",
1350
+ {
1351
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1352
+ title: "Delete hardware",
1353
+ description: "Permanently delete a hardware record.",
1354
+ inputSchema: {
1355
+ hardwareId: z4.string().describe("Hardware id (see list_hardware)")
1356
+ }
1357
+ },
1358
+ async ({ hardwareId }) => guard(async () => {
1359
+ await sdk().hardware.delete(hardwareId);
1360
+ return ok(`Deleted hardware ${hardwareId}.`);
1361
+ })
1362
+ );
1363
+ }
375
1364
 
376
1365
  // src/tools/simulators.ts
377
- import { z as z4 } from "zod";
378
- async function runOnSimulator(project, simulator, actionName, args = []) {
379
- const { ctx, sim } = await resolveSimulatorTarget(simulator, project);
380
- const actionId = await resolveActionId(ctx.simWorldId, sim.protocol, actionName);
1366
+ import { z as z5 } from "zod";
1367
+ import { buildOperationSubmissions } from "@timetick/sdk";
1368
+ var protocolEnum2 = z5.enum(["OCPP_1_6", "OCPP_2_0_1"]);
1369
+ async function runStep(target, actionName, args = {}) {
1370
+ const run = await resolveRunTarget(target);
1371
+ const action = await resolveAction(run.ctx.simWorldId, run.protocol, actionName);
381
1372
  const status = await sdk().testRuns.runAction({
382
- chargerId: sim.id,
383
- action: { requirementKey: "1", stream: 1, order: 1, actionId, args }
1373
+ ...run.kind === "SIMULATOR" ? { chargerId: run.id } : { deviceId: run.id },
1374
+ action: {
1375
+ requirementKey: "1",
1376
+ stream: 1,
1377
+ order: 1,
1378
+ actionId: action.id,
1379
+ operationSubmissions: buildOperationSubmissions(action, args)
1380
+ }
384
1381
  });
385
- return { sim, actionName, status };
1382
+ return { run, status };
386
1383
  }
1384
+ async function modelOcppConfigId(modelId) {
1385
+ const model = await sdk().models.get(modelId);
1386
+ return model?.ocppConfig?.id ?? void 0;
1387
+ }
1388
+ var CREATE_POLL_ATTEMPTS = 8;
1389
+ var CREATE_POLL_DELAY_MS = 2e3;
387
1390
  function registerSimulatorTools(server) {
388
- const projectArg = z4.string().optional().describe("Project name or id (defaults to active project)");
1391
+ const projectArg = z5.string().optional().describe("Project name or id (defaults to active project)");
1392
+ server.registerTool(
1393
+ "create_simulator",
1394
+ {
1395
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1396
+ title: "Create simulator",
1397
+ description: "Create a simulator (virtual charger) from a model. Needs a model (list_models / create_model) and an endpoint (list_endpoints / create_endpoint). After creating, boot it with power_on_simulator and confirm it connects with get_simulator.",
1398
+ inputSchema: {
1399
+ baseName: z5.string().describe("Simulator name (letters, digits, _ and -). It is the charge point identity the CSMS sees."),
1400
+ modelId: z5.string().describe("Model id (see list_models)"),
1401
+ endpointId: z5.string().describe("Endpoint id to connect to (see list_endpoints)"),
1402
+ protocol: protocolEnum2,
1403
+ ocppConfigId: z5.string().optional().describe("OCPP config to attach (default: the model's; see list_ocpp_configs)"),
1404
+ lat: z5.number().optional().describe("Map location latitude (default 52.3676)"),
1405
+ lng: z5.number().optional().describe("Map location longitude (default 4.9041)"),
1406
+ autoPowerOff: z5.boolean().optional().describe("Power the simulator off automatically when idle"),
1407
+ autoPowerOffTimeout: z5.number().optional().describe("Auto power-off timeout in seconds"),
1408
+ project: projectArg
1409
+ }
1410
+ },
1411
+ async ({ baseName, modelId, endpointId, protocol, ocppConfigId, lat, lng, autoPowerOff, autoPowerOffTimeout, project }) => guard(async () => {
1412
+ const ctx = await resolveSimWorld(project);
1413
+ const config = ocppConfigId ?? await modelOcppConfigId(modelId);
1414
+ let simulator = await sdk().simulators.create({
1415
+ baseName,
1416
+ simWorldId: ctx.simWorldId,
1417
+ chargerModelId: modelId,
1418
+ endpointId,
1419
+ protocol,
1420
+ location: { lat: lat ?? 52.3676, lng: lng ?? 4.9041 },
1421
+ ocppConfigId: config,
1422
+ autoPowerOff,
1423
+ autoPowerOffTimeout
1424
+ });
1425
+ for (let attempt = 0; !simulator && attempt < CREATE_POLL_ATTEMPTS; attempt++) {
1426
+ await new Promise((resolve) => setTimeout(resolve, CREATE_POLL_DELAY_MS));
1427
+ const page2 = await sdk().simulators.list({
1428
+ simWorldId: ctx.simWorldId,
1429
+ searchValue: baseName,
1430
+ filters: { endpointIds: [endpointId] }
1431
+ });
1432
+ simulator = page2?.records?.find((record) => record?.baseName === baseName);
1433
+ }
1434
+ if (!simulator) {
1435
+ return ok(
1436
+ `Creation of simulator "${baseName}" is queued but it has not appeared yet. Check with list_simulators (search: "${baseName}") in a moment, then boot it with power_on_simulator.`
1437
+ );
1438
+ }
1439
+ return ok(
1440
+ `Created simulator "${simulator.baseName}" (id: ${simulator.id}). Boot it with power_on_simulator when ready.`,
1441
+ simulator
1442
+ );
1443
+ })
1444
+ );
1445
+ server.registerTool(
1446
+ "create_simulators_bulk",
1447
+ {
1448
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1449
+ title: "Create simulators (bulk)",
1450
+ description: "Create many simulators at once from a model, named <baseName>_1 \u2026 _N. Runs in the background, so they appear in list_simulators shortly after. For load-testing fleets. Same requirements as create_simulator: model and endpoint.",
1451
+ inputSchema: {
1452
+ baseName: z5.string().describe("Name base: simulators are created as <baseName>_1, _2, \u2026"),
1453
+ modelId: z5.string().describe("Model id (see list_models)"),
1454
+ endpointId: z5.string().describe("Endpoint id to connect to (see list_endpoints)"),
1455
+ protocol: protocolEnum2,
1456
+ amount: z5.number().int().min(1).describe("How many simulators to create"),
1457
+ startNumber: z5.number().int().min(1).optional().describe("First number suffix (default 1)"),
1458
+ ocppConfigId: z5.string().optional().describe("OCPP config to attach (see list_ocpp_configs)"),
1459
+ lat: z5.number().optional().describe("Map location latitude (default 52.3676)"),
1460
+ lng: z5.number().optional().describe("Map location longitude (default 4.9041)"),
1461
+ project: projectArg
1462
+ }
1463
+ },
1464
+ async ({ baseName, modelId, endpointId, protocol, amount, startNumber, ocppConfigId, lat, lng, project }) => guard(async () => {
1465
+ const ctx = await resolveSimWorld(project);
1466
+ const config = ocppConfigId ?? await modelOcppConfigId(modelId);
1467
+ await sdk().simulators.createBulk({
1468
+ baseName,
1469
+ simWorldId: ctx.simWorldId,
1470
+ chargerModelId: modelId,
1471
+ endpointId,
1472
+ protocol,
1473
+ chargersAmount: amount,
1474
+ startNumber,
1475
+ location: { lat: lat ?? 52.3676, lng: lng ?? 4.9041 },
1476
+ ocppConfigId: config
1477
+ });
1478
+ return ok(
1479
+ `Queued creation of ${amount} simulator(s) named ${amount === 1 ? baseName : `${baseName}_\u2026`}. They appear in list_simulators (search: "${baseName}") shortly.`
1480
+ );
1481
+ })
1482
+ );
1483
+ server.registerTool(
1484
+ "update_simulator",
1485
+ {
1486
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1487
+ title: "Update simulator",
1488
+ description: "Rename a simulator or move it to another model or endpoint.",
1489
+ inputSchema: {
1490
+ simulator: z5.string().describe(SIMULATOR_ARG_DESCRIPTION),
1491
+ baseName: z5.string().optional().describe("New name"),
1492
+ modelId: z5.string().optional().describe("Move to another model (see list_models)"),
1493
+ endpointId: z5.string().optional().describe("Move to another endpoint (see list_endpoints)"),
1494
+ protocol: protocolEnum2.optional(),
1495
+ project: projectArg
1496
+ }
1497
+ },
1498
+ async ({ simulator, baseName, modelId, endpointId, protocol, project }) => guard(async () => {
1499
+ const { ctx, sim } = await resolveSimulatorTarget(simulator, project);
1500
+ const current = await sdk().simulators.get(sim.id);
1501
+ const updated = await sdk().simulators.update({
1502
+ id: sim.id,
1503
+ tags: (current?.tags ?? []).map(({ id, name, color }) => ({ id, name, color })),
1504
+ simWorldId: ctx.simWorldId,
1505
+ baseName,
1506
+ chargerModelId: modelId,
1507
+ endpointId,
1508
+ protocol
1509
+ });
1510
+ return ok(`Updated simulator ${sim.baseName} (id: ${sim.id}).`, updated);
1511
+ })
1512
+ );
1513
+ server.registerTool(
1514
+ "delete_simulator",
1515
+ {
1516
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1517
+ title: "Delete simulator",
1518
+ description: "Permanently delete a simulator and its history. There is no undo.",
1519
+ inputSchema: {
1520
+ simulator: z5.string().describe(SIMULATOR_ARG_DESCRIPTION),
1521
+ project: projectArg
1522
+ }
1523
+ },
1524
+ async ({ simulator, project }) => guard(async () => {
1525
+ const { sim } = await resolveSimulatorTarget(simulator, project);
1526
+ await sdk().simulators.delete(sim.id);
1527
+ return ok(`Deleted simulator ${sim.baseName} (id: ${sim.id}).`);
1528
+ })
1529
+ );
1530
+ server.registerTool(
1531
+ "update_simulator_ocpp_key",
1532
+ {
1533
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1534
+ title: "Set a simulator's OCPP key",
1535
+ description: "Change one OCPP configuration value on one simulator, e.g. HeartbeatInterval. get_simulator_config lists the current keys. This is the usual fix before a rerun; update_ocpp_config changes a shared config template instead.",
1536
+ inputSchema: {
1537
+ simulator: z5.string().describe(SIMULATOR_ARG_DESCRIPTION),
1538
+ key: z5.string().describe("OCPP 1.6 key, or OCPP 2.0.1 variable name"),
1539
+ component: z5.string().optional().describe("OCPP 2.0.1 component name, when the variable name is ambiguous"),
1540
+ evseId: z5.number().int().optional().describe("OCPP 2.0.1 EVSE id, when the variable exists per EVSE"),
1541
+ componentInstance: z5.string().optional().describe("OCPP 2.0.1 component instance, when there are several"),
1542
+ variableInstance: z5.string().optional().describe("OCPP 2.0.1 variable instance, when there are several"),
1543
+ value: z5.string(),
1544
+ project: projectArg
1545
+ }
1546
+ },
1547
+ async ({ simulator, key, component, evseId, componentInstance, variableInstance, value, project }) => guard(async () => {
1548
+ const { sim } = await resolveSimulatorTarget(simulator, project);
1549
+ const items = await sdk().simulators.getOcppConfigItems(sim.id) ?? [];
1550
+ const matches = items.filter(
1551
+ (item) => (item.key ?? item.variableName) === key && (!component || item.componentName === component) && (evseId === void 0 || item.evseId === evseId) && (componentInstance === void 0 || item.componentInstance === componentInstance) && (variableInstance === void 0 || item.variableInstance === variableInstance)
1552
+ );
1553
+ if (matches.length === 0) return fail(`${sim.baseName} has no OCPP key "${key}".`);
1554
+ if (matches.length > 1) {
1555
+ const places = matches.map(
1556
+ (item) => `${item.componentName} (EVSE ${item.evseId ?? "-"}, component instance ${item.componentInstance ?? "-"}, variable instance ${item.variableInstance ?? "-"})`
1557
+ ).join(", ");
1558
+ return fail(`"${key}" exists in several places: ${places}. Narrow it down with the matching arguments.`);
1559
+ }
1560
+ await sdk().simulators.updateOcppConfigItem({ id: matches[0].id, simulatorId: sim.id, value });
1561
+ return ok(`Set ${key} to "${value}" on ${sim.baseName} (id: ${sim.id}).`);
1562
+ })
1563
+ );
389
1564
  server.registerTool(
390
1565
  "power_on_simulator",
391
1566
  {
@@ -393,13 +1568,13 @@ function registerSimulatorTools(server) {
393
1568
  title: "Power on simulator",
394
1569
  description: "Power on a simulator (boots it and connects it to the OCPP server).",
395
1570
  inputSchema: {
396
- simulator: z4.string().describe(SIMULATOR_ARG_DESCRIPTION),
1571
+ simulator: z5.string().describe(SIMULATOR_ARG_DESCRIPTION),
397
1572
  project: projectArg
398
1573
  }
399
1574
  },
400
1575
  async ({ simulator, project }) => guard(async () => {
401
- const { sim, status } = await runOnSimulator(project, simulator, "Power On Charger");
402
- return ok(`Powered on ${sim.baseName} (id: ${sim.id}).`, { status });
1576
+ const { run, status } = await runStep({ simulator, project }, "Power On Charger");
1577
+ return ok(`Powered on ${run.name} (id: ${run.id}). Check networkStatus with get_simulator.`, { status });
403
1578
  })
404
1579
  );
405
1580
  server.registerTool(
@@ -409,60 +1584,115 @@ function registerSimulatorTools(server) {
409
1584
  title: "Power off simulator",
410
1585
  description: "Power off a simulator.",
411
1586
  inputSchema: {
412
- simulator: z4.string().describe(SIMULATOR_ARG_DESCRIPTION),
1587
+ simulator: z5.string().describe(SIMULATOR_ARG_DESCRIPTION),
413
1588
  project: projectArg
414
1589
  }
415
1590
  },
416
1591
  async ({ simulator, project }) => guard(async () => {
417
- const { sim, status } = await runOnSimulator(project, simulator, "Power Off Charger");
418
- return ok(`Powered off ${sim.baseName} (id: ${sim.id}).`, { status });
1592
+ const { run, status } = await runStep({ simulator, project }, "Power Off Charger");
1593
+ return ok(`Powered off ${run.name} (id: ${run.id}).`, { status });
419
1594
  })
420
1595
  );
421
1596
  server.registerTool(
422
- "run_simulator_action",
1597
+ "run_step",
423
1598
  {
424
1599
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
425
- title: "Run simulator action",
426
- description: "Run a single named action against a simulator \u2014 e.g. 'Reset Charger', 'Plug In Connector', 'Plug Out Connector'. Use list_actions to discover names and required args. Args are passed as key/value pairs (e.g. connectorId=1).",
1600
+ title: "Run a single step",
1601
+ description: "Run one step right now on a simulator or hardware, e.g. 'Reset Charger', 'Plug In Connector', 'Remote: Reset'. Use list_actions to discover names and arguments.",
427
1602
  inputSchema: {
428
- simulator: z4.string().describe(SIMULATOR_ARG_DESCRIPTION),
429
- actionName: z4.string().describe("Exact action name, e.g. 'Reset Charger'"),
430
- args: z4.array(z4.object({ key: z4.string(), value: z4.string() })).optional().describe("Action arguments as key/value pairs"),
1603
+ simulator: z5.string().optional().describe(SIMULATOR_ARG_DESCRIPTION),
1604
+ hardware: z5.string().optional().describe("Hardware serial number or id (instead of simulator)"),
1605
+ actionName: z5.string().describe("Step name, e.g. 'Reset Charger'"),
1606
+ args: z5.record(z5.string(), z5.unknown()).optional().describe("Step arguments, e.g. { connectorId: 1 }"),
431
1607
  project: projectArg
432
1608
  }
433
1609
  },
434
- async ({ simulator, actionName, args, project }) => guard(async () => {
435
- const { sim, status } = await runOnSimulator(project, simulator, actionName, args ?? []);
436
- return ok(`Ran "${actionName}" on ${sim.baseName} (id: ${sim.id}).`, { status });
1610
+ async ({ simulator, hardware, actionName, args, project }) => guard(async () => {
1611
+ const { run, status } = await runStep({ simulator, hardware, project }, actionName, args);
1612
+ return ok(`Ran "${actionName}" on ${run.name} (id: ${run.id}).`, { status });
437
1613
  })
438
1614
  );
439
1615
  }
440
1616
 
441
1617
  // src/tools/tests.ts
442
- import { z as z5 } from "zod";
443
- var protocolEnum2 = z5.enum(["OCPP_1_6", "OCPP_2_0_1"]);
1618
+ import { z as z6 } from "zod";
1619
+ import { buildOperationSubmissions as buildOperationSubmissions2 } from "@timetick/sdk";
1620
+ var protocolEnum3 = z6.enum(["OCPP_1_6", "OCPP_2_0_1"]);
1621
+ var DEFAULT_SLOT = "A";
1622
+ var stepSchema = z6.object({
1623
+ actionName: z6.string().describe("Step name from list_actions, e.g. 'Power On Charger'"),
1624
+ args: z6.record(z6.string(), z6.unknown()).optional().describe("Step arguments, e.g. { connectorId: 1 }"),
1625
+ stream: z6.number().int().min(1).optional().describe("Parallel column, default 1. Columns start together; steps within a column run in order."),
1626
+ slot: z6.string().optional().describe("Which slot runs the step, when the test has several"),
1627
+ stopTestOnFailure: z6.boolean().optional().describe("Fail the whole test if this step fails. Default false: the test continues."),
1628
+ expectedResults: z6.array(
1629
+ z6.object({
1630
+ command: z6.string().describe("OCPP message, e.g. 'Authorize' (see list_expected_result_commands)"),
1631
+ request: z6.record(z6.string(), z6.unknown()).optional().describe("Request fields to check"),
1632
+ response: z6.record(z6.string(), z6.unknown()).optional().describe("Response fields to check, e.g. { status: 'Accepted' }")
1633
+ })
1634
+ ).optional().describe("What must happen for the step to pass. Omit to use the step's defaults."),
1635
+ strict: z6.boolean().optional().describe("Fail the step on any OCPP message not listed in expectedResults. For certification-style tests.")
1636
+ });
1637
+ async function buildSteps(simWorldId, steps, slots, defaultSlot) {
1638
+ const catalogs = /* @__PURE__ */ new Map();
1639
+ const orderInStream = /* @__PURE__ */ new Map();
1640
+ const built = [];
1641
+ for (const step of steps) {
1642
+ const slotName = step.slot ?? defaultSlot;
1643
+ const slot = slotName ? slots.get(slotName) : void 0;
1644
+ if (!slot) {
1645
+ throw new Error(`Step "${step.actionName}" needs \`slot\`, one of: ${[...slots.keys()].join(", ")}.`);
1646
+ }
1647
+ let catalog = catalogs.get(slot.protocol);
1648
+ if (!catalog) {
1649
+ catalog = await loadActionCatalog(simWorldId, slot.protocol);
1650
+ catalogs.set(slot.protocol, catalog);
1651
+ }
1652
+ const action = findAction(catalog, slot.protocol, step.actionName);
1653
+ const stream = step.stream ?? 1;
1654
+ const order = (orderInStream.get(stream) ?? 0) + 1;
1655
+ orderInStream.set(stream, order);
1656
+ built.push({
1657
+ order,
1658
+ stream,
1659
+ actionId: action.id,
1660
+ requirementKey: slot.requirementKey,
1661
+ ...step.stopTestOnFailure ? { onFailStrategy: "failScenario" } : {},
1662
+ operationSubmissions: buildOperationSubmissions2(action, step.args, {
1663
+ strictMode: step.strict,
1664
+ expectedResults: step.expectedResults?.map((result) => ({
1665
+ commandName: result.command,
1666
+ requestPayload: result.request,
1667
+ responsePayload: result.response
1668
+ }))
1669
+ })
1670
+ });
1671
+ }
1672
+ return built;
1673
+ }
444
1674
  function registerTestTools(server) {
445
- const projectArg = z5.string().optional().describe("Project name or id (defaults to active project)");
1675
+ const projectArg = z6.string().optional().describe("Project name or id (defaults to active project)");
446
1676
  server.registerTool(
447
1677
  "list_tests",
448
1678
  {
449
1679
  annotations: { readOnlyHint: true },
450
1680
  title: "List tests",
451
- description: "List tests (scenarios) in the active project's sim world.",
1681
+ description: "List tests in the active project's sim world.",
452
1682
  inputSchema: {
453
- protocol: protocolEnum2.optional(),
454
- search: z5.string().optional().describe("Filter by test name"),
1683
+ protocol: protocolEnum3.optional(),
1684
+ search: z6.string().optional().describe("Filter by test name"),
455
1685
  ...paginationArgs,
456
1686
  project: projectArg
457
1687
  }
458
1688
  },
459
- async ({ protocol, search, page, pageSize, project }) => guard(async () => {
1689
+ async ({ protocol, search, page: page2, pageSize, project }) => guard(async () => {
460
1690
  const ctx = await resolveSimWorld(project);
461
1691
  const result = await sdk().tests.list({
462
1692
  simWorldId: ctx.simWorldId,
463
1693
  searchValue: search,
464
1694
  filters: protocol ? { protocol } : void 0,
465
- pagination: pagination(page, pageSize)
1695
+ pagination: pagination(page2, pageSize)
466
1696
  });
467
1697
  return ok(pageSummary("test(s)", result?.records?.length ?? 0, result?.pageInfo), result);
468
1698
  })
@@ -472,15 +1702,63 @@ function registerTestTools(server) {
472
1702
  {
473
1703
  annotations: { readOnlyHint: true },
474
1704
  title: "Get test",
475
- description: "Get a test by id, including its device requirements.",
1705
+ description: "Get a test by id, with its slots, steps, arguments and expected results.",
476
1706
  inputSchema: {
477
- testId: z5.string().describe("Test (scenario) id")
1707
+ testId: z6.string().describe("Test id")
478
1708
  }
479
1709
  },
480
1710
  async ({ testId }) => guard(async () => {
481
1711
  const test = await sdk().tests.get(testId);
482
1712
  if (!test) return ok(`No test found with id ${testId}.`);
483
- return ok(`Test ${test.name} (id: ${testId}).`, test);
1713
+ const { requirements, ...rest } = test;
1714
+ return ok(`Test ${test.name} (id: ${testId}).`, {
1715
+ ...rest,
1716
+ slots: requirements.map((requirement) => ({
1717
+ id: requirement.id,
1718
+ protocol: requirement.params.find((param) => param.key === "protocol")?.value
1719
+ }))
1720
+ });
1721
+ })
1722
+ );
1723
+ server.registerTool(
1724
+ "list_test_templates",
1725
+ {
1726
+ annotations: { readOnlyHint: true },
1727
+ title: "List test templates",
1728
+ description: "List ready-made tests, including protocol test cases such as 'TC_G_06_CSMS: Change Availability'. Search here before building a protocol test by hand; copy one with create_test_from_template.",
1729
+ inputSchema: {
1730
+ protocol: protocolEnum3.optional(),
1731
+ search: z6.string().optional().describe("Filter by name, e.g. 'CSMS' or 'TC_G'"),
1732
+ ...paginationArgs,
1733
+ project: projectArg
1734
+ }
1735
+ },
1736
+ async ({ protocol, search, page: page2, pageSize, project }) => guard(async () => {
1737
+ const ctx = await resolveSimWorld(project);
1738
+ const result = await sdk().tests.listTemplates({
1739
+ simWorldId: ctx.simWorldId,
1740
+ searchValue: search,
1741
+ filters: protocol ? { protocol } : void 0,
1742
+ pagination: pagination(page2, pageSize)
1743
+ });
1744
+ return ok(pageSummary("template(s)", result?.records?.length ?? 0, result?.pageInfo), result);
1745
+ })
1746
+ );
1747
+ server.registerTool(
1748
+ "create_test_from_template",
1749
+ {
1750
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1751
+ title: "Create test from template",
1752
+ description: "Copy a template into the project as a new test you can run and edit.",
1753
+ inputSchema: {
1754
+ templateId: z6.string().describe("Template id (see list_test_templates)"),
1755
+ project: projectArg
1756
+ }
1757
+ },
1758
+ async ({ templateId, project }) => guard(async () => {
1759
+ const ctx = await resolveSimWorld(project);
1760
+ const test = await sdk().tests.createFromTemplate(templateId, ctx.simWorldId);
1761
+ return ok(`Created test "${test?.name}" (id: ${test?.id}).`, test);
484
1762
  })
485
1763
  );
486
1764
  server.registerTool(
@@ -488,91 +1766,251 @@ function registerTestTools(server) {
488
1766
  {
489
1767
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
490
1768
  title: "Create test",
491
- description: "Create a test from an ordered list of steps. Each step references an action by name (use list_actions to discover them) plus optional key/value args. The steps run in order against a single simulator. Example steps: 'Power On Charger', 'Plug In Connector', 'Use RFID Card', 'Charge a Vehicle', 'Plug Out Connector', 'Power Off Charger'.",
1769
+ description: "Create a test from steps (use list_actions for names and arguments). Example: 'Power On Charger', 'Plug In Connector', 'Use RFID Card', 'Charge a Vehicle', 'Plug Out Connector', 'Power Off Charger'. Steps may run in parallel columns (stream). One slot by default; pass `slots` when several simulators must interact, such as site load sharing. To run the same test on many simulators use run_stress_test instead.",
492
1770
  inputSchema: {
493
- name: z5.string().describe("Test name"),
494
- protocol: protocolEnum2,
495
- steps: z5.array(
496
- z5.object({
497
- actionName: z5.string().describe("Exact action name, e.g. 'Power On Charger'"),
498
- args: z5.array(z5.object({ key: z5.string(), value: z5.string() })).optional().describe("Action arguments as key/value pairs")
499
- })
500
- ).min(1).describe("Ordered list of steps"),
1771
+ name: z6.string().describe("Test name"),
1772
+ description: z6.string().optional(),
1773
+ protocol: protocolEnum3.optional().describe("Protocol of the single slot; required unless `slots` is given"),
1774
+ slots: z6.array(z6.object({ name: z6.string().describe("Slot name, e.g. 'A'"), protocol: protocolEnum3 })).min(1).optional().describe("Slots for a test with several simulators or hardware"),
1775
+ steps: z6.array(stepSchema).min(1).describe("Ordered list of steps"),
501
1776
  project: projectArg
502
1777
  }
503
1778
  },
504
- async ({ name, protocol, steps, project }) => guard(async () => {
1779
+ async ({ name, description, protocol, slots, steps, project }) => guard(async () => {
1780
+ const declared = slots ?? (protocol ? [{ name: DEFAULT_SLOT, protocol }] : []);
1781
+ if (declared.length === 0) return fail("Pass `protocol`, or `slots` for a test with several simulators or hardware.");
505
1782
  const ctx = await resolveSimWorld(project);
506
- const actions = [];
507
- for (let i = 0; i < steps.length; i++) {
508
- const actionId = await resolveActionId(ctx.simWorldId, protocol, steps[i].actionName);
509
- actions.push({
510
- order: i + 1,
511
- stream: 1,
512
- actionId,
513
- requirementKey: "charger",
514
- args: steps[i].args ?? []
515
- });
516
- }
1783
+ const slotMap = new Map(declared.map((slot) => [slot.name, { protocol: slot.protocol, requirementKey: slot.name }]));
1784
+ const scenarioActions = await buildSteps(
1785
+ ctx.simWorldId,
1786
+ steps,
1787
+ slotMap,
1788
+ declared.length === 1 ? declared[0].name : void 0
1789
+ );
517
1790
  const test = await sdk().tests.create({
518
1791
  simWorldId: ctx.simWorldId,
519
1792
  name,
1793
+ description,
520
1794
  deviceType: "CHARGER",
521
- requirements: [
522
- { key: "charger", type: "CHARGER", params: [{ key: "protocol", value: protocol }] }
523
- ],
524
- actions
1795
+ requirements: declared.map((slot) => ({
1796
+ key: slot.name,
1797
+ type: "CHARGER",
1798
+ params: [{ key: "protocol", value: slot.protocol }]
1799
+ })),
1800
+ scenarioActions
525
1801
  });
526
1802
  return ok(`Created test "${name}" (id: ${test?.id}).`, test);
527
1803
  })
528
1804
  );
1805
+ server.registerTool(
1806
+ "update_test",
1807
+ {
1808
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1809
+ title: "Update test",
1810
+ description: "Update a test's name, description, priority or steps. `steps` replaces all steps; each step's `slot` is a slot id from get_test, and can be left out when the test has one slot.",
1811
+ inputSchema: {
1812
+ testId: z6.string().describe("Test id"),
1813
+ name: z6.string().optional(),
1814
+ description: z6.string().optional(),
1815
+ priority: z6.enum(["URGENT", "HIGH", "MEDIUM", "LOW"]).optional(),
1816
+ steps: z6.array(stepSchema).min(1).optional().describe("Replaces all steps"),
1817
+ project: z6.string().optional().describe("Project name or id the test belongs to (defaults to active project)")
1818
+ }
1819
+ },
1820
+ async ({ testId, name, description, priority, steps, project }) => guard(async () => {
1821
+ const ctx = await resolveSimWorld(project);
1822
+ const existing = await sdk().tests.get(testId);
1823
+ if (!existing) return fail(`No test found with id ${testId}.`);
1824
+ let stepChanges = {};
1825
+ if (steps) {
1826
+ const requirements = existing.requirements;
1827
+ const slotMap = new Map(
1828
+ requirements.map((requirement) => {
1829
+ const slotProtocol = requirement.params.find((param) => param.key === "protocol")?.value;
1830
+ if (!slotProtocol) throw new Error(`Slot ${requirement.id} of test ${testId} has no protocol.`);
1831
+ return [requirement.id, { protocol: slotProtocol, requirementKey: requirement.id }];
1832
+ })
1833
+ );
1834
+ stepChanges = {
1835
+ scenarioActions: await buildSteps(
1836
+ ctx.simWorldId,
1837
+ steps,
1838
+ slotMap,
1839
+ requirements.length === 1 ? requirements[0].id : void 0
1840
+ ),
1841
+ requirements: requirements.map((requirement) => ({
1842
+ key: requirement.id,
1843
+ type: requirement.type,
1844
+ params: requirement.params.map(({ key, value }) => ({ key, value }))
1845
+ }))
1846
+ };
1847
+ }
1848
+ const test = await sdk().tests.update({
1849
+ id: testId,
1850
+ simWorldId: ctx.simWorldId,
1851
+ name,
1852
+ description,
1853
+ priority,
1854
+ tags: (existing.tags ?? []).map(({ id, name: tagName, color }) => ({ id, name: tagName, color })),
1855
+ checklistTemplateItems: (existing.checklistTemplateItems ?? []).map(({ order, description: description2 }) => ({
1856
+ order,
1857
+ description: description2 ?? ""
1858
+ })),
1859
+ ...stepChanges
1860
+ });
1861
+ return ok(`Updated test (id: ${testId}).`, test);
1862
+ })
1863
+ );
1864
+ server.registerTool(
1865
+ "delete_test",
1866
+ {
1867
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1868
+ title: "Delete test",
1869
+ description: "Permanently delete a test. There is no undo.",
1870
+ inputSchema: {
1871
+ testId: z6.string().describe("Test id")
1872
+ }
1873
+ },
1874
+ async ({ testId }) => guard(async () => {
1875
+ await sdk().tests.delete(testId);
1876
+ return ok(`Deleted test ${testId}.`);
1877
+ })
1878
+ );
529
1879
  }
530
1880
 
531
1881
  // src/tools/test-runs.ts
532
- import { z as z6 } from "zod";
1882
+ import { z as z7 } from "zod";
1883
+ var conflictEnum = z7.enum(["FAIL", "RESCHEDULE", "FORCE_RUN"]);
1884
+ var CONFLICT_DESCRIPTION = "When something is busy. FAIL (default) starts nothing, RESCHEDULE uses the first free time, FORCE_RUN stops the runs holding it, so ask the user before using it.";
1885
+ function withoutInternalGroup(run) {
1886
+ if (run.category === "STRESS_TEST") return run;
1887
+ const { groupId: _groupId, ...rest } = run;
1888
+ return rest;
1889
+ }
1890
+ var SIMULATOR_PAGE_SIZE = 200;
1891
+ var FINISHED_STATUSES = /* @__PURE__ */ new Set(["COMPLETED", "FAILED", "SKIPPED"]);
1892
+ async function listSimulatorIds(simWorldId, selection) {
1893
+ const ids = [];
1894
+ for (let page2 = 1; ; page2++) {
1895
+ const result = await sdk().simulators.list({
1896
+ simWorldId,
1897
+ searchValue: selection.search,
1898
+ filters: { protocol: selection.protocol, tagIds: selection.tagIds },
1899
+ pagination: { page: page2, pageSize: SIMULATOR_PAGE_SIZE }
1900
+ });
1901
+ const records = result?.records ?? [];
1902
+ for (const record of records) if (record?.id) ids.push(record.id);
1903
+ if (records.length === 0 || !result?.pageInfo?.hasNextPage) return ids;
1904
+ }
1905
+ }
533
1906
  function registerTestRunTools(server) {
534
- const projectArg = z6.string().optional().describe("Project name or id (defaults to active project)");
1907
+ const projectArg = z7.string().optional().describe("Project name or id (defaults to active project)");
535
1908
  server.registerTool(
536
1909
  "run_test",
537
1910
  {
538
1911
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
539
1912
  title: "Run test",
540
- description: "Run a test (scenario) on a simulator, starting immediately. Returns the test run id and status \u2014 poll with get_test_run to follow it. A simulator runs one test at a time; if it is busy this says so and starts nothing.",
1913
+ description: "Run a test on a simulator or on hardware, now or at a set time. Returns the test run id and status; follow it with get_test_run. A simulator or hardware runs one test at a time: if it is busy this says so and starts nothing, unless onConflict says otherwise. A test with several slots needs `assignments`.",
541
1914
  inputSchema: {
542
- testId: z6.string().describe("Test (scenario) id to run"),
543
- simulator: z6.string().describe(`${SIMULATOR_ARG_DESCRIPTION} to run the test on`),
1915
+ testId: z7.string().describe("Test id to run"),
1916
+ simulator: z7.string().optional().describe(SIMULATOR_ARG_DESCRIPTION),
1917
+ hardware: z7.string().optional().describe("Hardware serial number or id (instead of simulator)"),
1918
+ assignments: z7.array(
1919
+ z7.object({
1920
+ slotId: z7.string().describe("Slot id from get_test"),
1921
+ simulator: z7.string().optional(),
1922
+ hardware: z7.string().optional()
1923
+ })
1924
+ ).min(1).optional().describe("Which simulator or hardware fills each slot, for a test with several slots"),
1925
+ speedup: z7.number().int().min(1).optional().describe("Time acceleration, e.g. 10 = 10x faster (default 1)"),
1926
+ startAt: z7.string().optional().describe("ISO 8601 start time; omit to start now"),
1927
+ onConflict: conflictEnum.optional().describe(CONFLICT_DESCRIPTION),
544
1928
  project: projectArg
545
1929
  }
546
1930
  },
547
- async ({ testId, simulator, project }) => guard(async () => {
548
- const { ctx, sim } = await resolveSimulatorTarget(simulator, project);
1931
+ async ({ testId, simulator, hardware, assignments, speedup, startAt, onConflict, project }) => guard(async () => {
549
1932
  const test = await sdk().tests.get(testId);
550
- const requirementId = test?.requirements?.[0]?.id;
551
- if (!requirementId) return fail(`Test ${testId} has no device requirement to assign.`);
1933
+ const requirements = test?.requirements ?? [];
1934
+ if (requirements.length === 0) return fail(`Test ${testId} has no slot to assign.`);
1935
+ const slots = assignments ?? [{ slotId: requirements[0].id, simulator, hardware }];
1936
+ if (!assignments && requirements.length > 1) {
1937
+ return fail(
1938
+ `Test ${testId} has ${requirements.length} slots (${requirements.map((r) => r.id).join(", ")}). Pass \`assignments\` with a simulator or hardware for each.`
1939
+ );
1940
+ }
1941
+ const assigned = slots.map((slot) => slot.slotId).sort().join(",");
1942
+ const expected = requirements.map((requirement) => requirement.id).sort().join(",");
1943
+ if (assigned !== expected) {
1944
+ return fail(`Assign exactly one simulator or hardware to each slot of test ${testId}: ${expected}.`);
1945
+ }
1946
+ const targets = await Promise.all(
1947
+ slots.map(async (slot) => ({
1948
+ slotId: slot.slotId,
1949
+ target: await resolveRunTarget({ simulator: slot.simulator, hardware: slot.hardware, project })
1950
+ }))
1951
+ );
1952
+ for (const { slotId, target } of targets) {
1953
+ const requirement = requirements.find((candidate) => candidate.id === slotId);
1954
+ const protocol = requirement?.params.find((param) => param.key === "protocol")?.value;
1955
+ if (protocol && protocol !== target.protocol) {
1956
+ return fail(`${target.name} speaks ${target.protocol}, but slot ${slotId} of this test needs ${protocol}.`);
1957
+ }
1958
+ }
552
1959
  const run = await sdk().testRuns.create({
553
1960
  scenarioId: testId,
554
- simWorldId: ctx.simWorldId,
555
- scheduleMode: "NOW",
556
- speedup: 1,
557
- forceDelete: false,
558
- requirementDevices: [
559
- { requirementId, deviceId: sim.id, deviceMode: "SIMULATOR" }
560
- ]
561
- }).catch(async (err) => {
562
- const message = err instanceof Error ? err.message : String(err);
563
- if (!/conflict/i.test(message)) throw err;
564
- const details = await sdk().simulators.get(sim.id).catch(() => null);
565
- const blocking = details?.lastRun;
566
- throw new Error(
567
- `${sim.baseName} (id: ${sim.id}) is busy` + (blocking?.id ? ` with test run ${blocking.id} (${blocking.status})` : "") + `, so nothing was started. Stop that run with stop_test_run \u2014 the simulator is free once it stops \u2014 then run this test again.`
568
- );
1961
+ simWorldId: targets[0].target.ctx.simWorldId,
1962
+ scheduleMode: startAt ? "DATETIME" : "NOW",
1963
+ startTimestamp: startAt,
1964
+ speedup: speedup ?? 1,
1965
+ conflictResolution: onConflict,
1966
+ requirementDevices: targets.map(({ slotId, target }) => ({
1967
+ requirementId: slotId,
1968
+ deviceId: target.id,
1969
+ deviceMode: target.kind
1970
+ }))
569
1971
  });
570
1972
  return ok(
571
- `Started test "${test?.name}" on ${sim.baseName} \u2014 run id ${run?.id}, status ${run?.status}.`,
1973
+ `Created run ${run?.id} of test "${test?.name}" on ${targets.map(({ target }) => target.name).join(", ")}, status ${run?.status}.`,
572
1974
  run
573
1975
  );
574
1976
  })
575
1977
  );
1978
+ server.registerTool(
1979
+ "complete_test_step",
1980
+ {
1981
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1982
+ title: "Complete a waiting step",
1983
+ description: "Mark a waiting manual step as passed: a Remote: step on a simulator, or a charger-side step on hardware. Only needed for steps without expected results; those with expected results pass on their own. Ask the user whether the action really happened before calling this. stepId comes from get_test_run_details.",
1984
+ inputSchema: {
1985
+ stepId: z7.string().describe("Step id from get_test_run_details"),
1986
+ project: projectArg
1987
+ }
1988
+ },
1989
+ async ({ stepId, project }) => guard(async () => {
1990
+ const ctx = await resolveSimWorld(project);
1991
+ const status = await sdk().testRuns.completeManualStep(stepId, ctx.simWorldId);
1992
+ if (status === "ERROR") return fail(`Step ${stepId} could not be marked as passed. Is it still waiting?`);
1993
+ return ok(`Asked to mark step ${stepId} as passed (${status}). Confirm its status with get_test_run_details.`);
1994
+ })
1995
+ );
1996
+ server.registerTool(
1997
+ "fail_test_step",
1998
+ {
1999
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2000
+ title: "Fail a waiting step",
2001
+ description: "Mark a waiting manual step as failed. stepId comes from get_test_run_details.",
2002
+ inputSchema: {
2003
+ stepId: z7.string().describe("Step id from get_test_run_details"),
2004
+ project: projectArg
2005
+ }
2006
+ },
2007
+ async ({ stepId, project }) => guard(async () => {
2008
+ const ctx = await resolveSimWorld(project);
2009
+ const status = await sdk().testRuns.failManualStep(stepId, ctx.simWorldId);
2010
+ if (status === "ERROR") return fail(`Step ${stepId} could not be marked as failed. Is it still waiting?`);
2011
+ return ok(`Asked to mark step ${stepId} as failed (${status}). Confirm its status with get_test_run_details.`);
2012
+ })
2013
+ );
576
2014
  server.registerTool(
577
2015
  "get_test_run",
578
2016
  {
@@ -580,14 +2018,14 @@ function registerTestRunTools(server) {
580
2018
  title: "Get test run",
581
2019
  description: "Get a test run's status by id.",
582
2020
  inputSchema: {
583
- runId: z6.string().describe("Test run id")
2021
+ runId: z7.string().describe("Test run id")
584
2022
  }
585
2023
  },
586
2024
  async ({ runId }) => guard(async () => {
587
2025
  const run = await sdk().testRuns.get(runId);
588
2026
  if (!run) return ok(`No test run found with id ${runId}.`);
589
2027
  const reason = run.failReason ? ` \u2014 ${run.failReason}` : "";
590
- return ok(`Test run ${runId} ("${run.scenario?.name}"): ${run.status}${reason}.`, run);
2028
+ return ok(`Test run ${runId} ("${run.scenario?.name}"): ${run.status}${reason}.`, withoutInternalGroup(run));
591
2029
  })
592
2030
  );
593
2031
  server.registerTool(
@@ -601,13 +2039,16 @@ function registerTestRunTools(server) {
601
2039
  project: projectArg
602
2040
  }
603
2041
  },
604
- async ({ page, pageSize, project }) => guard(async () => {
2042
+ async ({ page: page2, pageSize, project }) => guard(async () => {
605
2043
  const ctx = await resolveSimWorld(project);
606
2044
  const result = await sdk().testRuns.list({
607
2045
  simWorldId: ctx.simWorldId,
608
- pagination: pagination(page, pageSize)
2046
+ pagination: pagination(page2, pageSize)
2047
+ });
2048
+ return ok(pageSummary("test run(s)", result?.records?.length ?? 0, result?.pageInfo), {
2049
+ ...result,
2050
+ records: (result?.records ?? []).map((record) => record && withoutInternalGroup(record))
609
2051
  });
610
- return ok(pageSummary("test run(s)", result?.records?.length ?? 0, result?.pageInfo), result);
611
2052
  })
612
2053
  );
613
2054
  server.registerTool(
@@ -615,39 +2056,402 @@ function registerTestRunTools(server) {
615
2056
  {
616
2057
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
617
2058
  title: "Stop test run",
618
- description: "Shut down (stop) a running or scheduled test run by id.",
2059
+ description: "Stop a running or scheduled test run. For a run in a stress test use stop_stress_test, and for a run in a test plan use stop_test_plan: stopping one such run would not stop the rest.",
619
2060
  inputSchema: {
620
- runId: z6.string().describe("Test run id")
2061
+ runId: z7.string().describe("Test run id")
621
2062
  }
622
2063
  },
623
2064
  async ({ runId }) => guard(async () => {
624
2065
  const existing = await sdk().testRuns.get(runId);
625
2066
  if (!existing) return fail(`No test run found with id ${runId}.`);
626
- await sdk().testRuns.shutDown(runId);
2067
+ if (FINISHED_STATUSES.has(String(existing.status))) {
2068
+ return fail(`Test run ${runId} has already finished: it is ${existing.status}.`);
2069
+ }
2070
+ if (existing.category === "STRESS_TEST") {
2071
+ return fail(`Test run ${runId} is part of stress test ${existing.groupId}. Use stop_stress_test.`);
2072
+ }
2073
+ if (existing.testPlanId) {
2074
+ return fail(`Test run ${runId} is part of test plan ${existing.testPlanId}. Use stop_test_plan.`);
2075
+ }
2076
+ const stopped = await sdk().testRuns.shutDown(runId);
2077
+ if (!stopped) return fail(`Test run ${runId} was not stopped: it is ${existing.status}.`);
627
2078
  const after = await sdk().testRuns.get(runId);
628
2079
  return ok(
629
- `Shut down test run ${runId} (was ${existing.status}, now ${after?.status ?? "unknown"}).`,
630
- after
2080
+ `Stopped test run ${runId} (was ${existing.status}, now ${after?.status ?? "unknown"}).`,
2081
+ after && withoutInternalGroup(after)
631
2082
  );
632
2083
  })
633
2084
  );
634
- }
635
-
636
- // src/tools/support.ts
637
- import { z as z7 } from "zod";
638
-
639
- // src/logs-format.ts
640
- var DEFAULT_LOGS_LAST = "1h";
641
- var DEFAULT_LOGS_LIMIT = 200;
642
- var MAX_LOGS_LIMIT = 1e3;
643
- var PAYLOAD_MAX_CHARS = 500;
644
- var UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
645
- function parseDuration(value) {
646
- const match = value.trim().match(/^(\d+)\s*([smhd])$/i);
647
- if (!match) throw new Error(`Invalid duration "${value}". Use a number and a unit: 30m, 6h, 2d.`);
648
- return Number(match[1]) * UNIT_MS[match[2].toLowerCase()];
649
- }
650
- function parseInstant(value, name) {
2085
+ server.registerTool(
2086
+ "update_test_run",
2087
+ {
2088
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2089
+ title: "Update test run",
2090
+ description: "Change a scheduled test run's start time or speed-up factor. Only applies to runs that haven't started yet. To run on other simulators, delete it and use run_test.",
2091
+ inputSchema: {
2092
+ runId: z7.string().describe("Test run id"),
2093
+ startAt: z7.string().optional().describe("New ISO 8601 start time"),
2094
+ speedup: z7.number().int().min(1).optional().describe("Time acceleration factor, e.g. 10 = 10x faster"),
2095
+ onConflict: conflictEnum.optional().describe(CONFLICT_DESCRIPTION)
2096
+ }
2097
+ },
2098
+ async ({ runId, startAt, speedup, onConflict }) => guard(async () => {
2099
+ const current = await sdk().testRuns.get(runId);
2100
+ if (!current) return fail(`No test run found with id ${runId}.`);
2101
+ const startTimestamp = startAt ?? current.startTimestamp;
2102
+ if (!startTimestamp) return fail(`Test run ${runId} has no start time. Pass \`startAt\`.`);
2103
+ const run = await sdk().testRuns.update({
2104
+ id: runId,
2105
+ startTimestamp,
2106
+ speedup: speedup ?? current.speedup ?? 1,
2107
+ conflictResolution: onConflict
2108
+ });
2109
+ return ok(`Updated test run (id: ${runId}).`, run);
2110
+ })
2111
+ );
2112
+ server.registerTool(
2113
+ "delete_test_run",
2114
+ {
2115
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2116
+ title: "Delete test run",
2117
+ description: "Permanently delete a test run and its results. Stop it first if it is still running (stop_test_run).",
2118
+ inputSchema: {
2119
+ runId: z7.string().describe("Test run id")
2120
+ }
2121
+ },
2122
+ async ({ runId }) => guard(async () => {
2123
+ await sdk().testRuns.delete(runId);
2124
+ return ok(`Deleted test run ${runId}.`);
2125
+ })
2126
+ );
2127
+ server.registerTool(
2128
+ "list_latest_test_runs",
2129
+ {
2130
+ annotations: { readOnlyHint: true },
2131
+ title: "List latest test runs",
2132
+ description: "List what ran most recently in the active project's sim world, the quickest way to see how it went. Scheduled runs are not included. An entry with totalCount above 1 is a stress test; use its stressTestId with get_stress_test.",
2133
+ inputSchema: {
2134
+ limit: z7.number().int().min(1).max(50).optional().describe("How many entries to return (default 10)"),
2135
+ project: projectArg
2136
+ }
2137
+ },
2138
+ async ({ limit, project }) => guard(async () => {
2139
+ const ctx = await resolveSimWorld(project);
2140
+ const runs = await sdk().testRuns.listLatest(ctx.simWorldId, limit ?? 10);
2141
+ return ok(`Found ${runs?.length ?? 0} recent run(s).`, runs);
2142
+ })
2143
+ );
2144
+ server.registerTool(
2145
+ "list_stress_tests",
2146
+ {
2147
+ annotations: { readOnlyHint: true },
2148
+ title: "List stress tests",
2149
+ description: "List stress tests in the active project's sim world, newest first, including scheduled ones, with their counts and stressTestId. Pass testId to find the one you just queued with run_stress_test; it appears once its background job has created the runs.",
2150
+ inputSchema: {
2151
+ testId: z7.string().optional().describe("Only stress tests of this test"),
2152
+ ...paginationArgs,
2153
+ project: projectArg
2154
+ }
2155
+ },
2156
+ async ({ testId, page: page2, pageSize, project }) => guard(async () => {
2157
+ const ctx = await resolveSimWorld(project);
2158
+ const result = await sdk().testRuns.listGrouped({
2159
+ simWorldId: ctx.simWorldId,
2160
+ filters: { categories: ["STRESS_TEST"], ...testId ? { scenarioIds: [testId] } : {} },
2161
+ pagination: pagination(page2, pageSize)
2162
+ });
2163
+ return ok(pageSummary("stress test(s)", result?.records?.length ?? 0, result?.pageInfo), result);
2164
+ })
2165
+ );
2166
+ server.registerTool(
2167
+ "stop_stress_test",
2168
+ {
2169
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2170
+ title: "Stop stress test",
2171
+ description: "Stop every run of a stress test. The stressTestId comes from list_stress_tests.",
2172
+ inputSchema: {
2173
+ stressTestId: z7.string().describe("Stress test id"),
2174
+ project: projectArg
2175
+ }
2176
+ },
2177
+ async ({ stressTestId, project }) => guard(async () => {
2178
+ const ctx = await resolveSimWorld(project);
2179
+ const status = await sdk().testRuns.shutDownGroup(stressTestId, ctx.simWorldId);
2180
+ if (status === "ERROR") return fail(`Stress test ${stressTestId} could not be stopped.`);
2181
+ return ok(`Asked to stop stress test ${stressTestId} (${status}). Follow it with get_stress_test.`);
2182
+ })
2183
+ );
2184
+ server.registerTool(
2185
+ "get_stress_test",
2186
+ {
2187
+ annotations: { readOnlyHint: true },
2188
+ title: "Get stress test summary",
2189
+ description: "Summarize a stress test: counts of planned, in-progress, completed, failed, skipped and draft runs. The stressTestId comes from list_stress_tests.",
2190
+ inputSchema: {
2191
+ stressTestId: z7.string().describe("Stress test id")
2192
+ }
2193
+ },
2194
+ async ({ stressTestId }) => guard(async () => {
2195
+ const group = await sdk().testRuns.getGrouped(stressTestId);
2196
+ return ok(`Stress test ${stressTestId}: ${group?.completedCount ?? 0} completed, ${group?.failedCount ?? 0} failed, ${group?.skippedCount ?? 0} skipped, ${group?.inProgressCount ?? 0} in progress of ${group?.totalCount ?? 0} total.`, group);
2197
+ })
2198
+ );
2199
+ server.registerTool(
2200
+ "run_stress_test",
2201
+ {
2202
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2203
+ title: "Run stress test",
2204
+ description: "Run one test on two or more simulators at once, the load-testing primitive: one run per selected simulator, for a test with one slot. Select with `simulatorIds`, or with `allSimulators` plus optional search, tagIds and excludeSimulatorIds; simulators of the test's protocol are used. It starts in the background: find it with list_stress_tests, then follow it with get_stress_test.",
2205
+ inputSchema: {
2206
+ testId: z7.string().describe("Test id to run"),
2207
+ simulatorIds: z7.array(z7.string()).min(1).optional().describe("Run on exactly these simulators"),
2208
+ allSimulators: z7.boolean().optional().describe("Run on every simulator matching the filters"),
2209
+ search: z7.string().optional().describe("With allSimulators: filter by simulator name"),
2210
+ tagIds: z7.array(z7.string()).optional().describe("With allSimulators: filter by tags"),
2211
+ excludeSimulatorIds: z7.array(z7.string()).optional().describe("With allSimulators: leave these out"),
2212
+ speedup: z7.number().int().min(1).optional().describe("Time acceleration, e.g. 10 = 10x faster (default 1)"),
2213
+ startAt: z7.string().optional().describe("ISO 8601 start time (omit to start now)"),
2214
+ onConflict: conflictEnum.optional().describe(CONFLICT_DESCRIPTION),
2215
+ project: projectArg
2216
+ }
2217
+ },
2218
+ async ({ testId, simulatorIds, allSimulators, search, tagIds, excludeSimulatorIds, speedup, startAt, onConflict, project }) => guard(async () => {
2219
+ if (Boolean(simulatorIds) === Boolean(allSimulators)) {
2220
+ return fail("Pass exactly one of `simulatorIds` or `allSimulators`.");
2221
+ }
2222
+ const ctx = await resolveSimWorld(project);
2223
+ const test = await sdk().tests.get(testId);
2224
+ const requirements = test?.requirements ?? [];
2225
+ if (requirements.length !== 1) {
2226
+ return fail(
2227
+ `A stress test needs a test with one slot; test ${testId} has ${requirements.length}. Use run_test with \`assignments\` for a test with several.`
2228
+ );
2229
+ }
2230
+ const requirement = requirements[0];
2231
+ const protocol = requirement.params.find((param) => param.key === "protocol")?.value;
2232
+ const matching = await listSimulatorIds(
2233
+ ctx.simWorldId,
2234
+ allSimulators ? { search, protocol, tagIds } : { protocol }
2235
+ );
2236
+ const wrong = (simulatorIds ?? []).filter((id) => !matching.includes(id));
2237
+ if (wrong.length > 0) {
2238
+ return fail(`Simulators ${wrong.join(", ")} are not ${protocol} simulators in this project.`);
2239
+ }
2240
+ const selected = allSimulators ? matching.filter((id) => !excludeSimulatorIds?.includes(id)) : [...new Set(simulatorIds)];
2241
+ if (selected.length === 0) return fail("No simulators match the selection.");
2242
+ if (selected.length === 1) {
2243
+ return fail(`Only simulator ${selected[0]} matches, so this is not a stress test. Use run_test.`);
2244
+ }
2245
+ const multiplier = selected.length;
2246
+ await sdk().testRuns.createBulkRuns({
2247
+ scenarioId: testId,
2248
+ simWorldId: ctx.simWorldId,
2249
+ multiplier,
2250
+ speedup: speedup ?? 1,
2251
+ startTimestamp: startAt,
2252
+ scheduleMode: startAt ? "DATETIME" : "NOW",
2253
+ conflictResolution: onConflict,
2254
+ bulkRequirementDevices: [
2255
+ {
2256
+ requirementId: requirement.id,
2257
+ includedDevices: selected.map((deviceId) => ({ deviceId, deviceMode: "SIMULATOR" }))
2258
+ }
2259
+ ]
2260
+ });
2261
+ return ok(
2262
+ `Queued a stress test: ${multiplier} run(s) of "${test?.name}" at ${speedup ?? 1}x speed. It is created in the background. Find it with list_stress_tests (testId ${testId}), do not start it again, then follow it with get_stress_test.`
2263
+ );
2264
+ })
2265
+ );
2266
+ }
2267
+
2268
+ // src/tools/test-plans.ts
2269
+ import { z as z8 } from "zod";
2270
+ function registerTestPlanTools(server) {
2271
+ const projectArg = z8.string().optional().describe("Project name or id (defaults to active project)");
2272
+ server.registerTool(
2273
+ "list_test_plans",
2274
+ {
2275
+ annotations: { readOnlyHint: true },
2276
+ title: "List test plans",
2277
+ description: "List test plans in the active project's sim world. A plan runs several tests one after another on one simulator or hardware, now or at a set time.",
2278
+ inputSchema: {
2279
+ search: z8.string().optional().describe("Filter by plan name"),
2280
+ ...paginationArgs,
2281
+ project: projectArg
2282
+ }
2283
+ },
2284
+ async ({ search, page: page2, pageSize, project }) => guard(async () => {
2285
+ const ctx = await resolveSimWorld(project);
2286
+ const result = await sdk().testPlans.list({
2287
+ filters: { simWorldId: ctx.simWorldId, name: search },
2288
+ pagination: pagination(page2, pageSize)
2289
+ });
2290
+ return ok(
2291
+ pageSummary("test plan(s)", result?.records?.length ?? 0, result?.pageInfo),
2292
+ result
2293
+ );
2294
+ })
2295
+ );
2296
+ server.registerTool(
2297
+ "get_test_plan",
2298
+ {
2299
+ annotations: { readOnlyHint: true },
2300
+ title: "Get test plan",
2301
+ description: "Get a test plan by id, with its status and scheduled start.",
2302
+ inputSchema: {
2303
+ planId: z8.string().describe("Test plan id (see list_test_plans)")
2304
+ }
2305
+ },
2306
+ async ({ planId }) => guard(async () => {
2307
+ const plan = await sdk().testPlans.get(planId);
2308
+ if (!plan) return ok(`No test plan found with id ${planId}.`);
2309
+ return ok(`Test plan ${plan.name} (id: ${planId}).`, plan);
2310
+ })
2311
+ );
2312
+ server.registerTool(
2313
+ "create_test_plan",
2314
+ {
2315
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2316
+ title: "Create test plan",
2317
+ description: "Create a test plan: a set of tests run one after another on one simulator or one hardware. Next step: launch it with launch_test_plan.",
2318
+ inputSchema: {
2319
+ name: z8.string(),
2320
+ description: z8.string().optional(),
2321
+ testIds: z8.array(z8.string()).min(1).describe("Test ids to include (see list_tests)"),
2322
+ simulatorId: z8.string().optional().describe("Simulator the plan runs on (see list_simulators)"),
2323
+ hardwareId: z8.string().optional().describe("Hardware the plan runs on, instead of a simulator"),
2324
+ project: projectArg
2325
+ }
2326
+ },
2327
+ async ({ name, description, testIds, simulatorId, hardwareId, project }) => guard(async () => {
2328
+ if (simulatorId && hardwareId) return fail("Pass `simulatorId` or `hardwareId`, not both.");
2329
+ const ctx = await resolveSimWorld(project);
2330
+ const plan = await sdk().testPlans.create({
2331
+ simWorldId: ctx.simWorldId,
2332
+ name,
2333
+ description,
2334
+ scenarios: testIds,
2335
+ chargerIds: simulatorId ? [simulatorId] : void 0,
2336
+ deviceIds: hardwareId ? [hardwareId] : void 0
2337
+ });
2338
+ return ok(`Created test plan "${name}" (id: ${plan?.id}).`, plan);
2339
+ })
2340
+ );
2341
+ server.registerTool(
2342
+ "update_test_plan",
2343
+ {
2344
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2345
+ title: "Update test plan",
2346
+ description: "Update a test plan's name or description, or, before it is launched, the simulator or hardware it runs on. Its tests cannot be changed; create a new plan for different tests.",
2347
+ inputSchema: {
2348
+ planId: z8.string().describe("Test plan id (see list_test_plans)"),
2349
+ name: z8.string().optional(),
2350
+ description: z8.string().optional(),
2351
+ simulatorId: z8.string().optional().describe("Run on this simulator instead"),
2352
+ hardwareId: z8.string().optional().describe("Run on this hardware instead")
2353
+ }
2354
+ },
2355
+ async ({ planId, name, description, simulatorId, hardwareId }) => guard(async () => {
2356
+ if (simulatorId && hardwareId) return fail("Pass `simulatorId` or `hardwareId`, not both.");
2357
+ const current = await sdk().testPlans.get(planId);
2358
+ if (!current) return fail(`No test plan found with id ${planId}.`);
2359
+ if ((simulatorId || hardwareId) && current.status !== "DRAFT") {
2360
+ return fail(
2361
+ `Test plan ${planId} is ${current.status}: its runs are already bound to their simulator or hardware. Create a new plan to run elsewhere.`
2362
+ );
2363
+ }
2364
+ const plan = await sdk().testPlans.update({
2365
+ id: planId,
2366
+ name,
2367
+ description,
2368
+ tags: (current.tags ?? []).map(({ id, name: tagName, color }) => ({ id, name: tagName, color })),
2369
+ ...simulatorId ? { chargerIds: [simulatorId], deviceIds: [] } : {},
2370
+ ...hardwareId ? { deviceIds: [hardwareId], chargerIds: [] } : {}
2371
+ });
2372
+ return ok(`Updated test plan (id: ${planId}).`, plan);
2373
+ })
2374
+ );
2375
+ server.registerTool(
2376
+ "launch_test_plan",
2377
+ {
2378
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2379
+ title: "Launch test plan",
2380
+ description: "Launch a test plan now, or at a set time with startAt. Follow progress with list_test_runs and get_test_run.",
2381
+ inputSchema: {
2382
+ planId: z8.string().describe("Test plan id (see list_test_plans)"),
2383
+ startAt: z8.string().optional().describe("ISO 8601 start time; omit to start now"),
2384
+ onConflict: z8.enum(["FAIL", "RESCHEDULE", "FORCE_RUN"]).optional().describe(
2385
+ "When something is busy. FAIL (default) starts nothing, RESCHEDULE uses the first free time, FORCE_RUN stops the runs holding it, so ask the user before using it."
2386
+ )
2387
+ }
2388
+ },
2389
+ async ({ planId, startAt, onConflict }) => guard(async () => {
2390
+ const plan = await sdk().testPlans.launch({
2391
+ id: planId,
2392
+ scheduleMode: startAt ? "DATETIME" : "NOW",
2393
+ startTimestamp: startAt,
2394
+ conflictResolution: onConflict
2395
+ });
2396
+ return ok(`Launched test plan (id: ${planId})${startAt ? ` for ${startAt}` : ""}.`, plan);
2397
+ })
2398
+ );
2399
+ server.registerTool(
2400
+ "stop_test_plan",
2401
+ {
2402
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2403
+ title: "Stop test plan",
2404
+ description: "Stop a launched test plan: the tests that have not started are skipped, and the one in progress is stopped too unless keepActive is true. Stopping the active run leaves its simulator or hardware in a non-default state.",
2405
+ inputSchema: {
2406
+ planId: z8.string().describe("Test plan id (see list_test_plans)"),
2407
+ keepActive: z8.boolean().optional().describe("Let the test in progress finish (default false)")
2408
+ }
2409
+ },
2410
+ async ({ planId, keepActive }) => guard(async () => {
2411
+ const current = await sdk().testPlans.get(planId);
2412
+ if (!current) return fail(`No test plan found with id ${planId}.`);
2413
+ if (current.status === "DRAFT") {
2414
+ return fail(`Test plan ${planId} has not been launched, so there is nothing to stop.`);
2415
+ }
2416
+ if (current.status === "COMPLETED" || current.status === "FAILED" || current.status === "SKIPPED") {
2417
+ return fail(`Test plan ${planId} has already finished: it is ${current.status}.`);
2418
+ }
2419
+ const plan = await sdk().testPlans.skipRemaining(planId, keepActive ? "PENDING_ONLY" : "INCLUDING_ACTIVE");
2420
+ return ok(`Stopped test plan ${planId}, now ${plan?.status ?? "unknown"}.`, plan);
2421
+ })
2422
+ );
2423
+ server.registerTool(
2424
+ "delete_test_plan",
2425
+ {
2426
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2427
+ title: "Delete test plan",
2428
+ description: "Permanently delete a test plan.",
2429
+ inputSchema: {
2430
+ planId: z8.string().describe("Test plan id (see list_test_plans)")
2431
+ }
2432
+ },
2433
+ async ({ planId }) => guard(async () => {
2434
+ await sdk().testPlans.delete(planId);
2435
+ return ok(`Deleted test plan ${planId}.`);
2436
+ })
2437
+ );
2438
+ }
2439
+
2440
+ // src/tools/support.ts
2441
+ import { z as z9 } from "zod";
2442
+
2443
+ // src/logs-format.ts
2444
+ var DEFAULT_LOGS_LAST = "1h";
2445
+ var DEFAULT_LOGS_LIMIT = 200;
2446
+ var MAX_LOGS_LIMIT = 1e3;
2447
+ var PAYLOAD_MAX_CHARS = 500;
2448
+ var UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
2449
+ function parseDuration(value) {
2450
+ const match = value.trim().match(/^(\d+)\s*([smhd])$/i);
2451
+ if (!match) throw new Error(`Invalid duration "${value}". Use a number and a unit: 30m, 6h, 2d.`);
2452
+ return Number(match[1]) * UNIT_MS[match[2].toLowerCase()];
2453
+ }
2454
+ function parseInstant(value, name) {
651
2455
  const date = new Date(value);
652
2456
  if (Number.isNaN(date.getTime())) throw new Error(`Invalid ${name} "${value}". Use an ISO date-time.`);
653
2457
  return date;
@@ -756,21 +2560,29 @@ function findRunSimulator(records, simulator) {
756
2560
  (rc) => rc.charger?.baseName?.toLowerCase() === needle || rc.device?.serialNumber?.toLowerCase() === needle
757
2561
  );
758
2562
  }
759
- function summarizeRun(run, simulators, page, steps) {
2563
+ function summarizeRun(run, simulators, page2, steps) {
760
2564
  const records = (simulators?.records ?? []).filter(Boolean);
761
2565
  const lines = [
762
2566
  `Test run ${run.id} ("${run.scenario?.name ?? "?"}"): ${run.status}` + (run.failReason ? ` \u2014 ${run.failReason}` : "") + `. Started ${run.realStartTimestamp ?? run.startTimestamp ?? "\u2014"}, stopped ${run.realStopTimestamp ?? "\u2014"}.`,
763
- pageSummary("simulator(s)", records.length, simulators?.pageInfo ?? { page })
2567
+ pageSummary("simulator(s)", records.length, simulators?.pageInfo ?? { page: page2 })
764
2568
  ];
765
2569
  for (const rc of records) {
766
- lines.push(`- ${simulatorName(rc)} (simulator ${rc.charger?.id ?? rc.device?.id ?? "?"}, run entry ${rc.id}): ${rc.status}`);
2570
+ const target = rc.charger?.id ? `simulator ${rc.charger.id}` : `hardware ${rc.device?.id ?? "?"}`;
2571
+ lines.push(`- ${simulatorName(rc)} (${target}, run entry ${rc.id}): ${rc.status}`);
767
2572
  }
768
2573
  if (steps) {
769
2574
  lines.push("", `Steps of ${simulatorName(steps)}:`);
770
2575
  for (const step of steps.chargerActions ?? []) {
2576
+ const reason = step.displayFailReason ?? step.failReason;
2577
+ const action = step.scenarioAction?.action;
771
2578
  lines.push(
772
- ` step ${step.order} ${step.scenarioAction?.action?.name ?? "?"}: ${step.status}` + (step.failReason ? ` \u2014 ${step.failReason}` : "")
2579
+ ` step ${step.order} ${action?.name ?? "?"} (id: ${step.id}${action?.deviceType === "CSMS" ? ", remote" : ""}): ${step.status}${reason ? ` \u2014 ${reason}` : ""}`
773
2580
  );
2581
+ for (const result of step.resultComparisons ?? []) {
2582
+ lines.push(
2583
+ ` expected ${result.command} ${JSON.stringify(result.expectedResponse ?? result.expectedRequest ?? {})}, actual ${result.actualCommand ?? "nothing yet"} ${JSON.stringify(result.actualResponse ?? result.actualRequest ?? null)}`
2584
+ );
2585
+ }
774
2586
  }
775
2587
  } else if (records.length > 1 || simulators?.pageInfo?.hasNextPage) {
776
2588
  lines.push("", "Pass `simulator` with one of the names or ids above to see its steps.");
@@ -778,13 +2590,13 @@ function summarizeRun(run, simulators, page, steps) {
778
2590
  return lines.join("\n");
779
2591
  }
780
2592
  function registerSupportTools(server) {
781
- const projectArg = z7.string().optional().describe("Project name or id (defaults to active project; a portal link sets its own)");
2593
+ const projectArg = z9.string().optional().describe("Project name or id (defaults to active project; a portal link sets its own)");
782
2594
  server.registerTool(
783
2595
  "get_simulator_config",
784
2596
  {
785
2597
  title: "Get simulator configuration",
786
2598
  description: "Read-only. Everything that explains why a simulator behaves the way it does: its OCPP configuration keys (e.g. FreevendEnabled, HeartbeatInterval), behavior settings (vendor behavior, remote start/stop templates, supported commands, timeouts), model and connector types (fixed or flexible cable), live connectors, authorizations, charging profiles, transactions, reservations, and the last test run with its fail reason. Credential values are masked.",
787
- inputSchema: { simulator: z7.string().describe(SIMULATOR_ARG_DESCRIPTION), project: projectArg },
2599
+ inputSchema: { simulator: z9.string().describe(SIMULATOR_ARG_DESCRIPTION), project: projectArg },
788
2600
  annotations: READ_ONLY
789
2601
  },
790
2602
  async ({ simulator, project }) => guard(async () => {
@@ -793,31 +2605,33 @@ function registerSupportTools(server) {
793
2605
  if (!details) return ok(`No simulator found with id ${sim.id}.`);
794
2606
  const items = await sdk().simulators.getOcppConfigItems(sim.id);
795
2607
  const safe = { ...details, chargerOcppConfigItems: maskSecretItems(items) };
796
- return ok(summarizeSimulator(safe), safe);
2608
+ const unreadable = details.authorizations === null ? "\nIts authorizations could not be read." : "";
2609
+ return ok(`${summarizeSimulator(safe)}${unreadable}`, safe);
797
2610
  })
798
2611
  );
799
2612
  server.registerTool(
800
2613
  "get_simulator_logs",
801
2614
  {
802
- title: "Get simulator logs",
803
- description: "Read-only. The simulator's OCPP message log for a time window, one line per message: `time direction action payload` (payload cut at ~500 chars). Newest messages are kept when the limit cuts the window; lines are printed oldest first. Use `action` to keep one OCPP action, e.g. StatusNotification (its results and errors are kept too).",
2615
+ title: "Get simulator or hardware logs",
2616
+ description: "Read-only. The OCPP message log of a simulator or hardware for a time window, one line per message: `time direction action payload` (payload cut at ~500 chars). Newest messages are kept when the limit cuts the window; lines are printed oldest first. Use `action` to keep one OCPP action, e.g. StatusNotification (its results and errors are kept too).",
804
2617
  inputSchema: {
805
- simulator: z7.string().describe(SIMULATOR_ARG_DESCRIPTION),
806
- from: z7.string().optional().describe("Window start, ISO date-time (wins over `last`)"),
807
- to: z7.string().optional().describe("Window end, ISO date-time (default now)"),
808
- last: z7.string().optional().describe(`Window length back from \`to\`, e.g. 30m, 6h, 2d (default ${DEFAULT_LOGS_LAST})`),
809
- limit: z7.number().int().min(1).max(MAX_LOGS_LIMIT).optional().describe(`Max messages to show (default ${DEFAULT_LOGS_LIMIT}, max ${MAX_LOGS_LIMIT})`),
810
- action: z7.string().optional().describe("Keep only this OCPP action, e.g. StatusNotification"),
2618
+ simulator: z9.string().optional().describe(SIMULATOR_ARG_DESCRIPTION),
2619
+ hardware: z9.string().optional().describe("Hardware serial number or id (instead of simulator)"),
2620
+ from: z9.string().optional().describe("Window start, ISO date-time (wins over `last`)"),
2621
+ to: z9.string().optional().describe("Window end, ISO date-time (default now)"),
2622
+ last: z9.string().optional().describe(`Window length back from \`to\`, e.g. 30m, 6h, 2d (default ${DEFAULT_LOGS_LAST})`),
2623
+ limit: z9.number().int().min(1).max(MAX_LOGS_LIMIT).optional().describe(`Max messages to show (default ${DEFAULT_LOGS_LIMIT}, max ${MAX_LOGS_LIMIT})`),
2624
+ action: z9.string().optional().describe("Keep only this OCPP action, e.g. StatusNotification"),
811
2625
  project: projectArg
812
2626
  },
813
2627
  annotations: READ_ONLY
814
2628
  },
815
- async ({ simulator, from, to, last, limit, action, project }) => guard(async () => {
2629
+ async ({ simulator, hardware, from, to, last, limit, action, project }) => guard(async () => {
816
2630
  const window = resolveWindow({ from, to, last });
817
- const { sim } = await resolveSimulatorTarget(simulator, project);
2631
+ const target = await resolveRunTarget({ simulator, hardware, project });
818
2632
  const shown = Math.min(limit ?? DEFAULT_LOGS_LIMIT, MAX_LOGS_LIMIT);
819
2633
  const result = await sdk().logs.list({
820
- simulatorId: sim.id,
2634
+ ...target.kind === "SIMULATOR" ? { simulatorId: target.id } : { hardwareId: target.id },
821
2635
  from: window.from,
822
2636
  to: window.to,
823
2637
  limit: action ? MAX_LOGS_LIMIT : shown
@@ -826,7 +2640,7 @@ function registerSupportTools(server) {
826
2640
  const matched = action ? all.filter((line) => matchesAction(line, action)) : all;
827
2641
  const lines = matched.slice(0, shown).reverse();
828
2642
  const truncated = matched.length > shown || Boolean(result.pageInfo?.hasNextPage);
829
- const head = `${sim.baseName} (id: ${sim.id}) ${window.from.toISOString()} \u2026 ${window.to.toISOString()}: ${lines.length} message(s) shown` + (action ? ` matching "${action}" of ${all.length} scanned` : "") + (result.pageInfo?.totalCount != null ? `; ${result.pageInfo.totalCount} in window` : "") + `; truncated: ${truncated ? "yes \u2014 narrow the window or raise the limit" : "no"}.`;
2643
+ const head = `${target.name} (id: ${target.id}) ${window.from.toISOString()} \u2026 ${window.to.toISOString()}: ${lines.length} message(s) shown` + (action ? ` matching "${action}" of ${all.length} scanned` : "") + (result.pageInfo?.totalCount != null ? `; ${result.pageInfo.totalCount} in window` : "") + `; truncated: ${truncated ? "yes \u2014 narrow the window or raise the limit" : "no"}.`;
830
2644
  return ok([head, "", ...lines.map(formatLine)].join("\n"));
831
2645
  })
832
2646
  );
@@ -834,18 +2648,18 @@ function registerSupportTools(server) {
834
2648
  "get_test_run_details",
835
2649
  {
836
2650
  title: "Get test run details",
837
- description: `Read-only. A test run's status and fail reason, plus one page of its simulators or devices with their status (${RUN_SIMULATORS_PAGE_SIZE} per page). Steps \u2014 status, fail reason, expected / actual result \u2014 are shown for one simulator at a time: pass \`simulator\` with a name or id from the page. A run with a single simulator shows its steps right away.`,
2651
+ description: `Read-only. A test run's status and fail reason, plus one page of its simulators or hardware with their status (${RUN_SIMULATORS_PAGE_SIZE} per page). Steps, with id, status, fail reason and expected / actual result, are shown for one simulator at a time: pass \`simulator\` with a name or id from the page. A run with a single simulator shows its steps right away. The step id is what complete_test_step and fail_test_step take.`,
838
2652
  inputSchema: {
839
- runId: z7.string().describe("Test run id"),
840
- page: z7.number().int().min(1).optional().describe("Page of the run's simulators, starting at 1 (default 1)"),
841
- simulator: z7.string().optional().describe("Show the steps of this simulator from the page: its name, simulator id or run entry id")
2653
+ runId: z9.string().describe("Test run id"),
2654
+ page: z9.number().int().min(1).optional().describe("Page of the run's simulators, starting at 1 (default 1)"),
2655
+ simulator: z9.string().optional().describe("Show the steps of this simulator from the page: its name, simulator id or run entry id")
842
2656
  },
843
2657
  annotations: READ_ONLY
844
2658
  },
845
- async ({ runId, page, simulator }) => guard(async () => {
2659
+ async ({ runId, page: page2, simulator }) => guard(async () => {
846
2660
  const run = await sdk().testRuns.getDetails(runId);
847
2661
  if (!run) return ok(`No test run found with id ${runId}.`);
848
- const current = page ?? 1;
2662
+ const current = page2 ?? 1;
849
2663
  const simulators = await sdk().testRuns.listSimulators(runId, {
850
2664
  pagination: { page: current, pageSize: RUN_SIMULATORS_PAGE_SIZE }
851
2665
  });
@@ -868,47 +2682,1049 @@ function registerSupportTools(server) {
868
2682
  );
869
2683
  }
870
2684
 
871
- // src/tools/index.ts
872
- var READONLY_ENV = "TIMETICK_MCP_READONLY";
873
- function isReadOnlyMode(env = process.env) {
874
- const value = env[READONLY_ENV]?.trim().toLowerCase();
875
- return value === "1" || value === "true";
876
- }
877
- function readOnlyView(server) {
878
- return new Proxy(server, {
879
- get(target, prop, receiver) {
880
- if (prop !== "registerTool") return Reflect.get(target, prop, receiver);
881
- return (name, config, cb) => config.annotations?.readOnlyHint === true ? target.registerTool(name, config, cb) : void 0;
882
- }
883
- });
884
- }
885
- function registerTools(server, options) {
886
- const target = options.readOnly ? readOnlyView(server) : server;
887
- registerContextTools(target);
888
- registerDataTools(target);
889
- registerSimulatorTools(target);
890
- registerTestTools(target);
891
- registerTestRunTools(target);
892
- registerSupportTools(target);
2685
+ // src/tools/api-keys.ts
2686
+ import { z as z10 } from "zod";
2687
+ function registerApiKeyTools(server) {
2688
+ server.registerTool(
2689
+ "list_api_keys",
2690
+ {
2691
+ annotations: { readOnlyHint: true },
2692
+ title: "List API keys",
2693
+ description: "List the API keys on your Timetick account.",
2694
+ inputSchema: {}
2695
+ },
2696
+ async () => guard(async () => {
2697
+ const keys = await sdk().apiKeys.list();
2698
+ return ok(`Found ${keys?.length ?? 0} API key(s).`, keys);
2699
+ })
2700
+ );
2701
+ server.registerTool(
2702
+ "create_api_key",
2703
+ {
2704
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2705
+ title: "Create API key",
2706
+ description: "Create a new API key for the account. The secret is shown once in the reply \u2014 store it immediately, it cannot be retrieved later. Use this to give another machine or integration access without sharing your login.",
2707
+ inputSchema: {
2708
+ name: z10.string().describe("Key name, e.g. 'CI pipeline'"),
2709
+ expiresAt: z10.string().optional().describe("Optional expiration date (ISO 8601)")
2710
+ }
2711
+ },
2712
+ async ({ name, expiresAt }) => guard(async () => {
2713
+ const key = await sdk().apiKeys.create({ name, enabled: true, expiresAt });
2714
+ return ok(
2715
+ `Created API key "${key?.apiKey?.name}" (id: ${key?.apiKey?.id}). Secret shown once below \u2014 save it now.`,
2716
+ { secretKey: key?.secretKey }
2717
+ );
2718
+ })
2719
+ );
2720
+ server.registerTool(
2721
+ "rotate_api_key",
2722
+ {
2723
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2724
+ title: "Rotate API key",
2725
+ description: "Rotate an API key's secret: the old secret stops working and a new one is returned (shown once \u2014 save it now).",
2726
+ inputSchema: {
2727
+ keyId: z10.string().describe("API key id (see list_api_keys)")
2728
+ }
2729
+ },
2730
+ async ({ keyId }) => guard(async () => {
2731
+ const stored = peekCredentials();
2732
+ const key = await sdk().apiKeys.rotate(keyId);
2733
+ if (stored?.keyId === keyId && !process.env.TIMETICK_API_TOKEN && key?.secretKey) {
2734
+ await saveCredentials({ ...stored, apiKey: key.secretKey });
2735
+ resetSdk();
2736
+ return ok(`Rotated the API key this server uses (id: ${keyId}) and stored the new secret locally.`);
2737
+ }
2738
+ return ok(
2739
+ `Rotated API key (id: ${key?.apiKey?.id}). The previous secret no longer works. New secret shown once below \u2014 save it now.`,
2740
+ { secretKey: key?.secretKey }
2741
+ );
2742
+ })
2743
+ );
2744
+ server.registerTool(
2745
+ "delete_api_key",
2746
+ {
2747
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2748
+ title: "Delete API key",
2749
+ description: "Permanently delete an API key. Anything using it loses access immediately. To keep access while changing the secret, use rotate_api_key instead.",
2750
+ inputSchema: {
2751
+ keyId: z10.string().describe("API key id (see list_api_keys)")
2752
+ }
2753
+ },
2754
+ async ({ keyId }) => guard(async () => {
2755
+ if (peekCredentials()?.keyId === keyId && !process.env.TIMETICK_API_TOKEN) {
2756
+ return fail("This is the API key this server is using. Call logout to revoke it and sign out.");
2757
+ }
2758
+ const status = await sdk().apiKeys.delete(keyId);
2759
+ if (status === "ERROR") return fail(`API key ${keyId} could not be deleted.`);
2760
+ return ok(`Deleted API key ${keyId}.`);
2761
+ })
2762
+ );
893
2763
  }
894
2764
 
895
- // src/index.ts
896
- async function main() {
897
- if (!process.env.TIMETICK_API_TOKEN) {
898
- console.error(
899
- "TIMETICK_API_TOKEN is not set. Generate an API key in the Timetick portal (Account \u2192 API keys) and expose it to the MCP server as TIMETICK_API_TOKEN."
900
- );
901
- process.exit(1);
902
- }
903
- const server = new McpServer({ name: "timetick", version: "0.2.1" });
904
- const readOnly = isReadOnlyMode();
905
- registerTools(server, { readOnly });
906
- const transport = new StdioServerTransport();
907
- await server.connect(transport);
908
- console.error(`Timetick MCP server running on stdio${readOnly ? " (read-only)" : ""}`);
2765
+ // src/tools/projects.ts
2766
+ import { z as z11 } from "zod";
2767
+ function registerProjectTools(server) {
2768
+ server.registerTool(
2769
+ "create_project",
2770
+ {
2771
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2772
+ title: "Create project",
2773
+ description: "Create the account's first project and make it the active one. A project holds its endpoints, models, simulators and tests. Its free trial starts right away, skipping the portal's onboarding. An account that already has a project sets up further ones with payment details in the web portal.",
2774
+ inputSchema: {
2775
+ name: z11.string().describe("Project name"),
2776
+ timezone: z11.string().optional().describe("IANA timezone, e.g. 'Europe/Amsterdam' (default: this machine's)"),
2777
+ website: z11.string().optional().describe("Company website")
2778
+ }
2779
+ },
2780
+ async ({ name, timezone, website }) => guard(async () => {
2781
+ const existing = await sdk().projects.list() ?? [];
2782
+ if (existing.length > 0) {
2783
+ const me = await sdk().users.getCurrent();
2784
+ let invitedUnfinished = null;
2785
+ for (const candidate of existing) {
2786
+ if (candidate?.onboardingStatus !== "NOT_STARTED" && candidate?.onboardingStatus !== "IN_PROGRESS") continue;
2787
+ const role = me?.id ? await sdk().projects.getUserRole(candidate.id, me.id).catch(() => null) : null;
2788
+ if (role === "OWNER") {
2789
+ return fail(
2790
+ `Project "${candidate.name}" (id: ${candidate.id}) has not started its free trial: call start_trial with project "${candidate.id}". An account has one trial, so a new project would use it and leave that one read-only.`
2791
+ );
2792
+ }
2793
+ invitedUnfinished ??= candidate.name ?? null;
2794
+ }
2795
+ return fail(
2796
+ `This account already has a project, so another one needs payment details, set up in the web portal: ${portalUrl("/projects/new")}. Use use_project to work in an existing one.` + (invitedUnfinished ? ` Project "${invitedUnfinished}" is read-only until its owner starts its trial.` : "")
2797
+ );
2798
+ }
2799
+ const project = await sdk().projects.create({
2800
+ project: { name, timezone: timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, website }
2801
+ });
2802
+ let onboardingStatus = project?.onboardingStatus;
2803
+ let trialNote = "";
2804
+ if (project?.id && project.onboardingStatus === "NOT_STARTED") {
2805
+ const reason = await sdk().projects.update({ id: project.id, onboardingStatus: "SKIPPED" }).then(
2806
+ () => "",
2807
+ (err) => ` (${err instanceof Error ? err.message : String(err)})`
2808
+ );
2809
+ const created = await sdk().projects.get(project.id).catch(() => null);
2810
+ onboardingStatus = created?.onboardingStatus ?? (reason ? onboardingStatus : "SKIPPED");
2811
+ if (!created && reason) {
2812
+ trialNote = ` Could not confirm the free trial started${reason}: do not create the project again, call start_trial.`;
2813
+ } else if (created && !created.projectPlan) {
2814
+ const canRetry = created.onboardingStatus === "NOT_STARTED" || created.onboardingStatus === "IN_PROGRESS";
2815
+ trialNote = ` The free trial did not start${reason}, so the project is read-only: do not create it again.` + (canRetry ? " Call start_trial." : " A paid plan is set up in the web portal.");
2816
+ }
2817
+ }
2818
+ const active = project?.id ? await useProject({ project: project.id }).catch(() => null) : null;
2819
+ return ok(
2820
+ `Created project "${project?.name}" (id: ${project?.id})` + (active ? ", now active." : ". Select it with use_project.") + trialNote,
2821
+ { ...project, onboardingStatus }
2822
+ );
2823
+ })
2824
+ );
2825
+ server.registerTool(
2826
+ "start_trial",
2827
+ {
2828
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2829
+ title: "Start the free trial",
2830
+ description: "Start the free trial of a project whose onboarding was never finished in the portal, the same as its Skip button. Only the project's owner can. Until then the project is read-only. get_context shows whether it is needed.",
2831
+ inputSchema: {
2832
+ project: z11.string().optional().describe("Project name or id (defaults to active project)")
2833
+ }
2834
+ },
2835
+ async ({ project }) => guard(async () => {
2836
+ const ctx = await resolveSimWorld(project);
2837
+ const current = await sdk().projects.get(ctx.projectId);
2838
+ if (current?.projectPlan) {
2839
+ return ok(`Project "${ctx.projectName}" already has a plan: ${current.projectPlan.plan?.name ?? "unknown"}.`);
2840
+ }
2841
+ if (current?.onboardingStatus !== "NOT_STARTED" && current?.onboardingStatus !== "IN_PROGRESS") {
2842
+ return fail(`Project "${ctx.projectName}" has no active plan. A paid plan is set up in the web portal.`);
2843
+ }
2844
+ const me = await sdk().users.getCurrent();
2845
+ const role = me?.id ? await sdk().projects.getUserRole(ctx.projectId, me.id) : null;
2846
+ if (role !== "OWNER") {
2847
+ return fail(`Only the owner of project "${ctx.projectName}" can start its trial. Ask them to do it.`);
2848
+ }
2849
+ await sdk().projects.update({ id: ctx.projectId, onboardingStatus: "SKIPPED" });
2850
+ const after = await sdk().projects.get(ctx.projectId);
2851
+ if (!after?.projectPlan) {
2852
+ return fail(`The trial of project "${ctx.projectName}" did not start. A paid plan is set up in the web portal.`);
2853
+ }
2854
+ return ok(`Started the free trial of project "${ctx.projectName}": ${after.projectPlan.plan?.name ?? "unknown"}.`);
2855
+ })
2856
+ );
2857
+ server.registerTool(
2858
+ "update_project",
2859
+ {
2860
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2861
+ title: "Update project",
2862
+ description: "Rename a project or change its timezone / website.",
2863
+ inputSchema: {
2864
+ projectId: z11.string().describe("Project id (see list_projects)"),
2865
+ name: z11.string().optional(),
2866
+ timezone: z11.string().optional().describe("IANA timezone, e.g. 'Europe/Amsterdam'"),
2867
+ website: z11.string().optional()
2868
+ }
2869
+ },
2870
+ async ({ projectId, name, timezone, website }) => guard(async () => {
2871
+ const project = await sdk().projects.update({ id: projectId, name, timezone, website });
2872
+ return ok(`Updated project (id: ${projectId}).`, project);
2873
+ })
2874
+ );
2875
+ server.registerTool(
2876
+ "delete_project",
2877
+ {
2878
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2879
+ title: "Delete project",
2880
+ description: "Permanently delete a project with everything in it \u2014 simulators, tests, runs, history. There is no undo.",
2881
+ inputSchema: {
2882
+ projectId: z11.string().describe("Project id (see list_projects)")
2883
+ }
2884
+ },
2885
+ async ({ projectId }) => guard(async () => {
2886
+ await sdk().projects.delete(projectId);
2887
+ if (peekContext()?.projectId === projectId) resetContext();
2888
+ return ok(`Deleted project ${projectId}.`);
2889
+ })
2890
+ );
2891
+ }
2892
+
2893
+ // src/tools/catalog.ts
2894
+ import { z as z12 } from "zod";
2895
+ var protocolEnum4 = z12.enum(["OCPP_1_6", "OCPP_2_0_1"]);
2896
+ var connectorsSchema = z12.array(
2897
+ z12.object({
2898
+ type: z12.enum(["J1772", "MENNEKES", "GBT", "CHADEMO", "CCS1", "CCS2", "TESLA"]),
2899
+ maxPower: z12.number().int().optional().describe("Max power in kW (default: the model's power, rounded)"),
2900
+ number: z12.number().int().min(1).optional().describe("Connector number (default: its position, from 1)"),
2901
+ cableFixed: z12.boolean().optional().describe("Fixed cable (default true)"),
2902
+ evseId: z12.number().int().min(1).optional().describe("EVSE id: required on OCPP 2.0.1, not allowed on OCPP 1.6")
2903
+ })
2904
+ ).min(1);
2905
+ function connectorParams(connectors, protocol, power) {
2906
+ return connectors.map((connector, index) => {
2907
+ if (protocol === "OCPP_2_0_1" && !connector.evseId) {
2908
+ throw new Error("Every connector of an OCPP 2.0.1 model needs `evseId`.");
2909
+ }
2910
+ if (protocol === "OCPP_1_6" && connector.evseId != null) {
2911
+ throw new Error("Connectors of an OCPP 1.6 model must not have `evseId`.");
2912
+ }
2913
+ return {
2914
+ ...connector,
2915
+ number: connector.number ?? index + 1,
2916
+ maxPower: connector.maxPower ?? (power === void 0 ? void 0 : Math.round(power))
2917
+ };
2918
+ });
2919
+ }
2920
+ var PRIVATE_HOST = /^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|0\.0\.0\.0|\[::1\]|.*\.local)$/i;
2921
+ function externalEndpointProblem(uri) {
2922
+ if (!uri) return "An EXTERNAL endpoint needs `uri`: the CSMS websocket URL.";
2923
+ let url;
2924
+ try {
2925
+ url = new URL(uri);
2926
+ } catch {
2927
+ return `"${uri}" is not a valid URL.`;
2928
+ }
2929
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") return `The CSMS URL must start with ws:// or wss://, got "${uri}".`;
2930
+ if (PRIVATE_HOST.test(url.hostname)) {
2931
+ return `${url.hostname} is not reachable: simulators connect from Timetick's cloud, so the CSMS needs a public address. Expose it through a public host or a tunnel, and pass that URL.`;
2932
+ }
2933
+ return null;
2934
+ }
2935
+ function registerCatalogTools(server) {
2936
+ const projectArg = z12.string().optional().describe("Project name or id (defaults to active project)");
2937
+ server.registerTool(
2938
+ "list_manufacturers",
2939
+ {
2940
+ annotations: { readOnlyHint: true },
2941
+ title: "List manufacturers",
2942
+ description: "List manufacturers in the active project's sim world. Manufacturers group models; create one with create_manufacturer if yours is missing.",
2943
+ inputSchema: {
2944
+ search: z12.string().optional().describe("Filter by manufacturer name"),
2945
+ ...paginationArgs,
2946
+ project: projectArg
2947
+ }
2948
+ },
2949
+ async ({ search, page: page2, pageSize, project }) => guard(async () => {
2950
+ const ctx = await resolveSimWorld(project);
2951
+ const result = await sdk().manufacturers.list({
2952
+ simWorldId: ctx.simWorldId,
2953
+ searchValue: search,
2954
+ pagination: pagination(page2, pageSize)
2955
+ });
2956
+ return ok(
2957
+ pageSummary("manufacturer(s)", result?.records?.length ?? 0, result?.pageInfo),
2958
+ result
2959
+ );
2960
+ })
2961
+ );
2962
+ server.registerTool(
2963
+ "create_manufacturer",
2964
+ {
2965
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2966
+ title: "Create manufacturer",
2967
+ description: "Create a manufacturer, the vendor of the chargers you want to simulate. Next step: create a model under it with create_model.",
2968
+ inputSchema: {
2969
+ name: z12.string().describe("Manufacturer name, e.g. 'ABB'"),
2970
+ description: z12.string().optional(),
2971
+ project: projectArg
2972
+ }
2973
+ },
2974
+ async ({ name, description, project }) => guard(async () => {
2975
+ const ctx = await resolveSimWorld(project);
2976
+ const manufacturer = await sdk().manufacturers.create({
2977
+ simWorldId: ctx.simWorldId,
2978
+ name,
2979
+ type: "CHARGER",
2980
+ description
2981
+ });
2982
+ return ok(`Created manufacturer "${name}" (id: ${manufacturer?.id}).`, manufacturer);
2983
+ })
2984
+ );
2985
+ server.registerTool(
2986
+ "update_manufacturer",
2987
+ {
2988
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2989
+ title: "Update manufacturer",
2990
+ description: "Rename a manufacturer or change its description.",
2991
+ inputSchema: {
2992
+ manufacturerId: z12.string().describe("Manufacturer id (see list_manufacturers)"),
2993
+ name: z12.string().optional(),
2994
+ description: z12.string().optional(),
2995
+ project: projectArg
2996
+ }
2997
+ },
2998
+ async ({ manufacturerId, name, description, project }) => guard(async () => {
2999
+ const ctx = await resolveSimWorld(project);
3000
+ const current = await sdk().manufacturers.get(manufacturerId);
3001
+ if (!current) return fail(`No manufacturer found with id ${manufacturerId}.`);
3002
+ const manufacturer = await sdk().manufacturers.update({
3003
+ id: manufacturerId,
3004
+ simWorldId: ctx.simWorldId,
3005
+ name: name ?? current.name,
3006
+ description
3007
+ });
3008
+ return ok(`Updated manufacturer (id: ${manufacturerId}).`, manufacturer);
3009
+ })
3010
+ );
3011
+ server.registerTool(
3012
+ "delete_manufacturer",
3013
+ {
3014
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
3015
+ title: "Delete manufacturer",
3016
+ description: "Permanently delete a manufacturer.",
3017
+ inputSchema: {
3018
+ manufacturerId: z12.string().describe("Manufacturer id (see list_manufacturers)")
3019
+ }
3020
+ },
3021
+ async ({ manufacturerId }) => guard(async () => {
3022
+ const status = await sdk().manufacturers.delete(manufacturerId);
3023
+ if (status === "ERROR") return fail(`Manufacturer ${manufacturerId} could not be deleted.`);
3024
+ return ok(`Deleted manufacturer ${manufacturerId}.`);
3025
+ })
3026
+ );
3027
+ server.registerTool(
3028
+ "list_models",
3029
+ {
3030
+ annotations: { readOnlyHint: true },
3031
+ title: "List simulator models",
3032
+ description: "List models in the active project's sim world. A model defines protocol, power, firmware and connectors; simulators are created from models. Create one with create_model.",
3033
+ inputSchema: {
3034
+ search: z12.string().optional().describe("Filter by model name"),
3035
+ protocol: protocolEnum4.optional(),
3036
+ ...paginationArgs,
3037
+ project: projectArg
3038
+ }
3039
+ },
3040
+ async ({ search, protocol, page: page2, pageSize, project }) => guard(async () => {
3041
+ const ctx = await resolveSimWorld(project);
3042
+ const result = await sdk().models.list({
3043
+ simWorldId: ctx.simWorldId,
3044
+ searchValue: search,
3045
+ filters: protocol ? { protocol } : void 0,
3046
+ pagination: pagination(page2, pageSize)
3047
+ });
3048
+ return ok(pageSummary("model(s)", result?.records?.length ?? 0, result?.pageInfo), result);
3049
+ })
3050
+ );
3051
+ server.registerTool(
3052
+ "create_model",
3053
+ {
3054
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3055
+ title: "Create simulator model",
3056
+ description: "Create a model. Needs a manufacturer (list_manufacturers / create_manufacturer), a simulator config (list_simulator_configs) and, for OCPP 1.6, an OCPP config (list_ocpp_configs). Next step: create simulators from this model with create_simulator.",
3057
+ inputSchema: {
3058
+ name: z12.string().describe("Model name, e.g. 'Terra 54'"),
3059
+ manufacturerId: z12.string().describe("Manufacturer id (see list_manufacturers)"),
3060
+ simulatorConfigId: z12.string().describe("Simulator config id (see list_simulator_configs)"),
3061
+ ocppConfigId: z12.string().optional().describe("OCPP config id (see list_ocpp_configs); required on OCPP 1.6"),
3062
+ protocol: protocolEnum4,
3063
+ power: z12.number().describe("Max power in kW"),
3064
+ currentType: z12.enum(["AC", "DC"]),
3065
+ firmwareVersion: z12.string().describe("Firmware version string, e.g. '1.2.3'"),
3066
+ connectors: connectorsSchema.describe("Connector definitions, e.g. [{ type: 'CCS2', maxPower: 150 }]"),
3067
+ project: projectArg
3068
+ }
3069
+ },
3070
+ async ({ name, manufacturerId, simulatorConfigId, ocppConfigId, protocol, power, currentType, firmwareVersion, connectors, project }) => guard(async () => {
3071
+ if (protocol === "OCPP_1_6" && !ocppConfigId) {
3072
+ return fail("An OCPP 1.6 model needs `ocppConfigId` (see list_ocpp_configs or create_ocpp_config).");
3073
+ }
3074
+ const ctx = await resolveSimWorld(project);
3075
+ const model = await sdk().models.create({
3076
+ simWorldId: ctx.simWorldId,
3077
+ name,
3078
+ deviceManufacturerId: manufacturerId,
3079
+ chargerConfigId: simulatorConfigId,
3080
+ ocppConfigId,
3081
+ protocol,
3082
+ power,
3083
+ type: currentType,
3084
+ firmwareVersion,
3085
+ connectorModelsParams: connectorParams(connectors, protocol, power)
3086
+ });
3087
+ return ok(`Created model "${name}" (id: ${model?.id}).`, model);
3088
+ })
3089
+ );
3090
+ server.registerTool(
3091
+ "update_model",
3092
+ {
3093
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3094
+ title: "Update simulator model",
3095
+ description: "Update a model's name, firmware, power or connectors.",
3096
+ inputSchema: {
3097
+ modelId: z12.string().describe("Model id (see list_models)"),
3098
+ name: z12.string().optional(),
3099
+ firmwareVersion: z12.string().optional(),
3100
+ power: z12.number().optional().describe("Max power in kW"),
3101
+ connectors: connectorsSchema.optional().describe("Replaces all connectors; omit to keep them"),
3102
+ project: projectArg
3103
+ }
3104
+ },
3105
+ async ({ modelId, name, firmwareVersion, power, connectors, project }) => guard(async () => {
3106
+ const ctx = await resolveSimWorld(project);
3107
+ const existing = await sdk().models.get(modelId);
3108
+ if (!existing) return fail(`No model found with id ${modelId}.`);
3109
+ await sdk().models.update({
3110
+ id: modelId,
3111
+ simWorldId: ctx.simWorldId,
3112
+ name,
3113
+ firmwareVersion,
3114
+ power,
3115
+ connectorModelsParams: connectors ? connectorParams(connectors, existing.protocol, power ?? existing.power) : (existing.connectorModels ?? []).map(({ number, type, maxPower, cableFixed, evseId }) => ({
3116
+ number,
3117
+ type,
3118
+ maxPower,
3119
+ cableFixed,
3120
+ evseId
3121
+ }))
3122
+ });
3123
+ return ok(`Updated model (id: ${modelId}).`, await sdk().models.get(modelId));
3124
+ })
3125
+ );
3126
+ server.registerTool(
3127
+ "delete_model",
3128
+ {
3129
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
3130
+ title: "Delete simulator model",
3131
+ description: "Permanently delete a model. Refused while a simulator or hardware that used it exists, including deleted ones the platform still retains, for up to a year. A manufacturer can't be deleted while it has a model.",
3132
+ inputSchema: {
3133
+ modelId: z12.string().describe("Model id (see list_models)")
3134
+ }
3135
+ },
3136
+ async ({ modelId }) => guard(async () => {
3137
+ await sdk().models.delete(modelId);
3138
+ return ok(`Deleted model ${modelId}.`);
3139
+ })
3140
+ );
3141
+ server.registerTool(
3142
+ "list_endpoints",
3143
+ {
3144
+ annotations: { readOnlyHint: true },
3145
+ title: "List endpoints",
3146
+ description: "List CSMS endpoints in the active project's sim world. An endpoint is the OCPP server (URL) simulators connect to. Every simulator needs one \u2014 create it with create_endpoint.",
3147
+ inputSchema: {
3148
+ search: z12.string().optional().describe("Filter by endpoint name"),
3149
+ protocol: protocolEnum4.optional(),
3150
+ ...paginationArgs,
3151
+ project: projectArg
3152
+ }
3153
+ },
3154
+ async ({ search, protocol, page: page2, pageSize, project }) => guard(async () => {
3155
+ const ctx = await resolveSimWorld(project);
3156
+ const result = await sdk().endpoints.list({
3157
+ simWorldId: ctx.simWorldId,
3158
+ searchValue: search,
3159
+ filters: protocol ? { protocol } : void 0,
3160
+ pagination: pagination(page2, pageSize)
3161
+ });
3162
+ return ok(pageSummary("endpoint(s)", result?.records?.length ?? 0, result?.pageInfo), result);
3163
+ })
3164
+ );
3165
+ server.registerTool(
3166
+ "create_endpoint",
3167
+ {
3168
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3169
+ title: "Create endpoint",
3170
+ description: "Create a CSMS endpoint, the OCPP server URL simulators connect to. New projects already have TIMETICK endpoints pointing at Timetick's CSMS. To test your own CSMS create an EXTERNAL one with its public ws(s):// URL. Protocol must match the simulators you plan to add.",
3171
+ inputSchema: {
3172
+ name: z12.string().describe("Endpoint name, e.g. 'My CSMS'"),
3173
+ protocol: protocolEnum4,
3174
+ endpointType: z12.enum(["TIMETICK", "EXTERNAL"]),
3175
+ uri: z12.string().optional().describe("Public CSMS websocket URL for EXTERNAL endpoints, e.g. 'wss://csms.example.com/ocpp'"),
3176
+ config: z12.string().optional().describe("Optional JSON config"),
3177
+ project: projectArg
3178
+ }
3179
+ },
3180
+ async ({ name, protocol, endpointType, uri, config, project }) => guard(async () => {
3181
+ const problem = endpointType === "EXTERNAL" ? externalEndpointProblem(uri) : null;
3182
+ if (problem) return fail(problem);
3183
+ const ctx = await resolveSimWorld(project);
3184
+ const endpoint = await sdk().endpoints.create({
3185
+ simWorldId: ctx.simWorldId,
3186
+ name,
3187
+ protocol,
3188
+ type: endpointType,
3189
+ uri,
3190
+ config
3191
+ });
3192
+ return ok(`Created endpoint "${name}" (id: ${endpoint?.id}).`, endpoint);
3193
+ })
3194
+ );
3195
+ server.registerTool(
3196
+ "update_endpoint",
3197
+ {
3198
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3199
+ title: "Update endpoint",
3200
+ description: "Update an endpoint's name, URI, protocol or config.",
3201
+ inputSchema: {
3202
+ endpointId: z12.string().describe("Endpoint id (see list_endpoints)"),
3203
+ name: z12.string().optional(),
3204
+ uri: z12.string().optional(),
3205
+ protocol: protocolEnum4.optional(),
3206
+ config: z12.string().optional()
3207
+ }
3208
+ },
3209
+ async ({ endpointId, name, uri, protocol, config }) => guard(async () => {
3210
+ const problem = uri ? externalEndpointProblem(uri) : null;
3211
+ if (problem) return fail(problem);
3212
+ const endpoint = await sdk().endpoints.update({ id: endpointId, name, uri, protocol, config });
3213
+ return ok(`Updated endpoint (id: ${endpointId}).`, endpoint);
3214
+ })
3215
+ );
3216
+ server.registerTool(
3217
+ "delete_endpoint",
3218
+ {
3219
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
3220
+ title: "Delete endpoint",
3221
+ description: "Permanently delete an endpoint.",
3222
+ inputSchema: {
3223
+ endpointId: z12.string().describe("Endpoint id (see list_endpoints)")
3224
+ }
3225
+ },
3226
+ async ({ endpointId }) => guard(async () => {
3227
+ await sdk().endpoints.delete(endpointId);
3228
+ return ok(`Deleted endpoint ${endpointId}.`);
3229
+ })
3230
+ );
3231
+ }
3232
+
3233
+ // src/tools/ocpp-configs.ts
3234
+ import { z as z13 } from "zod";
3235
+ var protocolEnum5 = z13.enum(["OCPP_1_6", "OCPP_2_0_1"]);
3236
+ var items1_6 = z13.array(z13.object({ key: z13.string(), value: z13.string().optional(), readonly: z13.boolean() })).describe("OCPP 1.6 configuration keys, e.g. [{ key: 'HeartbeatInterval', value: '60', readonly: false }]");
3237
+ var items2_0_1 = z13.array(
3238
+ z13.object({
3239
+ componentName: z13.string(),
3240
+ variableName: z13.string(),
3241
+ value: z13.string().describe("Value. An item without a value is not stored."),
3242
+ mutabilityMode: z13.enum(["ReadOnly", "WriteOnly", "ReadWrite"]).optional(),
3243
+ componentInstance: z13.string().optional(),
3244
+ variableInstance: z13.string().optional(),
3245
+ evseId: z13.number().int().optional(),
3246
+ evseConnectorId: z13.number().int().optional()
3247
+ })
3248
+ ).describe(
3249
+ "OCPP 2.0.1 component/variable pairs, e.g. [{ componentName: 'OCPPCommCtrlr', variableName: 'HeartbeatInterval', value: '30' }]"
3250
+ );
3251
+ var OUTPUT_ONLY = /* @__PURE__ */ new Set(["__typename", "simWorldId", "ocppConfigId"]);
3252
+ var V201_IDENTITY = ["componentName", "componentInstance", "variableName", "variableInstance", "evseId", "evseConnectorId"];
3253
+ function identityOf(item, isV201) {
3254
+ return (isV201 ? V201_IDENTITY : ["key"]).map((field) => String(item[field] ?? "")).join("|");
3255
+ }
3256
+ function mergeItems(existing, changes, remove, isV201) {
3257
+ const nameOf = (item) => String((isV201 ? item.variableName : item.key) ?? "");
3258
+ const removed = /* @__PURE__ */ new Set();
3259
+ for (const name of remove) {
3260
+ const matches = existing.filter(
3261
+ (item) => String(item.id) === name || nameOf(item) === name || `${item.componentName}/${item.variableName}` === name
3262
+ );
3263
+ if (matches.length === 0) throw new Error(`Nothing to remove for: ${name}.`);
3264
+ if (matches.length > 1) {
3265
+ const places = matches.map((item) => `${item.componentName}/${item.variableName}`).join(", ");
3266
+ throw new Error(`"${name}" matches several items (${places}). Pass the item id from get_ocpp_config.`);
3267
+ }
3268
+ removed.add(matches[0]);
3269
+ }
3270
+ const kept = existing.filter((item) => !removed.has(item)).map((item) => Object.fromEntries(Object.entries(item).filter(([field]) => !OUTPUT_ONLY.has(field))));
3271
+ const byIdentity = new Map(kept.map((item) => [identityOf(item, isV201), item]));
3272
+ for (const change of changes) {
3273
+ const current = byIdentity.get(identityOf(change, isV201));
3274
+ if (current) Object.assign(current, change);
3275
+ else {
3276
+ kept.push(change);
3277
+ byIdentity.set(identityOf(change, isV201), change);
3278
+ }
3279
+ }
3280
+ return kept;
3281
+ }
3282
+ function registerOcppConfigTools(server) {
3283
+ const projectArg = z13.string().optional().describe("Project name or id (defaults to active project)");
3284
+ server.registerTool(
3285
+ "list_ocpp_configs",
3286
+ {
3287
+ annotations: { readOnlyHint: true },
3288
+ title: "List OCPP configs",
3289
+ description: "List OCPP configurations in the active project's sim world. A config defines the OCPP key/values a simulator answers with; attach one when creating a simulator.",
3290
+ inputSchema: {
3291
+ search: z13.string().optional().describe("Filter by config name"),
3292
+ ...paginationArgs,
3293
+ project: projectArg
3294
+ }
3295
+ },
3296
+ async ({ search, page: page2, pageSize, project }) => guard(async () => {
3297
+ const ctx = await resolveSimWorld(project);
3298
+ const result = await sdk().ocppConfigs.list({
3299
+ simWorldId: ctx.simWorldId,
3300
+ searchValue: search,
3301
+ pagination: pagination(page2, pageSize)
3302
+ });
3303
+ return ok(
3304
+ pageSummary("OCPP config(s)", result?.records?.length ?? 0, result?.pageInfo),
3305
+ result
3306
+ );
3307
+ })
3308
+ );
3309
+ server.registerTool(
3310
+ "get_ocpp_config",
3311
+ {
3312
+ annotations: { readOnlyHint: true },
3313
+ title: "Get OCPP config",
3314
+ description: "Get an OCPP configuration by id, with all its items.",
3315
+ inputSchema: {
3316
+ configId: z13.string().describe("OCPP config id (see list_ocpp_configs)")
3317
+ }
3318
+ },
3319
+ async ({ configId }) => guard(async () => {
3320
+ const config = await sdk().ocppConfigs.get(configId);
3321
+ if (!config) return ok(`No OCPP config found with id ${configId}.`);
3322
+ return ok(`OCPP config (id: ${configId}).`, {
3323
+ ...config,
3324
+ configItems: maskSecretItems(config.configItems ?? [])
3325
+ });
3326
+ })
3327
+ );
3328
+ server.registerTool(
3329
+ "create_ocpp_config",
3330
+ {
3331
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3332
+ title: "Create OCPP config",
3333
+ description: "Create an OCPP configuration. Items depend on protocol: 1.6 uses key/value entries, 2.0.1 uses component/variable entries. Next step: attach it to a simulator via create_simulator's ocppConfigId.",
3334
+ inputSchema: {
3335
+ name: z13.string().describe("Config name, e.g. 'Default 1.6 heartbeat 60'"),
3336
+ protocol: protocolEnum5,
3337
+ items1_6: items1_6.optional().describe("Required when protocol is OCPP_1_6"),
3338
+ items2_0_1: items2_0_1.optional().describe("Required when protocol is OCPP_2_0_1"),
3339
+ project: projectArg
3340
+ }
3341
+ },
3342
+ async ({ name, protocol, items1_6: i16, items2_0_1: i201, project }) => guard(async () => {
3343
+ const items = protocol === "OCPP_1_6" ? i16 : i201;
3344
+ if (!items?.length) {
3345
+ return fail(`Pass ${protocol === "OCPP_1_6" ? "items1_6" : "items2_0_1"} for a ${protocol} config.`);
3346
+ }
3347
+ const ctx = await resolveSimWorld(project);
3348
+ const config = protocol === "OCPP_1_6" ? await sdk().ocppConfigs.create1_6({ simWorldId: ctx.simWorldId, name, configItems: i16 }) : await sdk().ocppConfigs.create2_0_1({ simWorldId: ctx.simWorldId, name, configItems: i201 });
3349
+ return ok(`Created OCPP config "${name}" (id: ${config?.id}).`, config);
3350
+ })
3351
+ );
3352
+ server.registerTool(
3353
+ "update_ocpp_config",
3354
+ {
3355
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3356
+ title: "Update OCPP config",
3357
+ description: "Update an OCPP configuration's name and items. Items you pass are changed or added; all others stay as they are. `remove` deletes items.",
3358
+ inputSchema: {
3359
+ configId: z13.string().describe("OCPP config id (see list_ocpp_configs)"),
3360
+ name: z13.string().optional(),
3361
+ items1_6: items1_6.optional().describe("For OCPP_1_6 configs: items to change or add"),
3362
+ items2_0_1: items2_0_1.optional().describe("For OCPP_2_0_1 configs: items to change or add"),
3363
+ remove: z13.array(z13.string()).optional().describe("Items to delete: item id from get_ocpp_config, key (1.6), variable name or component/variable (2.0.1)"),
3364
+ project: projectArg
3365
+ }
3366
+ },
3367
+ async ({ configId, name, items1_6: i16, items2_0_1: i201, remove, project }) => guard(async () => {
3368
+ const ctx = await resolveSimWorld(project);
3369
+ const current = await sdk().ocppConfigs.get(configId);
3370
+ if (!current) return fail(`No OCPP config found with id ${configId}.`);
3371
+ const isV201 = current.protocol === "OCPP_2_0_1";
3372
+ if (isV201 ? i16 : i201) {
3373
+ return fail(`Config ${configId} is ${current.protocol}: pass ${isV201 ? "items2_0_1" : "items1_6"}.`);
3374
+ }
3375
+ const changes = isV201 ? i201 : i16;
3376
+ const configItems = changes || remove ? mergeItems(current.configItems ?? [], changes ?? [], remove ?? [], isV201) : void 0;
3377
+ const config = isV201 ? await sdk().ocppConfigs.update2_0_1({ id: configId, simWorldId: ctx.simWorldId, name, configItems }) : await sdk().ocppConfigs.update1_6({ id: configId, simWorldId: ctx.simWorldId, name, configItems });
3378
+ return ok(`Updated OCPP config (id: ${configId}).`, config);
3379
+ })
3380
+ );
3381
+ server.registerTool(
3382
+ "delete_ocpp_config",
3383
+ {
3384
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
3385
+ title: "Delete OCPP config",
3386
+ description: "Permanently delete an OCPP configuration.",
3387
+ inputSchema: {
3388
+ configId: z13.string().describe("OCPP config id (see list_ocpp_configs)")
3389
+ }
3390
+ },
3391
+ async ({ configId }) => guard(async () => {
3392
+ await sdk().ocppConfigs.delete(configId);
3393
+ return ok(`Deleted OCPP config ${configId}.`);
3394
+ })
3395
+ );
3396
+ }
3397
+
3398
+ // src/tools/simulator-configs.ts
3399
+ import { z as z14 } from "zod";
3400
+ var protocolEnum6 = z14.enum(["OCPP_1_6", "OCPP_2_0_1"]);
3401
+ var chargingDirection = z14.enum(["CHARGING", "DISCHARGING", "BOTH"]);
3402
+ var powerDistribution = z14.enum(["SEQUENTIAL", "PARALLEL"]);
3403
+ function registerSimulatorConfigTools(server) {
3404
+ const projectArg = z14.string().optional().describe("Project name or id (defaults to active project)");
3405
+ server.registerTool(
3406
+ "list_simulator_configs",
3407
+ {
3408
+ annotations: { readOnlyHint: true },
3409
+ title: "List simulator configs",
3410
+ description: "List simulator configs (behavior templates) in the active project's sim world, with their protocol, intervals, charging direction and vendor behavior. Models reference one. get_simulator_config shows what a specific simulator actually uses.",
3411
+ inputSchema: {
3412
+ search: z14.string().optional().describe("Filter by config name"),
3413
+ ...paginationArgs,
3414
+ project: projectArg
3415
+ }
3416
+ },
3417
+ async ({ search, page: page2, pageSize, project }) => guard(async () => {
3418
+ const ctx = await resolveSimWorld(project);
3419
+ const result = await sdk().simulatorConfigs.list({
3420
+ simWorldId: ctx.simWorldId,
3421
+ searchValue: search,
3422
+ pagination: pagination(page2, pageSize)
3423
+ });
3424
+ return ok(
3425
+ pageSummary("simulator config(s)", result?.records?.length ?? 0, result?.pageInfo),
3426
+ result
3427
+ );
3428
+ })
3429
+ );
3430
+ server.registerTool(
3431
+ "create_simulator_config",
3432
+ {
3433
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3434
+ title: "Create simulator config",
3435
+ description: "Create a simulator config (behavior template). New projects already have a default per protocol, so usually copy those values. Next step: reference it in create_model. Intervals are in seconds.",
3436
+ inputSchema: {
3437
+ name: z14.string().describe("Config name, e.g. 'Generic DC 150kW'"),
3438
+ protocol: protocolEnum6,
3439
+ supportedCommands: z14.array(z14.string()).describe("OCPP commands the charger supports, e.g. ['Reset', 'UnlockConnector']"),
3440
+ callTimeoutInterval: z14.number().describe("Call timeout in seconds"),
3441
+ defaultRetryInterval: z14.number().describe("Retry interval in seconds"),
3442
+ authorizationExpirationTime: z14.number().describe("Authorization expiration in seconds"),
3443
+ hardResetInterval: z14.number().describe("Hard reset interval in seconds"),
3444
+ softResetInterval: z14.number().describe("Soft reset interval in seconds"),
3445
+ heartbeatDefaultInterval: z14.number().int().optional().describe("Heartbeat interval in seconds"),
3446
+ validIdTag: z14.string().optional().describe("idTag the simulator treats as valid"),
3447
+ chargingDirection,
3448
+ powerDistribution,
3449
+ useRemoteTemplates: z14.boolean().describe(
3450
+ "After a remote start: true plays the config's transaction template (default configs), false plays nothing, so the test needs a 'Charge a Vehicle' step"
3451
+ ),
3452
+ roundSocMeasurand: z14.boolean().describe("Round SoC values to integers"),
3453
+ behavior: z14.string().describe("Vendor behavior: GENERIC, or a vendor name such as ABB, AUTEL, KEMPOWER"),
3454
+ project: projectArg
3455
+ }
3456
+ },
3457
+ async (input) => guard(async () => {
3458
+ const ctx = await resolveSimWorld(input.project);
3459
+ const {
3460
+ project: _project,
3461
+ supportedCommands,
3462
+ ...rest
3463
+ } = input;
3464
+ const config = await sdk().simulatorConfigs.create({
3465
+ simWorldId: ctx.simWorldId,
3466
+ supportedCommands,
3467
+ ...rest,
3468
+ behavior: rest.behavior
3469
+ });
3470
+ return ok(`Created simulator config "${input.name}" (id: ${config?.id}).`, config);
3471
+ })
3472
+ );
3473
+ server.registerTool(
3474
+ "update_simulator_config",
3475
+ {
3476
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3477
+ title: "Update simulator config",
3478
+ description: "Update a simulator config. Only the fields you pass change.",
3479
+ inputSchema: {
3480
+ configId: z14.string().describe("Simulator config id (see list_simulator_configs)"),
3481
+ name: z14.string().optional(),
3482
+ supportedCommands: z14.array(z14.string()).optional(),
3483
+ callTimeoutInterval: z14.number().int().optional(),
3484
+ defaultRetryInterval: z14.number().int().optional(),
3485
+ authorizationExpirationTime: z14.number().int().optional(),
3486
+ hardResetInterval: z14.number().int().optional(),
3487
+ softResetInterval: z14.number().int().optional(),
3488
+ heartbeatDefaultInterval: z14.number().int().optional(),
3489
+ validIdTag: z14.string().optional(),
3490
+ chargingDirection: chargingDirection.optional(),
3491
+ powerDistribution: powerDistribution.optional(),
3492
+ useRemoteTemplates: z14.boolean().optional(),
3493
+ roundSocMeasurand: z14.boolean().optional(),
3494
+ behavior: z14.string().optional().describe("GENERIC, or a vendor name such as ABB, AUTEL, KEMPOWER"),
3495
+ project: projectArg
3496
+ }
3497
+ },
3498
+ async ({ configId, project, behavior, chargingDirection: chargingDirection2, powerDistribution: powerDistribution2, useRemoteTemplates, roundSocMeasurand, ...rest }) => guard(async () => {
3499
+ const ctx = await resolveSimWorld(project);
3500
+ const current = await sdk().simulatorConfigs.get(configId);
3501
+ if (!current) return fail(`No simulator config found with id ${configId}.`);
3502
+ const config = await sdk().simulatorConfigs.update({
3503
+ id: configId,
3504
+ simWorldId: ctx.simWorldId,
3505
+ ...rest,
3506
+ supportedCommands: rest.supportedCommands ?? current.supportedCommands,
3507
+ validIdTag: rest.validIdTag ?? current.validIdTag ?? void 0,
3508
+ behavior,
3509
+ chargingDirection: chargingDirection2 ?? current.chargingDirection,
3510
+ powerDistribution: powerDistribution2 ?? current.powerDistribution,
3511
+ useRemoteTemplates: useRemoteTemplates ?? current.useRemoteTemplates,
3512
+ roundSocMeasurand: roundSocMeasurand ?? current.roundSocMeasurand
3513
+ });
3514
+ return ok(`Updated simulator config (id: ${configId}).`, config);
3515
+ })
3516
+ );
3517
+ server.registerTool(
3518
+ "delete_simulator_config",
3519
+ {
3520
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
3521
+ title: "Delete simulator config",
3522
+ description: "Permanently delete a simulator config.",
3523
+ inputSchema: {
3524
+ configId: z14.string().describe("Simulator config id (see list_simulator_configs)")
3525
+ }
3526
+ },
3527
+ async ({ configId }) => guard(async () => {
3528
+ await sdk().simulatorConfigs.delete(configId);
3529
+ return ok(`Deleted simulator config ${configId}.`);
3530
+ })
3531
+ );
3532
+ }
3533
+
3534
+ // src/tools/tags.ts
3535
+ import { z as z15 } from "zod";
3536
+ var ENTITY_TYPES2 = { SIMULATOR: "CHARGER", TEST: "SCENARIO", TEST_PLAN: "TEST_PLAN", HARDWARE: "DEVICE" };
3537
+ var entityType = z15.enum(["SIMULATOR", "TEST", "TEST_PLAN", "HARDWARE"]);
3538
+ function registerTagTools(server) {
3539
+ const projectArg = z15.string().optional().describe("Project name or id (defaults to active project)");
3540
+ server.registerTool(
3541
+ "list_tags",
3542
+ {
3543
+ annotations: { readOnlyHint: true },
3544
+ title: "List tags",
3545
+ description: "List tags in the active project. Tags label simulators, tests, plans and hardware.",
3546
+ inputSchema: {
3547
+ search: z15.string().optional().describe("Filter by tag name"),
3548
+ ...paginationArgs,
3549
+ project: projectArg
3550
+ }
3551
+ },
3552
+ async ({ search, page: page2, pageSize, project }) => guard(async () => {
3553
+ const ctx = await resolveSimWorld(project);
3554
+ const result = await sdk().tags.list({
3555
+ projectId: ctx.projectId,
3556
+ searchValue: search,
3557
+ pagination: pagination(page2, pageSize)
3558
+ });
3559
+ return ok(pageSummary("tag(s)", result?.records?.length ?? 0, result?.pageInfo), result);
3560
+ })
3561
+ );
3562
+ server.registerTool(
3563
+ "create_tag",
3564
+ {
3565
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3566
+ title: "Create tag",
3567
+ description: "Create a tag in the active project.",
3568
+ inputSchema: {
3569
+ name: z15.string(),
3570
+ color: z15.string().describe("Hex color, e.g. '#2563eb'"),
3571
+ project: projectArg
3572
+ }
3573
+ },
3574
+ async ({ name, color, project }) => guard(async () => {
3575
+ const ctx = await resolveSimWorld(project);
3576
+ const tag = await sdk().tags.create({ projectId: ctx.projectId, name, color });
3577
+ return ok(`Created tag "${name}" (id: ${tag?.id}).`, tag);
3578
+ })
3579
+ );
3580
+ server.registerTool(
3581
+ "update_tag",
3582
+ {
3583
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3584
+ title: "Update tag",
3585
+ description: "Rename a tag or change its color.",
3586
+ inputSchema: {
3587
+ tagId: z15.string().describe("Tag id (see list_tags)"),
3588
+ name: z15.string().optional(),
3589
+ color: z15.string().optional().describe("Hex color, e.g. '#2563eb'"),
3590
+ project: projectArg
3591
+ }
3592
+ },
3593
+ async ({ tagId, name, color, project }) => guard(async () => {
3594
+ const ctx = await resolveSimWorld(project);
3595
+ const tag = await sdk().tags.update({ id: tagId, projectId: ctx.projectId, name, color });
3596
+ if (!tag) return fail(`No tag ${tagId} in project "${ctx.projectName}".`);
3597
+ return ok(`Updated tag (id: ${tagId}).`, tag);
3598
+ })
3599
+ );
3600
+ server.registerTool(
3601
+ "delete_tag",
3602
+ {
3603
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
3604
+ title: "Delete tag",
3605
+ description: "Permanently delete a tag and remove it from everything it labels.",
3606
+ inputSchema: {
3607
+ tagId: z15.string().describe("Tag id (see list_tags)"),
3608
+ project: projectArg
3609
+ }
3610
+ },
3611
+ async ({ tagId, project }) => guard(async () => {
3612
+ const ctx = await resolveSimWorld(project);
3613
+ const status = await sdk().tags.delete(tagId, ctx.projectId);
3614
+ if (status === "ERROR") return fail(`No tag ${tagId} in project "${ctx.projectName}"; nothing was deleted.`);
3615
+ return ok(`Deleted tag ${tagId}.`);
3616
+ })
3617
+ );
3618
+ server.registerTool(
3619
+ "link_tag",
3620
+ {
3621
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3622
+ title: "Link tag",
3623
+ description: "Attach a tag to a simulator, test, test plan or hardware. Creates the tag if it doesn't exist yet (then name is required).",
3624
+ inputSchema: {
3625
+ entityId: z15.string().describe("Id of the simulator, test, plan or hardware to label"),
3626
+ entityType,
3627
+ tagId: z15.string().optional().describe("Existing tag id (see list_tags)"),
3628
+ name: z15.string().optional().describe("Create-and-link when tagId is omitted"),
3629
+ color: z15.string().optional().describe("Hex color for a created tag"),
3630
+ project: projectArg
3631
+ }
3632
+ },
3633
+ async ({ entityId, entityType: entityType2, tagId, name, color, project }) => guard(async () => {
3634
+ const ctx = await resolveSimWorld(project);
3635
+ const existing = tagId ? await sdk().tags.get(tagId, ctx.projectId) : null;
3636
+ if (tagId && !existing) return fail(`No tag found with id ${tagId}.`);
3637
+ if (!existing && (!name || !color)) return fail("Pass `tagId`, or `name` and `color` to create a tag.");
3638
+ const tag = await sdk().tags.linkOrCreate({
3639
+ projectId: ctx.projectId,
3640
+ entityId,
3641
+ entityType: ENTITY_TYPES2[entityType2],
3642
+ tagId,
3643
+ name: existing?.name ?? name,
3644
+ color: existing?.color ?? color
3645
+ });
3646
+ return ok(`Linked tag "${tag?.name}" (id: ${tag?.id}) to ${entityType2} ${entityId}.`, tag);
3647
+ })
3648
+ );
3649
+ server.registerTool(
3650
+ "unlink_tag",
3651
+ {
3652
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3653
+ title: "Unlink tag",
3654
+ description: "Remove a tag from a simulator, test, test plan or hardware. The tag itself stays.",
3655
+ inputSchema: {
3656
+ tagId: z15.string().describe("Tag id"),
3657
+ entityId: z15.string().describe("Id of the labelled simulator, test, plan or hardware"),
3658
+ entityType,
3659
+ project: projectArg
3660
+ }
3661
+ },
3662
+ async ({ tagId, entityId, entityType: entityType2, project }) => guard(async () => {
3663
+ const ctx = await resolveSimWorld(project);
3664
+ const status = await sdk().tags.unlink({
3665
+ projectId: ctx.projectId,
3666
+ tagId,
3667
+ entityId,
3668
+ entityType: ENTITY_TYPES2[entityType2]
3669
+ });
3670
+ if (status === "ERROR") return fail(`Tag ${tagId} is not on ${entityType2} ${entityId}; nothing changed.`);
3671
+ return ok(`Unlinked tag ${tagId} from ${entityType2} ${entityId}.`);
3672
+ })
3673
+ );
3674
+ }
3675
+
3676
+ // src/tools/index.ts
3677
+ var READONLY_ENV = "TIMETICK_MCP_READONLY";
3678
+ function isReadOnlyMode(env = process.env) {
3679
+ const value = env[READONLY_ENV]?.trim().toLowerCase();
3680
+ return value === "1" || value === "true";
3681
+ }
3682
+ function readOnlyView(server) {
3683
+ return new Proxy(server, {
3684
+ get(target, prop, receiver) {
3685
+ if (prop !== "registerTool") return Reflect.get(target, prop, receiver);
3686
+ return (name, config, cb) => config.annotations?.readOnlyHint === true ? target.registerTool(name, config, cb) : void 0;
3687
+ }
3688
+ });
3689
+ }
3690
+ function registerTools(server, options) {
3691
+ const target = options.readOnly ? readOnlyView(server) : server;
3692
+ registerAuthTools(server, target);
3693
+ registerContextTools(target);
3694
+ registerDataTools(target);
3695
+ registerSimulatorTools(target);
3696
+ registerTestTools(target);
3697
+ registerTestRunTools(target);
3698
+ registerTestPlanTools(target);
3699
+ registerSupportTools(target);
3700
+ registerApiKeyTools(target);
3701
+ registerProjectTools(target);
3702
+ registerCatalogTools(target);
3703
+ registerOcppConfigTools(target);
3704
+ registerSimulatorConfigTools(target);
3705
+ registerTagTools(target);
3706
+ }
3707
+
3708
+ // src/index.ts
3709
+ async function main() {
3710
+ allowSlowConnections();
3711
+ const loggedIn = await loadCredentials();
3712
+ const readOnly = isReadOnlyMode();
3713
+ const server = new McpServer(
3714
+ { name: "timetick", version: VERSION },
3715
+ { instructions: SERVER_INSTRUCTIONS }
3716
+ );
3717
+ registerTools(server, { readOnly });
3718
+ const transport = new StdioServerTransport();
3719
+ await server.connect(transport);
3720
+ console.error(
3721
+ `Timetick MCP server ${VERSION} running on stdio${readOnly ? " (read-only)" : ""}${loggedIn || process.env.TIMETICK_API_TOKEN ? "" : ": not logged in yet, call the login tool"}`
3722
+ );
3723
+ void updateNotice().then((notice) => {
3724
+ if (notice) console.error(`Timetick MCP: ${notice}`);
3725
+ });
909
3726
  }
910
3727
  main().catch((error) => {
911
- console.error("Fatal error starting Timetick MCP server:", error);
3728
+ console.error("Fatal error in Timetick MCP server:", error);
912
3729
  process.exit(1);
913
3730
  });
914
- //# sourceMappingURL=index.js.map