@timetick/mcp 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +662 -0
- package/README.md +65 -70
- package/dist/index.js +3100 -284
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
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/
|
|
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(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
16
|
-
if (!apiKey)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
56
|
-
|
|
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 (
|
|
741
|
+
if (cached2) return cached2;
|
|
68
742
|
const projects = await sdk().projects.list();
|
|
69
743
|
if (!projects || projects.length === 0) {
|
|
70
|
-
throw new ContextError("
|
|
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
|
-
|
|
80
|
-
return
|
|
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
|
-
|
|
85
|
-
return
|
|
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
|
|
769
|
+
return cached2;
|
|
93
770
|
}
|
|
94
771
|
|
|
95
|
-
// src/
|
|
96
|
-
|
|
97
|
-
function
|
|
98
|
-
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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:
|
|
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})
|
|
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
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
${candidates}` : " No simulators found in this
|
|
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
|
|
211
|
-
const
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
pagination: { page: 1, pageSize: 50 }
|
|
1055
|
+
filters: { protocol },
|
|
1056
|
+
pagination: { page: 1, pageSize: CATALOG_PAGE_SIZE }
|
|
216
1057
|
});
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
1076
|
+
import { z as z3 } from "zod";
|
|
233
1077
|
var DEFAULT_PAGE_SIZE = 20;
|
|
234
1078
|
var paginationArgs = {
|
|
235
|
-
page:
|
|
236
|
-
pageSize:
|
|
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(
|
|
239
|
-
return { page:
|
|
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 =
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
1126
|
+
search: z4.string().optional().describe("Filter by simulator base name"),
|
|
284
1127
|
protocol: protocolEnum.optional(),
|
|
285
1128
|
...paginationArgs,
|
|
286
|
-
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(
|
|
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:
|
|
311
|
-
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
|
|
1168
|
+
description: "List real chargers registered as hardware in the active project's sim world.",
|
|
326
1169
|
inputSchema: {
|
|
327
|
-
search:
|
|
1170
|
+
search: z4.string().optional().describe("Filter by serial number / model"),
|
|
328
1171
|
...paginationArgs,
|
|
329
|
-
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(
|
|
1180
|
+
pagination: pagination(page2, pageSize)
|
|
338
1181
|
});
|
|
339
1182
|
return ok(
|
|
340
|
-
pageSummary("hardware
|
|
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
|
|
350
|
-
description: "List
|
|
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
|
|
353
|
-
search:
|
|
1211
|
+
protocol: protocolEnum,
|
|
1212
|
+
search: z4.string().optional().describe("Filter by step name"),
|
|
354
1213
|
...paginationArgs,
|
|
355
|
-
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: {
|
|
364
|
-
pagination: pagination(
|
|
1222
|
+
filters: { protocol },
|
|
1223
|
+
pagination: pagination(page2, pageSize, 100)
|
|
365
1224
|
});
|
|
366
|
-
const actions = (result?.records ?? []).
|
|
367
|
-
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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:
|
|
383
|
-
action: {
|
|
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 {
|
|
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 =
|
|
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:
|
|
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 {
|
|
402
|
-
return ok(`Powered on ${
|
|
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:
|
|
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 {
|
|
418
|
-
return ok(`Powered off ${
|
|
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
|
-
"
|
|
1597
|
+
"run_step",
|
|
423
1598
|
{
|
|
424
1599
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
425
|
-
title: "Run
|
|
426
|
-
description: "Run
|
|
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:
|
|
429
|
-
|
|
430
|
-
|
|
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 {
|
|
436
|
-
return ok(`Ran "${actionName}" on ${
|
|
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
|
|
443
|
-
|
|
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 =
|
|
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
|
|
1681
|
+
description: "List tests in the active project's sim world.",
|
|
452
1682
|
inputSchema: {
|
|
453
|
-
protocol:
|
|
454
|
-
search:
|
|
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(
|
|
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,
|
|
1705
|
+
description: "Get a test by id, with its slots, steps, arguments and expected results.",
|
|
476
1706
|
inputSchema: {
|
|
477
|
-
testId:
|
|
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
|
-
|
|
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
|
|
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:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
|
543
|
-
simulator:
|
|
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
|
|
551
|
-
if (
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
`
|
|
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:
|
|
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(
|
|
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: "
|
|
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:
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
|
|
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,
|
|
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
|
-
|
|
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} ${
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
|
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:
|
|
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 = `${
|
|
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
|
|
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:
|
|
840
|
-
page:
|
|
841
|
-
simulator:
|
|
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 =
|
|
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/
|
|
872
|
-
|
|
873
|
-
function
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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/
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
|
3728
|
+
console.error("Fatal error in Timetick MCP server:", error);
|
|
912
3729
|
process.exit(1);
|
|
913
3730
|
});
|
|
914
|
-
//# sourceMappingURL=index.js.map
|