@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.
- package/README.md +10 -15
- package/dist/index.js +159 -658
- 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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
29
|
+
import { TimetickError as TimetickError2 } from "@timetick/sdk";
|
|
73
30
|
|
|
74
|
-
// src/
|
|
75
|
-
import {
|
|
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/
|
|
80
|
-
|
|
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(/&/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
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
email
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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 };
|
|
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/
|
|
668
|
-
import {
|
|
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
|
|
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 (
|
|
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
|
-
|
|
754
|
-
return
|
|
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
|
-
|
|
759
|
-
return
|
|
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
|
-
|
|
428
|
+
cached = null;
|
|
767
429
|
}
|
|
768
430
|
function peekContext() {
|
|
769
|
-
return
|
|
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(/&/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
|
|
492
|
+
for (let page = 1; ; page++) {
|
|
982
493
|
const result = await sdk().simulators.list({
|
|
983
494
|
simWorldId,
|
|
984
495
|
searchValue: simulator,
|
|
985
|
-
pagination: { page
|
|
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
|
|
1027
|
-
const 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
|
|
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 (
|
|
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(
|
|
1083
|
-
return { page:
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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)" : ""}${
|
|
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