@timetick/mcp 0.3.1 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +10 -15
  2. package/dist/index.js +159 -658
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,19 +1,21 @@
1
1
  # @timetick/mcp
2
2
 
3
- Run [Timetick](https://timetick.io) from your AI client. Say "I want to test my CSMS" and the agent signs you in, sets up simulators, runs the test and explains the result. It works with OCPP 1.6 and 2.0.1, and can test a CSMS or a real charger.
3
+ Run [Timetick](https://timetick.io) from your AI client. Say "I want to test my CSMS" and the agent sets up simulators, runs the test and explains the result. It works with OCPP 1.6 and 2.0.1, and can test a CSMS or a real charger.
4
4
 
5
5
  ## Install
6
6
 
7
+ Sign up at [app.timetick.io](https://app.timetick.io) and create your first project. Open the user menu, choose **API keys** and create a key. Then add the server with that key.
8
+
7
9
  Claude Code:
8
10
 
9
11
  ```
10
- claude mcp add timetick -- npx -y @timetick/mcp@latest
12
+ claude mcp add timetick -e TIMETICK_API_TOKEN=your_api_key -- npx -y @timetick/mcp@latest
11
13
  ```
12
14
 
13
15
  Codex:
14
16
 
15
17
  ```
16
- codex mcp add timetick -- npx -y @timetick/mcp@latest
18
+ codex mcp add timetick --env TIMETICK_API_TOKEN=your_api_key -- npx -y @timetick/mcp@latest
17
19
  ```
18
20
 
19
21
  Gemini CLI, Claude Desktop, Cursor and other MCP clients:
@@ -23,7 +25,8 @@ Gemini CLI, Claude Desktop, Cursor and other MCP clients:
23
25
  "mcpServers": {
24
26
  "timetick": {
25
27
  "command": "npx",
26
- "args": ["-y", "@timetick/mcp@latest"]
28
+ "args": ["-y", "@timetick/mcp@latest"],
29
+ "env": { "TIMETICK_API_TOKEN": "your_api_key" }
27
30
  }
28
31
  }
29
32
  }
@@ -31,15 +34,7 @@ Gemini CLI, Claude Desktop, Cursor and other MCP clients:
31
34
 
32
35
  Keep `@latest` so you stay on the current version.
33
36
 
34
- ## Sign in
35
-
36
- Ask the agent to log in to Timetick. A page opens in your browser where you can sign in, create an account or paste an API key. Your password stays out of the AI conversation.
37
-
38
- If you use Google or LinkedIn to sign in, create an API key in the portal under **API keys** and paste it on that page.
39
-
40
- On a machine with no browser, such as over SSH, set `TIMETICK_API_TOKEN` to your API key in the client config.
41
-
42
- To switch accounts, ask the agent to log in again. To use two accounts at once, add the server twice, each with its own `TIMETICK_API_TOKEN`.
37
+ To switch accounts, change `TIMETICK_API_TOKEN` and restart the client. To use two accounts at once, add the server twice, each with its own `TIMETICK_API_TOKEN`.
43
38
 
44
39
  ## Test your CSMS
45
40
 
@@ -59,7 +54,7 @@ Your charger connects to Timetick's CSMS. Someone at the charger does the physic
59
54
  - Read results and OCPP logs
60
55
  - Manage projects, tags and API keys
61
56
 
62
- Some things still need the [portal](https://app.timetick.io): billing, resetting a password, and security profiles (Basic Auth or certificates towards a CSMS).
57
+ Some things still need the [portal](https://app.timetick.io): signing up, billing, resetting a password, and security profiles (Basic Auth or certificates towards a CSMS).
63
58
 
64
59
  ## Projects and trial
65
60
 
@@ -71,7 +66,7 @@ If you have several projects, tell the agent which one to use.
71
66
 
72
67
  ## Read-only mode
73
68
 
74
- Set `TIMETICK_MCP_READONLY=1` and the agent can read your data but not change it. You can still sign in and out.
69
+ Set `TIMETICK_MCP_READONLY=1` and the agent can read your data but not change it.
75
70
 
76
71
  ## Help
77
72
 
package/dist/index.js CHANGED
@@ -4,53 +4,10 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
 
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
7
  // 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).
8
+ 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 signing up, the first API key, billing, resetting a password, and security profiles (Basic Auth or certificates towards a CSMS).
52
9
 
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.
10
+ Sign-in: call whoami. If no API key is configured, ask the user to create one in the portal under API keys, set it as TIMETICK_API_TOKEN in the client config and restart the client. Never ask for passwords or API keys in chat.
54
11
 
55
12
  Pick the setup from what is under test:
56
13
  - 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.
@@ -69,15 +26,33 @@ function allowSlowConnections() {
69
26
 
70
27
  // src/tools/auth.ts
71
28
  import { z } from "zod";
72
- import { TimetickError as TimetickError3 } from "@timetick/sdk";
29
+ import { TimetickError as TimetickError2 } from "@timetick/sdk";
73
30
 
74
- // src/auth/browser-login.ts
75
- import { createServer } from "http";
76
- import { spawn } from "child_process";
77
- import { randomBytes } from "crypto";
31
+ // src/sdk.ts
32
+ import { createClient } from "@timetick/sdk";
78
33
 
79
- // src/result.ts
80
- import { TimetickError } from "@timetick/sdk";
34
+ // src/links.ts
35
+ var DEFAULT_API_ENDPOINT = "https://app.timetick.io/graphql";
36
+ function portalUrl(path = "/") {
37
+ return new URL(path, process.env.TIMETICK_API_URL ?? DEFAULT_API_ENDPOINT).toString();
38
+ }
39
+ var SIMULATOR_PATH = /\/projects\/([^/?#&|>\s]+)\/simulators\/([^/?#&|>\s]+)/;
40
+ function looksLikeLink(text) {
41
+ return /^<?https?:\/\//i.test(text) || /(^|\.)timetick\.io\//i.test(text);
42
+ }
43
+ function parseSimulatorRef(input) {
44
+ const text = input.trim();
45
+ if (!looksLikeLink(text)) return { simulator: text };
46
+ const unescaped = text.replace(/&amp;/g, "&");
47
+ const match = unescaped.match(SIMULATOR_PATH);
48
+ if (!match) {
49
+ throw new Error(
50
+ `"${input}" looks like a link but not to a simulator. Expected https://app.timetick.io/projects/<project id>/simulators/<simulator id>.`
51
+ );
52
+ }
53
+ return { project: decodeURIComponent(match[1]), simulator: decodeURIComponent(match[2]) };
54
+ }
55
+ var SIMULATOR_ARG_DESCRIPTION = "Simulator id, base name, or portal link (https://app.timetick.io/projects/<id>/simulators/<id>)";
81
56
 
82
57
  // src/version.ts
83
58
  import { readFileSync } from "fs";
@@ -107,7 +82,25 @@ async function updateNotice() {
107
82
  return `A newer Timetick MCP server is available: ${newest} (running ${VERSION}). ${UPDATE_HOW_TO}`;
108
83
  }
109
84
 
85
+ // src/sdk.ts
86
+ function notLoggedInMessage() {
87
+ return `No API key configured. Ask the user to sign in at ${portalUrl()}, create a key under API keys in the user menu, set it as TIMETICK_API_TOKEN in the client config and restart the client. Never ask for the key in chat.`;
88
+ }
89
+ var client = null;
90
+ function sdk() {
91
+ if (client) return client;
92
+ const apiKey = process.env.TIMETICK_API_TOKEN;
93
+ if (!apiKey) throw new Error(notLoggedInMessage());
94
+ client = createClient({
95
+ apiKey,
96
+ endpoint: process.env.TIMETICK_API_URL,
97
+ headers: CLIENT_HEADER
98
+ });
99
+ return client;
100
+ }
101
+
110
102
  // src/result.ts
103
+ import { TimetickError } from "@timetick/sdk";
111
104
  var KEY_RENAMES = {
112
105
  chargerActions: "steps",
113
106
  scenarioAction: "step",
@@ -265,7 +258,7 @@ function describeApiError(err) {
265
258
  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
259
  }
267
260
  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`.";
261
+ return "Timetick rejected the API key in TIMETICK_API_TOKEN (revoked, expired or disabled). Replace it in the client config.";
269
262
  }
270
263
  if (/trial is not available/i.test(err.message)) {
271
264
  return "This account has used its free trial, so this needs a paid plan, which is set up in the web portal.";
@@ -299,401 +292,70 @@ async function guard(fn) {
299
292
  }
300
293
  }
301
294
 
302
- // src/auth/platform.ts
303
- import { hostname } from "os";
304
- import { TimetickError as TimetickError2 } from "@timetick/sdk";
305
- async function request(operationName, query, variables, headers = {}) {
306
- const res = await fetch(apiEndpoint(), {
307
- method: "POST",
308
- headers: { "content-type": "application/json", ...CLIENT_HEADER, ...headers },
309
- body: JSON.stringify({ operationName, query, variables })
310
- });
311
- const body = await res.json().catch(() => null);
312
- if (body?.errors?.length) {
313
- throw new TimetickError2(body.errors);
314
- }
315
- if (!body?.data) {
316
- throw new Error(
317
- res.status >= 500 ? `Timetick is not responding properly (HTTP ${res.status}). Try again shortly.` : `${apiEndpoint()} did not answer like the Timetick API (HTTP ${res.status}). Check TIMETICK_API_URL.`
318
- );
319
- }
320
- return { data: body.data, setCookie: res.headers.getSetCookie?.() ?? [] };
321
- }
322
- var SIGN_IN_MUTATION = `
323
- mutation SignIn($email: String!, $password: String!) {
324
- signIn(email: $email, password: $password) {
325
- id
326
- email
327
- firstName
328
- lastName
329
- }
330
- }
331
- `;
332
- var SIGN_UP_MUTATION = `
333
- mutation SignUp($email: String!, $password: String!, $firstName: String, $lastName: String, $companyName: String) {
334
- signUp(email: $email, password: $password, firstName: $firstName, lastName: $lastName, companyName: $companyName) {
335
- id
336
- }
337
- }
338
- `;
339
- var CREATE_API_KEY_MUTATION = `
340
- mutation CreateApiKey($name: String!) {
341
- createApiKey(name: $name, enabled: true) {
342
- apiKey { id name createdAt }
343
- secretKey
344
- }
345
- }
346
- `;
347
- var DELETE_API_KEY_MUTATION = `
348
- mutation DeleteApiKey($id: ID!) {
349
- deleteApiKey(id: $id)
350
- }
351
- `;
352
- var SESSION_USER_QUERY = `
353
- query SessionUser {
354
- currentUser {
355
- id
356
- email
357
- firstName
358
- lastName
359
- companyName
360
- }
361
- }
362
- `;
363
- function extractSessionCookie(setCookie) {
364
- for (const cookie of setCookie) {
365
- const [pair] = cookie.split(";");
366
- const [name, ...rest] = pair.split("=");
367
- if (name?.trim() === "sessionID") {
368
- const value = rest.join("=");
369
- return value ? `sessionID=${value}` : null;
370
- }
371
- }
372
- return null;
373
- }
374
- async function signIn(email, password) {
375
- const { data, setCookie } = await request("SignIn", SIGN_IN_MUTATION, { email, password });
376
- if (!data.signIn) throw new Error("Sign-in failed: wrong email or password.");
377
- const sessionCookie = extractSessionCookie(setCookie);
378
- if (!sessionCookie) {
379
- throw new Error("Signed in but the platform did not return a session cookie.");
380
- }
381
- return { sessionCookie, user: data.signIn };
382
- }
383
- async function signUp(input) {
384
- await request("SignUp", SIGN_UP_MUTATION, input);
385
- }
386
- async function createApiKey(sessionCookie, name) {
387
- const { data } = await request("CreateApiKey", CREATE_API_KEY_MUTATION, { name }, { cookie: sessionCookie });
388
- const response = data.createApiKey;
389
- if (!response?.secretKey || !response.apiKey?.id) {
390
- throw new Error("The platform accepted the login but did not return an API key.");
391
- }
392
- return { id: response.apiKey.id, secretKey: response.secretKey };
393
- }
394
- async function deleteApiKey(apiKey, id) {
395
- const { data } = await request("DeleteApiKey", DELETE_API_KEY_MUTATION, { id }, { "x-api-key": apiKey });
396
- if (data.deleteApiKey === "ERROR") throw new Error("The platform could not delete the key.");
397
- }
398
- async function fetchSessionUser(apiKey) {
399
- const { data } = await request("SessionUser", SESSION_USER_QUERY, {}, { "x-api-key": apiKey });
400
- return data.currentUser ?? null;
401
- }
402
- function displayName(user) {
403
- return [user.firstName, user.lastName].filter(Boolean).join(" ") || void 0;
404
- }
405
- async function revokeReplaced(previous, newKey) {
406
- if (!previous?.keyId || previous.apiKey === newKey) return;
407
- await deleteApiKey(previous.apiKey, previous.keyId).catch((err) => {
408
- console.error(`Timetick MCP: could not revoke the replaced API key ${previous.keyId}: ${String(err)}`);
409
- });
410
- }
411
- async function loginWithCredentials(email, password) {
412
- const { sessionCookie, user } = await signIn(email, password);
413
- const key = await createApiKey(sessionCookie, `MCP (${hostname()})`);
414
- const previous = await loadCredentials(true);
415
- const credentials = {
416
- apiKey: key.secretKey,
417
- endpoint: apiEndpoint(),
418
- keyId: key.id,
419
- email: user.email ?? email,
420
- userName: displayName(user),
421
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
422
- };
423
- await saveCredentials(credentials);
424
- await revokeReplaced(previous, credentials.apiKey);
425
- return { credentials, keyId: key.id };
426
- }
427
- async function loginWithApiKey(apiKey) {
428
- const user = await fetchSessionUser(apiKey).catch((err) => {
429
- if (err instanceof TimetickError2 && /not authenticated/i.test(err.message)) return null;
430
- throw err;
431
- });
432
- if (!user?.id) throw new Error("Timetick did not accept that API key.");
433
- const previous = await loadCredentials(true);
434
- const credentials = {
435
- apiKey,
436
- endpoint: apiEndpoint(),
437
- email: user.email ?? void 0,
438
- userName: displayName(user),
439
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
440
- };
441
- await saveCredentials(credentials);
442
- await revokeReplaced(previous, apiKey);
443
- return { credentials };
444
- }
445
- function portalUrl(path = "/") {
446
- return new URL(path, apiEndpoint()).toString();
447
- }
448
-
449
- // src/auth/browser-login.ts
450
- var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
451
- var MAX_BODY_BYTES = 64 * 1024;
452
- var TERMS_URL = "https://timetick.io/terms/";
453
- var PRIVACY_URL = "https://timetick.io/privacy-policy/";
454
- var EMAIL_RULE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(\.[a-zA-Z]{2,63})+$/;
455
- var PASSWORD_RULE = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)\S{8,}$/;
456
- var STYLE = `
457
- body { font-family: -apple-system, "Segoe UI", sans-serif; background: #f6f7f9; display: flex;
458
- justify-content: center; padding-top: 8vh; margin: 0; }
459
- .card { background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.08);
460
- padding: 2.2rem; width: 24rem; }
461
- h1 { font-size: 1.2rem; margin: 0 0 .3rem; }
462
- summary { cursor: pointer; font-weight: 600; font-size: .95rem; padding: .7rem 0 .3rem; }
463
- p.sub, p.hint { color: #667; font-size: .8rem; margin: .2rem 0 .8rem; }
464
- label { display: block; font-size: .78rem; color: #445; margin: .7rem 0 .25rem; }
465
- input { width: 100%; box-sizing: border-box; padding: .55rem .7rem; font-size: .95rem;
466
- border: 1px solid #ccd; border-radius: 8px; }
467
- input[type=checkbox] { width: auto; margin-right: .4rem; }
468
- button { margin-top: 1.1rem; width: 100%; padding: .6rem; font-size: .95rem; color: #fff;
469
- background: #2563eb; border: 0; border-radius: 8px; cursor: pointer; }
470
- .error { background: #fee2e2; color: #991b1b; border-radius: 8px; padding: .6rem .8rem; font-size: .85rem; }
471
- .notice { background: #dcfce7; color: #166534; border-radius: 8px; padding: .6rem .8rem; font-size: .85rem; }
472
- `;
473
- function shell(title, body) {
474
- return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><style>${STYLE}</style></head>
475
- <body><div class="card">${body}</div></body></html>`;
476
- }
477
- function escapeHtml(text) {
478
- return text.replace(
479
- /[&<>"']/g,
480
- (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
481
- );
482
- }
483
- function page(state, tab, message) {
484
- return shell(
485
- "Timetick \u2014 sign in",
486
- `<h1>Connect your AI client to Timetick</h1>
487
- <p class="sub">This page runs on your computer. What you type goes to the Timetick MCP server on this
488
- machine and from there to Timetick. It is not shared with the AI client or its conversation.</p>
489
- ${message ? `<div class="${message.kind}">${escapeHtml(message.text)}</div>` : ""}
490
- <details ${tab === "signin" ? "open" : ""}><summary>Sign in</summary>
491
- <form method="post" action="/${state}/signin">
492
- <label for="email">Email</label><input id="email" name="email" type="email" required>
493
- <label for="password">Password</label><input id="password" name="password" type="password" required>
494
- <button type="submit">Sign in</button>
495
- </form>
496
- <p class="hint"><a href="${escapeHtml(portalUrl("/reset-password"))}" target="_blank" rel="noreferrer">Forgot your password?</a></p>
497
- </details>
498
- <details ${tab === "signup" ? "open" : ""}><summary>Create an account</summary>
499
- <form method="post" action="/${state}/signup">
500
- <label for="new-email">Work email</label><input id="new-email" name="email" type="email" required>
501
- <label for="new-password">Password (8+ characters with upper and lower case and a digit)</label>
502
- <input id="new-password" name="password" type="password" required>
503
- <label for="passwordConfirm">Confirm password</label>
504
- <input id="passwordConfirm" name="passwordConfirm" type="password" required>
505
- <label for="firstName">First name</label><input id="firstName" name="firstName" required minlength="2" maxlength="50">
506
- <label for="lastName">Last name</label><input id="lastName" name="lastName" required minlength="2" maxlength="50">
507
- <label for="companyName">Company</label><input id="companyName" name="companyName" required minlength="2" maxlength="50">
508
- <label><input name="agree" type="checkbox" required>I agree with the
509
- <a href="${TERMS_URL}" target="_blank" rel="noreferrer">Terms and Conditions</a> and the
510
- <a href="${PRIVACY_URL}" target="_blank" rel="noreferrer">Privacy Policy</a></label>
511
- <button type="submit">Create account</button>
512
- </form>
513
- </details>
514
- <details ${tab === "apikey" ? "open" : ""}><summary>Signed up with Google or LinkedIn</summary>
515
- <p class="hint">Sign in to the <a href="${escapeHtml(portalUrl())}" target="_blank" rel="noreferrer">
516
- Timetick portal</a>, open your project, go to API keys, create one and paste it here.</p>
517
- <form method="post" action="/${state}/apikey">
518
- <label for="apiKey">API key</label><input id="apiKey" name="apiKey" type="password" required>
519
- <button type="submit">Use this key</button>
520
- </form>
521
- </details>`
522
- );
523
- }
524
- var donePage = (title, message) => shell("Timetick", `<h1>${title}</h1><p class="sub">${message}</p>`);
525
- function openBrowser(url) {
526
- const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
527
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
528
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
529
- child.on("error", () => {
530
- });
531
- child.unref();
532
- }
533
- async function readForm(req) {
534
- const chunks = [];
535
- let size = 0;
536
- for await (const chunk of req) {
537
- size += chunk.length;
538
- if (size > MAX_BODY_BYTES) throw new Error("The form is too large.");
539
- chunks.push(chunk);
540
- }
541
- const out = {};
542
- for (const [key, value] of new URLSearchParams(Buffer.concat(chunks).toString("utf8"))) {
543
- out[key] = key === "password" || key === "passwordConfirm" ? value : value.trim();
544
- }
545
- return out;
546
- }
547
- function sendHtml(res, status, html) {
548
- res.writeHead(status, {
549
- "content-type": "text/html; charset=utf-8",
550
- "cache-control": "no-store",
551
- "referrer-policy": "no-referrer",
552
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'"
553
- });
554
- res.end(html);
555
- }
556
- function validateSignUp(form) {
557
- if (!EMAIL_RULE.test(form.email ?? "")) throw new Error("Check the email address.");
558
- if (!PASSWORD_RULE.test(form.password ?? "")) {
559
- throw new Error("The password needs 8 or more characters with upper and lower case and a digit, and no spaces.");
560
- }
561
- if (form.password !== form.passwordConfirm) throw new Error("The passwords do not match.");
562
- const named = (value, label) => {
563
- if (!value || value.length < 2 || value.length > 50) throw new Error(`${label} must be 2 to 50 characters.`);
564
- return value;
565
- };
566
- const account = {
567
- email: form.email,
568
- password: form.password,
569
- firstName: named(form.firstName, "First name"),
570
- lastName: named(form.lastName, "Last name"),
571
- companyName: named(form.companyName, "Company")
572
- };
573
- if (form.agree !== "on") throw new Error("Please agree with the Terms and Conditions and the Privacy Policy.");
574
- return account;
575
- }
576
- var pending = null;
577
- async function startBrowserLogin(onLogin) {
578
- if (pending) {
579
- const login = await pending;
580
- openBrowser(login.url);
581
- return login;
582
- }
583
- pending = openLoginPage(onLogin).catch((err) => {
584
- pending = null;
585
- throw err;
586
- });
587
- return pending;
588
- }
589
- async function openLoginPage(onLogin) {
590
- const state = randomBytes(16).toString("hex");
591
- let resolveDone;
592
- let rejectDone;
593
- const done = new Promise((resolve, reject) => {
594
- resolveDone = resolve;
595
- rejectDone = reject;
596
- });
597
- done.catch(() => {
598
- });
599
- let host = "";
600
- const server = createServer(async (req, res) => {
601
- const [, token, action] = (req.url ?? "/").split("?")[0].split("/");
602
- if (req.headers.host !== host || token !== state) {
603
- return sendHtml(res, 404, donePage("Page expired", "Call the login tool again."));
604
- }
605
- if (req.method === "GET" && !action) return sendHtml(res, 200, page(state, "signin"));
606
- if (req.method !== "POST") return sendHtml(res, 405, donePage("Not allowed", "Unsupported request."));
607
- const tab = action === "signup" ? "signup" : action === "apikey" ? "apikey" : "signin";
608
- try {
609
- const form = await readForm(req);
610
- if (action === "signup") {
611
- await signUp(validateSignUp(form));
612
- return sendHtml(res, 200, page(state, "signin", {
613
- kind: "notice",
614
- text: "Account created. Open the confirmation link we emailed you, then sign in here."
615
- }));
616
- }
617
- let result;
618
- if (action === "apikey") {
619
- if (!form.apiKey) throw new Error("Paste an API key.");
620
- result = await loginWithApiKey(form.apiKey);
621
- } else {
622
- if (!form.email || !form.password) throw new Error("Email and password are required.");
623
- result = await loginWithCredentials(form.email, form.password);
624
- }
625
- sendHtml(res, 200, donePage("Connected", "You can close this window and return to your AI client."));
626
- finish();
627
- onLogin(result);
628
- resolveDone(result);
629
- } catch (err) {
630
- sendHtml(res, 400, page(state, tab, {
631
- kind: "error",
632
- text: isNetworkFailure(err) ? "Could not reach Timetick. Check your connection and try again." : describeError(err)
633
- }));
634
- }
635
- });
636
- const timer = setTimeout(() => {
637
- finish();
638
- rejectDone(new Error("Sign-in timed out after 10 minutes. Call the login tool to try again."));
639
- }, LOGIN_TIMEOUT_MS);
640
- timer.unref();
641
- function finish() {
642
- clearTimeout(timer);
643
- server.close();
644
- server.closeIdleConnections?.();
645
- pending = null;
646
- }
647
- await new Promise((resolve, reject) => {
648
- server.once("error", (err) => {
649
- clearTimeout(timer);
650
- reject(new Error(`Could not start the local sign-in page: ${err.message}`));
651
- });
652
- server.listen(0, "127.0.0.1", resolve);
653
- });
654
- server.unref();
655
- const address = server.address();
656
- if (!address || typeof address === "string") {
657
- finish();
658
- throw new Error("Could not determine the local sign-in page address.");
659
- }
660
- host = `127.0.0.1:${address.port}`;
661
- const url = `http://${host}/${state}`;
662
- openBrowser(url);
663
- console.error(`Timetick MCP: waiting for sign-in at ${url}`);
664
- return { url, done };
295
+ // src/tools/auth.ts
296
+ function registerAuthTools(server, target = server) {
297
+ server.registerTool(
298
+ "whoami",
299
+ {
300
+ annotations: { readOnlyHint: true },
301
+ title: "Show authentication status",
302
+ description: "Show who the MCP server is authenticated as, the server version, and whether a newer version is available.",
303
+ inputSchema: {}
304
+ },
305
+ async () => guard(async () => {
306
+ const notice = await updateNotice();
307
+ const footer = `Timetick MCP ${VERSION}.${notice ? ` ${notice}` : ""}`;
308
+ if (!process.env.TIMETICK_API_TOKEN) return ok(`${notLoggedInMessage()}
309
+ ${footer}`);
310
+ const user = await sdk().users.getCurrent().catch((err) => {
311
+ if (err instanceof TimetickError2 && /not authenticated/i.test(err.message)) return null;
312
+ throw err;
313
+ });
314
+ if (!user?.id) {
315
+ return fail(`The API key in TIMETICK_API_TOKEN was not accepted (revoked, expired or disabled). Replace it in the client config.
316
+ ${footer}`);
317
+ }
318
+ const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email || "unknown user";
319
+ const company = `${user.email && user.email !== name ? ` <${user.email}>` : ""}${user.companyName ? `, ${user.companyName}` : ""}`;
320
+ return ok(`Authenticated as ${name}${company}.
321
+ ${footer}`, { user });
322
+ })
323
+ );
324
+ target.registerTool(
325
+ "update_profile",
326
+ {
327
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
328
+ title: "Update profile",
329
+ description: "Update your Timetick account profile (name, company, phone). Changing the sign-in email is done in the web portal.",
330
+ inputSchema: {
331
+ firstName: z.string().optional(),
332
+ lastName: z.string().optional(),
333
+ companyName: z.string().optional(),
334
+ companyWebsite: z.string().optional(),
335
+ phoneNumber: z.string().optional(),
336
+ countryCode: z.string().optional().describe("Two-letter country code, e.g. 'NL'")
337
+ }
338
+ },
339
+ async (input) => guard(async () => {
340
+ const me = await sdk().users.getCurrent();
341
+ if (!me?.id) return fail("Could not resolve the current user id.");
342
+ const { id } = me;
343
+ const values = Object.fromEntries(
344
+ Object.entries(input).filter(([, v]) => v !== void 0)
345
+ );
346
+ const updated = await sdk().users.updateProfile({ id, ...values });
347
+ return ok(`Updated profile.`, updated);
348
+ })
349
+ );
665
350
  }
666
351
 
667
- // src/sdk.ts
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
- }
677
- var client = null;
678
- function sdk() {
679
- if (client) return client;
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
- });
687
- return client;
688
- }
689
- function resetSdk() {
690
- client = null;
691
- }
352
+ // src/tools/context.ts
353
+ import { z as z2 } from "zod";
692
354
 
693
355
  // src/context.ts
694
356
  var ContextError = class extends Error {
695
357
  };
696
- var cached2 = null;
358
+ var cached = null;
697
359
  async function buildContext(projectId, projectName, source) {
698
360
  const worlds = await sdk().simWorlds.list(projectId);
699
361
  const world = worlds?.[0];
@@ -738,7 +400,7 @@ ${formatProjects(projects)}`
738
400
  return { id: match.id, name: match.name ?? "(unnamed)" };
739
401
  }
740
402
  async function getContext() {
741
- if (cached2) return cached2;
403
+ if (cached) return cached;
742
404
  const projects = await sdk().projects.list();
743
405
  if (!projects || projects.length === 0) {
744
406
  throw new ContextError("This account has no projects yet. Create one with create_project.");
@@ -750,158 +412,26 @@ ${formatProjects(projects)}`
750
412
  );
751
413
  }
752
414
  const only = projects[0];
753
- cached2 = await buildContext(only.id, only.name ?? "(unnamed)", "single-project");
754
- return cached2;
415
+ cached = await buildContext(only.id, only.name ?? "(unnamed)", "single-project");
416
+ return cached;
755
417
  }
756
418
  async function useProject(input) {
757
419
  const match = await findProject(input.project);
758
- cached2 = await buildContext(match.id, match.name, "explicit");
759
- return cached2;
420
+ cached = await buildContext(match.id, match.name, "explicit");
421
+ return cached;
760
422
  }
761
423
  async function lookupProject(project) {
762
424
  const match = await findProject(project);
763
425
  return buildContext(match.id, match.name, "explicit");
764
426
  }
765
427
  function resetContext() {
766
- cached2 = null;
428
+ cached = null;
767
429
  }
768
430
  function peekContext() {
769
- return cached2;
770
- }
771
-
772
- // src/tools/auth.ts
773
- var LOGIN_WAIT_MS = 45 * 1e3;
774
- function afterAuthChange() {
775
- resetSdk();
776
- resetContext();
777
- }
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.`;
781
- }
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
- );
431
+ return cached;
901
432
  }
902
433
 
903
434
  // src/tools/context.ts
904
- import { z as z2 } from "zod";
905
435
  async function planNote(projectId) {
906
436
  const project = await sdk().projects.get(projectId).catch(() => null);
907
437
  if (!project) return "";
@@ -949,25 +479,6 @@ function registerContextTools(server) {
949
479
  import { z as z4 } from "zod";
950
480
  import { describeStepArgs } from "@timetick/sdk";
951
481
 
952
- // src/links.ts
953
- var SIMULATOR_PATH = /\/projects\/([^/?#&|>\s]+)\/simulators\/([^/?#&|>\s]+)/;
954
- function looksLikeLink(text) {
955
- return /^<?https?:\/\//i.test(text) || /(^|\.)timetick\.io\//i.test(text);
956
- }
957
- function parseSimulatorRef(input) {
958
- const text = input.trim();
959
- if (!looksLikeLink(text)) return { simulator: text };
960
- const unescaped = text.replace(/&amp;/g, "&");
961
- const match = unescaped.match(SIMULATOR_PATH);
962
- if (!match) {
963
- throw new Error(
964
- `"${input}" looks like a link but not to a simulator. Expected https://app.timetick.io/projects/<project id>/simulators/<simulator id>.`
965
- );
966
- }
967
- return { project: decodeURIComponent(match[1]), simulator: decodeURIComponent(match[2]) };
968
- }
969
- var SIMULATOR_ARG_DESCRIPTION = "Simulator id, base name, or portal link (https://app.timetick.io/projects/<id>/simulators/<id>)";
970
-
971
482
  // src/resolve.ts
972
483
  async function resolveSimWorld(project) {
973
484
  if (project) return lookupProject(project);
@@ -978,11 +489,11 @@ var CANDIDATE_LIMIT = 10;
978
489
  async function resolveSimulator(simWorldId, simulator) {
979
490
  const needle = simulator.toLowerCase();
980
491
  const records = [];
981
- for (let page2 = 1; ; page2++) {
492
+ for (let page = 1; ; page++) {
982
493
  const result = await sdk().simulators.list({
983
494
  simWorldId,
984
495
  searchValue: simulator,
985
- pagination: { page: page2, pageSize: RESOLVE_PAGE_SIZE }
496
+ pagination: { page, pageSize: RESOLVE_PAGE_SIZE }
986
497
  });
987
498
  records.push(...result?.records ?? []);
988
499
  if (!result?.records?.length || !result.pageInfo?.hasNextPage) break;
@@ -1023,8 +534,8 @@ async function resolveSimulatorById(id) {
1023
534
  return { id: byId.id, baseName: byId.baseName ?? "", protocol: String(byId.protocol) };
1024
535
  }
1025
536
  async function resolveHardware(ctx, hardware) {
1026
- const page2 = await sdk().hardware.list({ deviceScopeId: ctx.deviceScopeId, searchValue: hardware });
1027
- const records = page2?.records ?? [];
537
+ const page = await sdk().hardware.list({ deviceScopeId: ctx.deviceScopeId, searchValue: hardware });
538
+ const records = page?.records ?? [];
1028
539
  const match = records.find((r) => r?.id === hardware) ?? records.find((r) => r?.serialNumber?.toLowerCase() === hardware.toLowerCase());
1029
540
  if (match?.id) return { id: match.id, name: match.serialNumber ?? match.id, protocol: String(match.protocol) };
1030
541
  const byId = await sdk().hardware.get(hardware).catch(() => null);
@@ -1050,12 +561,12 @@ async function resolveRunTarget(input) {
1050
561
  }
1051
562
  var CATALOG_PAGE_SIZE = 200;
1052
563
  async function loadActionCatalog(simWorldId, protocol) {
1053
- const page2 = await sdk().actions.list({
564
+ const page = await sdk().actions.list({
1054
565
  simWorldId,
1055
566
  filters: { protocol },
1056
567
  pagination: { page: 1, pageSize: CATALOG_PAGE_SIZE }
1057
568
  });
1058
- return (page2?.records ?? []).filter((action) => action != null);
569
+ return (page?.records ?? []).filter((action) => action != null);
1059
570
  }
1060
571
  function findAction(catalog, protocol, actionName) {
1061
572
  const needle = actionName.trim().toLowerCase();
@@ -1079,8 +590,8 @@ var paginationArgs = {
1079
590
  page: z3.number().int().min(1).optional().describe("Page number, starting at 1 (default 1)"),
1080
591
  pageSize: z3.number().int().min(1).max(200).optional().describe(`Results per page (default ${DEFAULT_PAGE_SIZE})`)
1081
592
  };
1082
- function pagination(page2, pageSize, defaultPageSize = DEFAULT_PAGE_SIZE) {
1083
- return { page: page2 ?? 1, pageSize: pageSize ?? defaultPageSize };
593
+ function pagination(page, pageSize, defaultPageSize = DEFAULT_PAGE_SIZE) {
594
+ return { page: page ?? 1, pageSize: pageSize ?? defaultPageSize };
1084
595
  }
1085
596
  function pageSummary(noun, count, pageInfo) {
1086
597
  const total = pageInfo?.totalCount;
@@ -1129,13 +640,13 @@ function registerDataTools(server) {
1129
640
  project: z4.string().optional().describe("Project name or id (defaults to active project)")
1130
641
  }
1131
642
  },
1132
- async ({ search, protocol, page: page2, pageSize, project }) => guard(async () => {
643
+ async ({ search, protocol, page, pageSize, project }) => guard(async () => {
1133
644
  const ctx = await resolveSimWorld(project);
1134
645
  const result = await sdk().simulators.list({
1135
646
  simWorldId: ctx.simWorldId,
1136
647
  searchValue: search,
1137
648
  filters: protocol ? { protocol } : void 0,
1138
- pagination: pagination(page2, pageSize)
649
+ pagination: pagination(page, pageSize)
1139
650
  });
1140
651
  return ok(
1141
652
  pageSummary("simulator(s)", result?.records?.length ?? 0, result?.pageInfo),
@@ -1172,12 +683,12 @@ function registerDataTools(server) {
1172
683
  project: z4.string().optional().describe("Project name or id (defaults to active project)")
1173
684
  }
1174
685
  },
1175
- async ({ search, page: page2, pageSize, project }) => guard(async () => {
686
+ async ({ search, page, pageSize, project }) => guard(async () => {
1176
687
  const ctx = await resolveSimWorld(project);
1177
688
  const result = await sdk().hardware.list({
1178
689
  deviceScopeId: ctx.deviceScopeId,
1179
690
  searchValue: search,
1180
- pagination: pagination(page2, pageSize)
691
+ pagination: pagination(page, pageSize)
1181
692
  });
1182
693
  return ok(
1183
694
  pageSummary("hardware record(s)", result?.records?.length ?? 0, result?.pageInfo),
@@ -1214,13 +725,13 @@ function registerDataTools(server) {
1214
725
  project: z4.string().optional().describe("Project name or id (defaults to active project)")
1215
726
  }
1216
727
  },
1217
- async ({ protocol, search, page: page2, pageSize, project }) => guard(async () => {
728
+ async ({ protocol, search, page, pageSize, project }) => guard(async () => {
1218
729
  const ctx = await resolveSimWorld(project);
1219
730
  const result = await sdk().actions.list({
1220
731
  simWorldId: ctx.simWorldId,
1221
732
  searchValue: search,
1222
733
  filters: { protocol },
1223
- pagination: pagination(page2, pageSize, 100)
734
+ pagination: pagination(page, pageSize, 100)
1224
735
  });
1225
736
  const actions = (result?.records ?? []).filter((a) => a != null).map(summarizeAction);
1226
737
  return ok(pageSummary("step(s)", actions.length, result?.pageInfo), actions);
@@ -1424,12 +935,12 @@ function registerSimulatorTools(server) {
1424
935
  });
1425
936
  for (let attempt = 0; !simulator && attempt < CREATE_POLL_ATTEMPTS; attempt++) {
1426
937
  await new Promise((resolve) => setTimeout(resolve, CREATE_POLL_DELAY_MS));
1427
- const page2 = await sdk().simulators.list({
938
+ const page = await sdk().simulators.list({
1428
939
  simWorldId: ctx.simWorldId,
1429
940
  searchValue: baseName,
1430
941
  filters: { endpointIds: [endpointId] }
1431
942
  });
1432
- simulator = page2?.records?.find((record) => record?.baseName === baseName);
943
+ simulator = page?.records?.find((record) => record?.baseName === baseName);
1433
944
  }
1434
945
  if (!simulator) {
1435
946
  return ok(
@@ -1686,13 +1197,13 @@ function registerTestTools(server) {
1686
1197
  project: projectArg
1687
1198
  }
1688
1199
  },
1689
- async ({ protocol, search, page: page2, pageSize, project }) => guard(async () => {
1200
+ async ({ protocol, search, page, pageSize, project }) => guard(async () => {
1690
1201
  const ctx = await resolveSimWorld(project);
1691
1202
  const result = await sdk().tests.list({
1692
1203
  simWorldId: ctx.simWorldId,
1693
1204
  searchValue: search,
1694
1205
  filters: protocol ? { protocol } : void 0,
1695
- pagination: pagination(page2, pageSize)
1206
+ pagination: pagination(page, pageSize)
1696
1207
  });
1697
1208
  return ok(pageSummary("test(s)", result?.records?.length ?? 0, result?.pageInfo), result);
1698
1209
  })
@@ -1733,13 +1244,13 @@ function registerTestTools(server) {
1733
1244
  project: projectArg
1734
1245
  }
1735
1246
  },
1736
- async ({ protocol, search, page: page2, pageSize, project }) => guard(async () => {
1247
+ async ({ protocol, search, page, pageSize, project }) => guard(async () => {
1737
1248
  const ctx = await resolveSimWorld(project);
1738
1249
  const result = await sdk().tests.listTemplates({
1739
1250
  simWorldId: ctx.simWorldId,
1740
1251
  searchValue: search,
1741
1252
  filters: protocol ? { protocol } : void 0,
1742
- pagination: pagination(page2, pageSize)
1253
+ pagination: pagination(page, pageSize)
1743
1254
  });
1744
1255
  return ok(pageSummary("template(s)", result?.records?.length ?? 0, result?.pageInfo), result);
1745
1256
  })
@@ -1891,12 +1402,12 @@ var SIMULATOR_PAGE_SIZE = 200;
1891
1402
  var FINISHED_STATUSES = /* @__PURE__ */ new Set(["COMPLETED", "FAILED", "SKIPPED"]);
1892
1403
  async function listSimulatorIds(simWorldId, selection) {
1893
1404
  const ids = [];
1894
- for (let page2 = 1; ; page2++) {
1405
+ for (let page = 1; ; page++) {
1895
1406
  const result = await sdk().simulators.list({
1896
1407
  simWorldId,
1897
1408
  searchValue: selection.search,
1898
1409
  filters: { protocol: selection.protocol, tagIds: selection.tagIds },
1899
- pagination: { page: page2, pageSize: SIMULATOR_PAGE_SIZE }
1410
+ pagination: { page, pageSize: SIMULATOR_PAGE_SIZE }
1900
1411
  });
1901
1412
  const records = result?.records ?? [];
1902
1413
  for (const record of records) if (record?.id) ids.push(record.id);
@@ -2039,11 +1550,11 @@ function registerTestRunTools(server) {
2039
1550
  project: projectArg
2040
1551
  }
2041
1552
  },
2042
- async ({ page: page2, pageSize, project }) => guard(async () => {
1553
+ async ({ page, pageSize, project }) => guard(async () => {
2043
1554
  const ctx = await resolveSimWorld(project);
2044
1555
  const result = await sdk().testRuns.list({
2045
1556
  simWorldId: ctx.simWorldId,
2046
- pagination: pagination(page2, pageSize)
1557
+ pagination: pagination(page, pageSize)
2047
1558
  });
2048
1559
  return ok(pageSummary("test run(s)", result?.records?.length ?? 0, result?.pageInfo), {
2049
1560
  ...result,
@@ -2153,12 +1664,12 @@ function registerTestRunTools(server) {
2153
1664
  project: projectArg
2154
1665
  }
2155
1666
  },
2156
- async ({ testId, page: page2, pageSize, project }) => guard(async () => {
1667
+ async ({ testId, page, pageSize, project }) => guard(async () => {
2157
1668
  const ctx = await resolveSimWorld(project);
2158
1669
  const result = await sdk().testRuns.listGrouped({
2159
1670
  simWorldId: ctx.simWorldId,
2160
1671
  filters: { categories: ["STRESS_TEST"], ...testId ? { scenarioIds: [testId] } : {} },
2161
- pagination: pagination(page2, pageSize)
1672
+ pagination: pagination(page, pageSize)
2162
1673
  });
2163
1674
  return ok(pageSummary("stress test(s)", result?.records?.length ?? 0, result?.pageInfo), result);
2164
1675
  })
@@ -2281,11 +1792,11 @@ function registerTestPlanTools(server) {
2281
1792
  project: projectArg
2282
1793
  }
2283
1794
  },
2284
- async ({ search, page: page2, pageSize, project }) => guard(async () => {
1795
+ async ({ search, page, pageSize, project }) => guard(async () => {
2285
1796
  const ctx = await resolveSimWorld(project);
2286
1797
  const result = await sdk().testPlans.list({
2287
1798
  filters: { simWorldId: ctx.simWorldId, name: search },
2288
- pagination: pagination(page2, pageSize)
1799
+ pagination: pagination(page, pageSize)
2289
1800
  });
2290
1801
  return ok(
2291
1802
  pageSummary("test plan(s)", result?.records?.length ?? 0, result?.pageInfo),
@@ -2560,11 +2071,11 @@ function findRunSimulator(records, simulator) {
2560
2071
  (rc) => rc.charger?.baseName?.toLowerCase() === needle || rc.device?.serialNumber?.toLowerCase() === needle
2561
2072
  );
2562
2073
  }
2563
- function summarizeRun(run, simulators, page2, steps) {
2074
+ function summarizeRun(run, simulators, page, steps) {
2564
2075
  const records = (simulators?.records ?? []).filter(Boolean);
2565
2076
  const lines = [
2566
2077
  `Test run ${run.id} ("${run.scenario?.name ?? "?"}"): ${run.status}` + (run.failReason ? ` \u2014 ${run.failReason}` : "") + `. Started ${run.realStartTimestamp ?? run.startTimestamp ?? "\u2014"}, stopped ${run.realStopTimestamp ?? "\u2014"}.`,
2567
- pageSummary("simulator(s)", records.length, simulators?.pageInfo ?? { page: page2 })
2078
+ pageSummary("simulator(s)", records.length, simulators?.pageInfo ?? { page })
2568
2079
  ];
2569
2080
  for (const rc of records) {
2570
2081
  const target = rc.charger?.id ? `simulator ${rc.charger.id}` : `hardware ${rc.device?.id ?? "?"}`;
@@ -2656,10 +2167,10 @@ function registerSupportTools(server) {
2656
2167
  },
2657
2168
  annotations: READ_ONLY
2658
2169
  },
2659
- async ({ runId, page: page2, simulator }) => guard(async () => {
2170
+ async ({ runId, page, simulator }) => guard(async () => {
2660
2171
  const run = await sdk().testRuns.getDetails(runId);
2661
2172
  if (!run) return ok(`No test run found with id ${runId}.`);
2662
- const current = page2 ?? 1;
2173
+ const current = page ?? 1;
2663
2174
  const simulators = await sdk().testRuns.listSimulators(runId, {
2664
2175
  pagination: { page: current, pageSize: RUN_SIMULATORS_PAGE_SIZE }
2665
2176
  });
@@ -2703,7 +2214,7 @@ function registerApiKeyTools(server) {
2703
2214
  {
2704
2215
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2705
2216
  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.",
2217
+ 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 this key.",
2707
2218
  inputSchema: {
2708
2219
  name: z10.string().describe("Key name, e.g. 'CI pipeline'"),
2709
2220
  expiresAt: z10.string().optional().describe("Optional expiration date (ISO 8601)")
@@ -2728,15 +2239,9 @@ function registerApiKeyTools(server) {
2728
2239
  }
2729
2240
  },
2730
2241
  async ({ keyId }) => guard(async () => {
2731
- const stored = peekCredentials();
2732
2242
  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
2243
  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.`,
2244
+ `Rotated API key (id: ${key?.apiKey?.id}). The previous secret no longer works. New secret shown once below \u2014 save it now. If this is the key in TIMETICK_API_TOKEN, update the client config and restart the client.`,
2740
2245
  { secretKey: key?.secretKey }
2741
2246
  );
2742
2247
  })
@@ -2752,12 +2257,9 @@ function registerApiKeyTools(server) {
2752
2257
  }
2753
2258
  },
2754
2259
  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
2260
  const status = await sdk().apiKeys.delete(keyId);
2759
2261
  if (status === "ERROR") return fail(`API key ${keyId} could not be deleted.`);
2760
- return ok(`Deleted API key ${keyId}.`);
2262
+ return ok(`Deleted API key ${keyId}. If this is the key in TIMETICK_API_TOKEN, update the client config and restart the client.`);
2761
2263
  })
2762
2264
  );
2763
2265
  }
@@ -2946,12 +2448,12 @@ function registerCatalogTools(server) {
2946
2448
  project: projectArg
2947
2449
  }
2948
2450
  },
2949
- async ({ search, page: page2, pageSize, project }) => guard(async () => {
2451
+ async ({ search, page, pageSize, project }) => guard(async () => {
2950
2452
  const ctx = await resolveSimWorld(project);
2951
2453
  const result = await sdk().manufacturers.list({
2952
2454
  simWorldId: ctx.simWorldId,
2953
2455
  searchValue: search,
2954
- pagination: pagination(page2, pageSize)
2456
+ pagination: pagination(page, pageSize)
2955
2457
  });
2956
2458
  return ok(
2957
2459
  pageSummary("manufacturer(s)", result?.records?.length ?? 0, result?.pageInfo),
@@ -3037,13 +2539,13 @@ function registerCatalogTools(server) {
3037
2539
  project: projectArg
3038
2540
  }
3039
2541
  },
3040
- async ({ search, protocol, page: page2, pageSize, project }) => guard(async () => {
2542
+ async ({ search, protocol, page, pageSize, project }) => guard(async () => {
3041
2543
  const ctx = await resolveSimWorld(project);
3042
2544
  const result = await sdk().models.list({
3043
2545
  simWorldId: ctx.simWorldId,
3044
2546
  searchValue: search,
3045
2547
  filters: protocol ? { protocol } : void 0,
3046
- pagination: pagination(page2, pageSize)
2548
+ pagination: pagination(page, pageSize)
3047
2549
  });
3048
2550
  return ok(pageSummary("model(s)", result?.records?.length ?? 0, result?.pageInfo), result);
3049
2551
  })
@@ -3151,13 +2653,13 @@ function registerCatalogTools(server) {
3151
2653
  project: projectArg
3152
2654
  }
3153
2655
  },
3154
- async ({ search, protocol, page: page2, pageSize, project }) => guard(async () => {
2656
+ async ({ search, protocol, page, pageSize, project }) => guard(async () => {
3155
2657
  const ctx = await resolveSimWorld(project);
3156
2658
  const result = await sdk().endpoints.list({
3157
2659
  simWorldId: ctx.simWorldId,
3158
2660
  searchValue: search,
3159
2661
  filters: protocol ? { protocol } : void 0,
3160
- pagination: pagination(page2, pageSize)
2662
+ pagination: pagination(page, pageSize)
3161
2663
  });
3162
2664
  return ok(pageSummary("endpoint(s)", result?.records?.length ?? 0, result?.pageInfo), result);
3163
2665
  })
@@ -3293,12 +2795,12 @@ function registerOcppConfigTools(server) {
3293
2795
  project: projectArg
3294
2796
  }
3295
2797
  },
3296
- async ({ search, page: page2, pageSize, project }) => guard(async () => {
2798
+ async ({ search, page, pageSize, project }) => guard(async () => {
3297
2799
  const ctx = await resolveSimWorld(project);
3298
2800
  const result = await sdk().ocppConfigs.list({
3299
2801
  simWorldId: ctx.simWorldId,
3300
2802
  searchValue: search,
3301
- pagination: pagination(page2, pageSize)
2803
+ pagination: pagination(page, pageSize)
3302
2804
  });
3303
2805
  return ok(
3304
2806
  pageSummary("OCPP config(s)", result?.records?.length ?? 0, result?.pageInfo),
@@ -3414,12 +2916,12 @@ function registerSimulatorConfigTools(server) {
3414
2916
  project: projectArg
3415
2917
  }
3416
2918
  },
3417
- async ({ search, page: page2, pageSize, project }) => guard(async () => {
2919
+ async ({ search, page, pageSize, project }) => guard(async () => {
3418
2920
  const ctx = await resolveSimWorld(project);
3419
2921
  const result = await sdk().simulatorConfigs.list({
3420
2922
  simWorldId: ctx.simWorldId,
3421
2923
  searchValue: search,
3422
- pagination: pagination(page2, pageSize)
2924
+ pagination: pagination(page, pageSize)
3423
2925
  });
3424
2926
  return ok(
3425
2927
  pageSummary("simulator config(s)", result?.records?.length ?? 0, result?.pageInfo),
@@ -3549,12 +3051,12 @@ function registerTagTools(server) {
3549
3051
  project: projectArg
3550
3052
  }
3551
3053
  },
3552
- async ({ search, page: page2, pageSize, project }) => guard(async () => {
3054
+ async ({ search, page, pageSize, project }) => guard(async () => {
3553
3055
  const ctx = await resolveSimWorld(project);
3554
3056
  const result = await sdk().tags.list({
3555
3057
  projectId: ctx.projectId,
3556
3058
  searchValue: search,
3557
- pagination: pagination(page2, pageSize)
3059
+ pagination: pagination(page, pageSize)
3558
3060
  });
3559
3061
  return ok(pageSummary("tag(s)", result?.records?.length ?? 0, result?.pageInfo), result);
3560
3062
  })
@@ -3708,7 +3210,6 @@ function registerTools(server, options) {
3708
3210
  // src/index.ts
3709
3211
  async function main() {
3710
3212
  allowSlowConnections();
3711
- const loggedIn = await loadCredentials();
3712
3213
  const readOnly = isReadOnlyMode();
3713
3214
  const server = new McpServer(
3714
3215
  { name: "timetick", version: VERSION },
@@ -3718,7 +3219,7 @@ async function main() {
3718
3219
  const transport = new StdioServerTransport();
3719
3220
  await server.connect(transport);
3720
3221
  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"}`
3222
+ `Timetick MCP server ${VERSION} running on stdio${readOnly ? " (read-only)" : ""}${process.env.TIMETICK_API_TOKEN ? "" : ": no API key, set TIMETICK_API_TOKEN"}`
3722
3223
  );
3723
3224
  void updateNotice().then((notice) => {
3724
3225
  if (notice) console.error(`Timetick MCP: ${notice}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timetick/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Model Context Protocol (MCP) server for Timetick (https://app.timetick.io) platform",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Timetick <support@timetick.io>",