@togglhq/mcp 1.7.2 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/build/cli.js +184 -2
- package/build/index.js +1 -1
- package/build/{src-BSqkikrb.js → src-Dh4qJKIU.js} +287 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -94,6 +94,18 @@ One tool per domain (for example `tasks`, `projects`, `time-blocks`, `time-entri
|
|
|
94
94
|
|
|
95
95
|
Agent-oriented guidance ships in `skills/toggl-mcp/SKILL.md` inside this package.
|
|
96
96
|
|
|
97
|
+
## Privacy & analytics
|
|
98
|
+
|
|
99
|
+
Public builds may send product analytics: tool name, entity-tool `action` (for example `list`), outcome (`success`, `error`, or `confirmation_required`), client, session id and package version. Tool arguments and results are never collected. Signed-in sessions are identified by your Toggl account id so repeat use is not counted as a new person each time; unauthenticated processes stay anonymous.
|
|
100
|
+
|
|
101
|
+
Disable product analytics:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
TOGGL_PRODUCT_ANALYTICS=off
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Optional error reporting via Sentry is off unless you set `TOGGL_SENTRY=1` and `SENTRY_DSN`.
|
|
108
|
+
|
|
97
109
|
## License
|
|
98
110
|
|
|
99
111
|
Proprietary Toggl software. See [LICENSE](LICENSE) and [Toggl legal terms](https://toggl.com/legal/).
|
package/build/cli.js
CHANGED
|
@@ -1,7 +1,189 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as runMcpCli } from "./src-
|
|
2
|
+
import { a as initProductAnalytics, c as shutdownProductAnalytics, i as getProductAnalyticsDistinctId, n as runMcpCli, o as redactUnsafeSentryMessages, r as captureToolInvocation, s as resolveProductAnalyticsDistinctId } from "./src-Dh4qJKIU.js";
|
|
3
|
+
import * as Sentry from "@sentry/node";
|
|
4
|
+
//#region src/analytics-tool-metadata.ts
|
|
5
|
+
/**
|
|
6
|
+
* Every entity tool takes `{ action, data, … }`, and `action` is a picklist of
|
|
7
|
+
* catalog identifiers, so it is the one argument that is safe to record.
|
|
8
|
+
*/
|
|
9
|
+
function readToolAction(handlerArgs) {
|
|
10
|
+
const params = handlerArgs[0];
|
|
11
|
+
if (!params || typeof params !== "object") return null;
|
|
12
|
+
const action = params.action;
|
|
13
|
+
return typeof action === "string" ? action : null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read the first text block of a non-error result as JSON.
|
|
17
|
+
*
|
|
18
|
+
* Handshake payloads are the awkward case for classification: they are shaped
|
|
19
|
+
* like successes — plain text content, no `isError` — so the only way to tell
|
|
20
|
+
* them apart is the object they carry. Nothing read here is retained.
|
|
21
|
+
*/
|
|
22
|
+
function firstTextPayload(result, marker) {
|
|
23
|
+
if (!result || typeof result !== "object") return null;
|
|
24
|
+
if (result.isError === true) return null;
|
|
25
|
+
const content = result.content;
|
|
26
|
+
if (!Array.isArray(content)) return null;
|
|
27
|
+
const first = content[0];
|
|
28
|
+
if (first?.type !== "text" || typeof first.text !== "string") return null;
|
|
29
|
+
if (!first.text.includes(marker)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const payload = JSON.parse(first.text);
|
|
32
|
+
return payload && typeof payload === "object" ? payload : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The confirm-gate response is a handshake, not an outcome: it carries no
|
|
39
|
+
* `isError`, so it used to be recorded as a success and the confirmed write was
|
|
40
|
+
* recorded as a second success for the same logical mutation.
|
|
41
|
+
*
|
|
42
|
+
* Detected from the payload `withMutationApproval` builds (`confirm_required`
|
|
43
|
+
* plus a `confirm_token`) rather than from the gate internals, so it keeps
|
|
44
|
+
* working when tool registration moves out of this package.
|
|
45
|
+
*/
|
|
46
|
+
function isConfirmationGateResult(result) {
|
|
47
|
+
const payload = firstTextPayload(result, "\"confirm_required\"");
|
|
48
|
+
return payload?.confirm_required === true && typeof payload.confirm_token === "string";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `auth` on a multi-workspace account is the same handshake in different clothes:
|
|
52
|
+
* it returns `{ authenticated: false, requires_workspace_selection: true }` with
|
|
53
|
+
* no `isError`, and the caller has to call `auth` again with `workspace_id`.
|
|
54
|
+
* Counting the first leg as a success double-counts one login — and, because
|
|
55
|
+
* `auth` refreshes identity on success, would report an identity change that had
|
|
56
|
+
* not happened yet.
|
|
57
|
+
*/
|
|
58
|
+
function isAuthWorkspaceSelectionResult(result) {
|
|
59
|
+
const payload = firstTextPayload(result, "\"requires_workspace_selection\"");
|
|
60
|
+
return payload?.requires_workspace_selection === true && payload.authenticated === false;
|
|
61
|
+
}
|
|
62
|
+
function classifyToolResult(result) {
|
|
63
|
+
if (isConfirmationGateResult(result)) return "confirmation_required";
|
|
64
|
+
if (isAuthWorkspaceSelectionResult(result)) return "confirmation_required";
|
|
65
|
+
return result?.isError === true ? "error" : "success";
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/public-analytics.ts
|
|
69
|
+
function envEnabled(raw) {
|
|
70
|
+
if (raw === void 0) return false;
|
|
71
|
+
const normalized = raw.trim().toLowerCase();
|
|
72
|
+
return normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes";
|
|
73
|
+
}
|
|
74
|
+
function initPublicSentry(environment) {
|
|
75
|
+
if (!envEnabled(process.env.TOGGL_SENTRY)) return false;
|
|
76
|
+
const dsn = process.env.SENTRY_DSN;
|
|
77
|
+
if (!dsn || dsn === "off") {
|
|
78
|
+
console.error("[Analytics] TOGGL_SENTRY is set but SENTRY_DSN is missing; Sentry left disabled");
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
Sentry.init({
|
|
82
|
+
dsn,
|
|
83
|
+
environment: environment ?? "production",
|
|
84
|
+
tracesSampleRate: .2,
|
|
85
|
+
enableLogs: false,
|
|
86
|
+
sendDefaultPii: false,
|
|
87
|
+
beforeSend: (event) => redactUnsafeSentryMessages(event)
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
function wrapPublicServerWithSentry(server) {
|
|
92
|
+
return Sentry.wrapMcpServerWithSentry(server, {
|
|
93
|
+
recordInputs: false,
|
|
94
|
+
recordOutputs: false
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function tagPublicSentrySession() {
|
|
98
|
+
Sentry.setTag("mcp.session.id", crypto.randomUUID());
|
|
99
|
+
}
|
|
100
|
+
async function shutdownPublicSentry() {
|
|
101
|
+
await Sentry.close(2e3);
|
|
102
|
+
}
|
|
103
|
+
function initMcpProductAnalytics(context) {
|
|
104
|
+
return initProductAnalytics({
|
|
105
|
+
...context,
|
|
106
|
+
client: "mcp"
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function flushPublicAnalytics(sentryEnabled) {
|
|
110
|
+
const tasks = [shutdownProductAnalytics()];
|
|
111
|
+
if (sentryEnabled) tasks.push(shutdownPublicSentry());
|
|
112
|
+
await Promise.allSettled(tasks);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Tools that replace the active MCP credential row on success, so their event
|
|
116
|
+
* belongs to the profile they leave behind. `profile-remove` is included because
|
|
117
|
+
* removing the active profile promotes the next one, and attributing the event to
|
|
118
|
+
* the profile just deleted files it under an identity that no longer exists.
|
|
119
|
+
*
|
|
120
|
+
* `logout` is deliberately absent: it only ever clears, so it is always the
|
|
121
|
+
* account that signed out.
|
|
122
|
+
*/
|
|
123
|
+
const IDENTITY_CHANGING_MCP_TOOLS = new Set([
|
|
124
|
+
"auth",
|
|
125
|
+
"profile-switch",
|
|
126
|
+
"profile-remove"
|
|
127
|
+
]);
|
|
128
|
+
function wrapServerWithProductAnalytics(server) {
|
|
129
|
+
const originalRegisterTool = server.registerTool.bind(server);
|
|
130
|
+
server.registerTool = new Proxy(originalRegisterTool, { apply(target, thisArg, argArray) {
|
|
131
|
+
const [name, config, toolHandlerCandidate] = argArray;
|
|
132
|
+
if (typeof toolHandlerCandidate !== "function") return Reflect.apply(target, thisArg, argArray);
|
|
133
|
+
const toolHandler = toolHandlerCandidate;
|
|
134
|
+
const wrappedHandler = async (...handlerArgs) => {
|
|
135
|
+
const action = readToolAction(handlerArgs);
|
|
136
|
+
let distinctId = resolveProductAnalyticsDistinctId("mcp") ?? getProductAnalyticsDistinctId();
|
|
137
|
+
let outcome = "error";
|
|
138
|
+
let result;
|
|
139
|
+
let thrown;
|
|
140
|
+
try {
|
|
141
|
+
result = await toolHandler(...handlerArgs);
|
|
142
|
+
outcome = classifyToolResult(result);
|
|
143
|
+
if (IDENTITY_CHANGING_MCP_TOOLS.has(name) && outcome === "success") distinctId = resolveProductAnalyticsDistinctId("mcp") ?? distinctId;
|
|
144
|
+
return result;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
thrown = error;
|
|
147
|
+
outcome = "error";
|
|
148
|
+
throw error;
|
|
149
|
+
} finally {
|
|
150
|
+
captureToolInvocation({
|
|
151
|
+
toolName: name,
|
|
152
|
+
action,
|
|
153
|
+
outcome,
|
|
154
|
+
distinctId,
|
|
155
|
+
error: outcome === "error" ? thrown ?? result : void 0
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
return Reflect.apply(target, thisArg, [
|
|
160
|
+
name,
|
|
161
|
+
config,
|
|
162
|
+
wrappedHandler
|
|
163
|
+
]);
|
|
164
|
+
} });
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
3
167
|
//#region src/cli.ts
|
|
4
|
-
|
|
168
|
+
const packageVersion = "1.8.1";
|
|
169
|
+
let sentryEnabled = false;
|
|
170
|
+
let productAnalyticsEnabled = false;
|
|
171
|
+
runMcpCli({
|
|
172
|
+
beforeRegister(server) {
|
|
173
|
+
productAnalyticsEnabled = initMcpProductAnalytics({ packageVersion });
|
|
174
|
+
sentryEnabled = initPublicSentry(process.env.NODE_ENV);
|
|
175
|
+
let next = server;
|
|
176
|
+
if (sentryEnabled) next = wrapPublicServerWithSentry(next);
|
|
177
|
+
if (productAnalyticsEnabled) wrapServerWithProductAnalytics(next);
|
|
178
|
+
return next;
|
|
179
|
+
},
|
|
180
|
+
beforeConnect() {
|
|
181
|
+
if (sentryEnabled) tagPublicSentrySession();
|
|
182
|
+
},
|
|
183
|
+
async beforeExit() {
|
|
184
|
+
if (sentryEnabled || productAnalyticsEnabled) await flushPublicAnalytics(sentryEnabled);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
5
187
|
//#endregion
|
|
6
188
|
export {};
|
|
7
189
|
|
package/build/index.js
CHANGED
|
@@ -31111,6 +31111,14 @@ async function deleteMyWorkingHours(httpClient, organization_id, data, signal) {
|
|
|
31111
31111
|
if ([204, 205].includes(response.status)) return null;
|
|
31112
31112
|
return response.json();
|
|
31113
31113
|
}
|
|
31114
|
+
//#endregion
|
|
31115
|
+
//#region ../cli-core/build/focus-client-B2l1BZIW.js
|
|
31116
|
+
/** Request header the API reads to attribute traffic to MCP or CLI. */
|
|
31117
|
+
const TOGGL_CLIENT_HEADER = "X-Toggl-Client";
|
|
31118
|
+
/** Headers that attribute a raw `fetch` to MCP or CLI (FocusClient sets these itself). */
|
|
31119
|
+
function togglClientHeaders(clientSurface) {
|
|
31120
|
+
return { [TOGGL_CLIENT_HEADER]: clientSurface };
|
|
31121
|
+
}
|
|
31114
31122
|
const customFieldValuesEntry = /* @__PURE__ */ optional$1(/* @__PURE__ */ nullable$1(/* @__PURE__ */ pipe$2(/* @__PURE__ */ record$1(/* @__PURE__ */ string$1(), /* @__PURE__ */ nullable$1(/* @__PURE__ */ union$1([
|
|
31115
31123
|
/* @__PURE__ */ array$1(/* @__PURE__ */ number$2()),
|
|
31116
31124
|
/* @__PURE__ */ string$1(),
|
|
@@ -31288,6 +31296,7 @@ var FocusClient = class {
|
|
|
31288
31296
|
timeOffMeHttp;
|
|
31289
31297
|
sharedDataHttp;
|
|
31290
31298
|
tokenProvider;
|
|
31299
|
+
clientSurface;
|
|
31291
31300
|
userId;
|
|
31292
31301
|
organizationIdNum;
|
|
31293
31302
|
workspaceId;
|
|
@@ -31296,6 +31305,7 @@ var FocusClient = class {
|
|
|
31296
31305
|
let baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
31297
31306
|
if (!baseUrl.endsWith("/api")) baseUrl += "/api";
|
|
31298
31307
|
this.tokenProvider = config.tokenProvider;
|
|
31308
|
+
this.clientSurface = config.clientSurface;
|
|
31299
31309
|
this.userId = config.userId;
|
|
31300
31310
|
this.workspaceId = config.workspaceId;
|
|
31301
31311
|
const orgParsed = Number.parseInt(config.organizationId, 10);
|
|
@@ -31311,6 +31321,7 @@ var FocusClient = class {
|
|
|
31311
31321
|
request.headers.set("Authorization", `Bearer ${token}`);
|
|
31312
31322
|
if (!request.headers.get("Content-Type")?.startsWith("multipart/form-data")) request.headers.set("Content-Type", "application/json");
|
|
31313
31323
|
request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
|
|
31324
|
+
request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
|
|
31314
31325
|
}
|
|
31315
31326
|
});
|
|
31316
31327
|
const orgBase = accountsOrgApiBaseUrl(config.accountsApiUrl);
|
|
@@ -31322,6 +31333,7 @@ var FocusClient = class {
|
|
|
31322
31333
|
request.headers.set("Authorization", `Bearer ${token}`);
|
|
31323
31334
|
request.headers.set("Content-Type", "application/json");
|
|
31324
31335
|
request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
|
|
31336
|
+
request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
|
|
31325
31337
|
}
|
|
31326
31338
|
});
|
|
31327
31339
|
this.timeOffMeHttp = createHttpClient({
|
|
@@ -31333,6 +31345,7 @@ var FocusClient = class {
|
|
|
31333
31345
|
request.headers.set("Content-Type", "application/json");
|
|
31334
31346
|
request.headers.set("X-Toggl-Product", "focus");
|
|
31335
31347
|
request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
|
|
31348
|
+
request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
|
|
31336
31349
|
}
|
|
31337
31350
|
});
|
|
31338
31351
|
this.sharedDataHttp = createHttpClient({
|
|
@@ -31343,6 +31356,7 @@ var FocusClient = class {
|
|
|
31343
31356
|
request.headers.set("Authorization", `Bearer ${token}`);
|
|
31344
31357
|
request.headers.set("Content-Type", "application/json");
|
|
31345
31358
|
request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
|
|
31359
|
+
request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
|
|
31346
31360
|
}
|
|
31347
31361
|
});
|
|
31348
31362
|
}
|
|
@@ -32441,7 +32455,7 @@ function waitForOAuthCallback(port = TOGGL_OAUTH_CALLBACK_PORT, timeoutMs = TOGG
|
|
|
32441
32455
|
});
|
|
32442
32456
|
});
|
|
32443
32457
|
}
|
|
32444
|
-
async function exchangeAuthorizationCode(accountsApiUrl, code, verifier, redirectUri = togglOAuthRedirectUri(), clientId = TOGGL_OAUTH_CLIENT_ID) {
|
|
32458
|
+
async function exchangeAuthorizationCode(accountsApiUrl, code, verifier, clientSurface, redirectUri = togglOAuthRedirectUri(), clientId = TOGGL_OAUTH_CLIENT_ID) {
|
|
32445
32459
|
const params = new URLSearchParams({
|
|
32446
32460
|
grant_type: "authorization_code",
|
|
32447
32461
|
code,
|
|
@@ -32449,7 +32463,10 @@ async function exchangeAuthorizationCode(accountsApiUrl, code, verifier, redirec
|
|
|
32449
32463
|
client_id: clientId,
|
|
32450
32464
|
redirect_uri: redirectUri
|
|
32451
32465
|
});
|
|
32452
|
-
const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, {
|
|
32466
|
+
const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, {
|
|
32467
|
+
method: "POST",
|
|
32468
|
+
headers: togglClientHeaders(clientSurface)
|
|
32469
|
+
});
|
|
32453
32470
|
if (!response.ok) {
|
|
32454
32471
|
const text = await response.text();
|
|
32455
32472
|
throw new Error(`Token exchange failed (${response.status}): ${text}`);
|
|
@@ -32549,18 +32566,20 @@ var WorkspaceSelectionRequiredError$1 = class extends Error {
|
|
|
32549
32566
|
this.workspaces = workspaces;
|
|
32550
32567
|
}
|
|
32551
32568
|
};
|
|
32552
|
-
async function fetchAccountsApiMe(accountsApiUrl, token) {
|
|
32569
|
+
async function fetchAccountsApiMe(accountsApiUrl, token, clientSurface) {
|
|
32553
32570
|
const response = await fetch(`${accountsApiUrl}/api/me`, { headers: {
|
|
32554
32571
|
Authorization: `Bearer ${token}`,
|
|
32555
|
-
"Content-Type": "application/json"
|
|
32572
|
+
"Content-Type": "application/json",
|
|
32573
|
+
...togglClientHeaders(clientSurface)
|
|
32556
32574
|
} });
|
|
32557
32575
|
if (!response.ok) throw new Error(`Failed to fetch user info (${response.status})`);
|
|
32558
32576
|
return response.json();
|
|
32559
32577
|
}
|
|
32560
|
-
async function fetchAccessibleWorkspaces(accountsApiUrl, token) {
|
|
32578
|
+
async function fetchAccessibleWorkspaces(accountsApiUrl, token, clientSurface) {
|
|
32561
32579
|
const response = await fetch(`${accountsApiUrl}/org/api/organizations/me`, { headers: {
|
|
32562
32580
|
Authorization: `Bearer ${token}`,
|
|
32563
|
-
"Content-Type": "application/json"
|
|
32581
|
+
"Content-Type": "application/json",
|
|
32582
|
+
...togglClientHeaders(clientSurface)
|
|
32564
32583
|
} });
|
|
32565
32584
|
if (!response.ok) throw new Error(`Failed to fetch organizations (${response.status})`);
|
|
32566
32585
|
return (await response.json()).flatMap((org) => (org.workspaces ?? []).filter((ws) => (ws.toggl_products ?? []).includes("focus")).map((ws) => {
|
|
@@ -32604,13 +32623,16 @@ const PUBLIC_MCP_PACKAGE_NAME = "@togglhq/mcp";
|
|
|
32604
32623
|
function npxMcpAuthCommand() {
|
|
32605
32624
|
return `npx ${PUBLIC_MCP_PACKAGE_NAME} auth`;
|
|
32606
32625
|
}
|
|
32607
|
-
async function refreshTokens(accountsApiUrl, refreshToken, clientId = TOGGL_OAUTH_CLIENT_ID) {
|
|
32626
|
+
async function refreshTokens(accountsApiUrl, refreshToken, clientSurface, clientId = TOGGL_OAUTH_CLIENT_ID) {
|
|
32608
32627
|
const params = new URLSearchParams({
|
|
32609
32628
|
grant_type: "refresh_token",
|
|
32610
32629
|
refresh_token: refreshToken,
|
|
32611
32630
|
client_id: clientId
|
|
32612
32631
|
});
|
|
32613
|
-
const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, {
|
|
32632
|
+
const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, {
|
|
32633
|
+
method: "POST",
|
|
32634
|
+
headers: togglClientHeaders(clientSurface)
|
|
32635
|
+
});
|
|
32614
32636
|
if (!response.ok) {
|
|
32615
32637
|
const text = await response.text();
|
|
32616
32638
|
throw new Error(`Token refresh failed (${response.status}): ${text}`);
|
|
@@ -32622,7 +32644,7 @@ async function getValidToken$1(scope = "cli", profile) {
|
|
|
32622
32644
|
if (!authConfig) throw new Error(scope === "cli" ? "Not authenticated. Run `toggl auth` to sign in." : `Not authenticated. Run \`${npxMcpAuthCommand()}\` to sign in.`);
|
|
32623
32645
|
const now = Date.now() / 1e3;
|
|
32624
32646
|
if (authConfig.expires_at > now + 60) return authConfig.access_token;
|
|
32625
|
-
const tokens = await refreshTokens(authConfig.accounts_api_url, authConfig.refresh_token, authConfig.oauth_client_id ?? "000000");
|
|
32647
|
+
const tokens = await refreshTokens(authConfig.accounts_api_url, authConfig.refresh_token, scope, authConfig.oauth_client_id ?? "000000");
|
|
32626
32648
|
authConfig.access_token = tokens.access_token;
|
|
32627
32649
|
authConfig.refresh_token = tokens.refresh_token;
|
|
32628
32650
|
authConfig.expires_at = now + tokens.expires_in;
|
|
@@ -32650,7 +32672,7 @@ async function refreshMcpWorkspaces(profile) {
|
|
|
32650
32672
|
const token = await getValidToken$1("mcp", profile);
|
|
32651
32673
|
const dev = loadDeveloperToolsConfig();
|
|
32652
32674
|
const auth = dev.profiles[profileKey];
|
|
32653
|
-
auth.workspaces = await fetchAccessibleWorkspaces(auth.accounts_api_url, token);
|
|
32675
|
+
auth.workspaces = await fetchAccessibleWorkspaces(auth.accounts_api_url, token, "mcp");
|
|
32654
32676
|
dev.profiles[profileKey] = auth;
|
|
32655
32677
|
saveDeveloperToolsConfig(dev);
|
|
32656
32678
|
return listCachedMcpWorkspaces(profile);
|
|
@@ -32667,6 +32689,253 @@ function listCachedMcpWorkspaces(profile) {
|
|
|
32667
32689
|
active: workspace.workspace_id === auth.workspace_id
|
|
32668
32690
|
}));
|
|
32669
32691
|
}
|
|
32692
|
+
const CLI_GENERIC_ERROR_CATEGORY = "cli.command_error";
|
|
32693
|
+
const MCP_GENERIC_ERROR_CATEGORY = "mcp.tool_error";
|
|
32694
|
+
const ERROR_CATEGORY_SET = new Set([
|
|
32695
|
+
"cli.validation_error",
|
|
32696
|
+
"cli.api_error",
|
|
32697
|
+
CLI_GENERIC_ERROR_CATEGORY,
|
|
32698
|
+
"mcp.validation_error",
|
|
32699
|
+
"mcp.api_error",
|
|
32700
|
+
"mcp.auth_error",
|
|
32701
|
+
MCP_GENERIC_ERROR_CATEGORY
|
|
32702
|
+
]);
|
|
32703
|
+
function isDeclaredErrorCategory(value) {
|
|
32704
|
+
return typeof value === "string" && ERROR_CATEGORY_SET.has(value);
|
|
32705
|
+
}
|
|
32706
|
+
function firstToolResultText(source) {
|
|
32707
|
+
if (!source || typeof source !== "object") return null;
|
|
32708
|
+
const content = source.content;
|
|
32709
|
+
if (!Array.isArray(content)) return null;
|
|
32710
|
+
const first = content[0];
|
|
32711
|
+
if (first?.type === "text" && typeof first.text === "string") return first.text;
|
|
32712
|
+
return null;
|
|
32713
|
+
}
|
|
32714
|
+
function errorTextForClassification(source) {
|
|
32715
|
+
if (source instanceof Error) return source.message;
|
|
32716
|
+
if (typeof source === "string") return source;
|
|
32717
|
+
const toolText = firstToolResultText(source);
|
|
32718
|
+
if (toolText !== null) return toolText;
|
|
32719
|
+
return "";
|
|
32720
|
+
}
|
|
32721
|
+
function classifyCli(message) {
|
|
32722
|
+
if (message.startsWith("Invalid input:") || message.startsWith("Invalid input ")) return "cli.validation_error";
|
|
32723
|
+
if (/^\d{3}:\s/.test(message)) return "cli.api_error";
|
|
32724
|
+
return CLI_GENERIC_ERROR_CATEGORY;
|
|
32725
|
+
}
|
|
32726
|
+
function classifyMcp(message) {
|
|
32727
|
+
if (message.startsWith("Invalid input:") || message.startsWith("Invalid input ")) return "mcp.validation_error";
|
|
32728
|
+
if (message.startsWith("API Error (")) return "mcp.api_error";
|
|
32729
|
+
if (message.startsWith("Authentication failed:") || message.startsWith("Not authenticated.")) return "mcp.auth_error";
|
|
32730
|
+
return MCP_GENERIC_ERROR_CATEGORY;
|
|
32731
|
+
}
|
|
32732
|
+
function classifyErrorCategory(client, source) {
|
|
32733
|
+
const message = errorTextForClassification(source);
|
|
32734
|
+
if (isDeclaredErrorCategory(message) && message.startsWith(`${client}.`)) return message;
|
|
32735
|
+
return client === "cli" ? classifyCli(message) : classifyMcp(message);
|
|
32736
|
+
}
|
|
32737
|
+
/**
|
|
32738
|
+
* Exception messages are the one channel `sendDefaultPii: false` does not cover,
|
|
32739
|
+
* and this repo's errors put user data in them: validation failures embed the
|
|
32740
|
+
* rejected input (valibot renders `issue.received`) and API failures embed the
|
|
32741
|
+
* response body. Sentry also captures uncaught exceptions and `cause` chains
|
|
32742
|
+
* through its own global handlers, which bypass any call-site scrubbing, so the
|
|
32743
|
+
* last word has to be `beforeSend`.
|
|
32744
|
+
*
|
|
32745
|
+
* Shared between the CLI and the public MCP build so the two cannot drift.
|
|
32746
|
+
*/
|
|
32747
|
+
/**
|
|
32748
|
+
* Fixed categories are the seven declared `<surface>.<snake_case>` values, not
|
|
32749
|
+
* a `cli|mcp` prefix regex. A prefix or regex test would pass a caught message
|
|
32750
|
+
* like `cli.customer_name`. A real filename message such as
|
|
32751
|
+
* `cli.ts:12: unexpected token "secret"` would also begin with `cli.`.
|
|
32752
|
+
*/
|
|
32753
|
+
const REDACTED_SENTRY_MESSAGE = "redacted";
|
|
32754
|
+
function isSafeSentryMessage(value) {
|
|
32755
|
+
return isDeclaredErrorCategory(value);
|
|
32756
|
+
}
|
|
32757
|
+
/**
|
|
32758
|
+
* Replace every exception message that is not a known category, and any top-level
|
|
32759
|
+
* message. Stack frames are left alone: they carry file, function and line, never
|
|
32760
|
+
* payloads.
|
|
32761
|
+
*/
|
|
32762
|
+
function redactUnsafeSentryMessages(event) {
|
|
32763
|
+
if (event.message !== void 0 && !isSafeSentryMessage(event.message)) event.message = REDACTED_SENTRY_MESSAGE;
|
|
32764
|
+
for (const value of event.exception?.values ?? []) if (!isSafeSentryMessage(value.value)) value.value = REDACTED_SENTRY_MESSAGE;
|
|
32765
|
+
return event;
|
|
32766
|
+
}
|
|
32767
|
+
const DEFAULT_POSTHOG_HOST = "https://eu.i.posthog.com";
|
|
32768
|
+
/**
|
|
32769
|
+
* The shared Toggl **production** PostHog project — the same token the Track
|
|
32770
|
+
* webapp (`posthogApiKey.production`), the marketing site (`GATSBY_POSTHOG_ID`)
|
|
32771
|
+
* and the Focus mobile app (`EXPO_PUBLIC_POSTHOG_KEY`) send to. MCP/CLI events
|
|
32772
|
+
* therefore land beside product events for the same people, which is why the
|
|
32773
|
+
* `distinct_id` has to be the accounts nanoid and why `$lib` has to separate the
|
|
32774
|
+
* developer tools out again.
|
|
32775
|
+
*/
|
|
32776
|
+
const SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN = "phc_PiFIHfmVYQVICbsScOwEv7D9wBwoNvrUFZ1Fq9WxBrp";
|
|
32777
|
+
/**
|
|
32778
|
+
* PostHog splits usage and billing by `$lib`, so it has to name the sending
|
|
32779
|
+
* client rather than this module: sharing the Toggl production project with the
|
|
32780
|
+
* webapp, marketing site and mobile app means a single library value would leave
|
|
32781
|
+
* MCP and CLI volume indistinguishable in that breakdown.
|
|
32782
|
+
*/
|
|
32783
|
+
const POSTHOG_LIB_BY_CLIENT = {
|
|
32784
|
+
mcp: "MCP",
|
|
32785
|
+
cli: "CLI"
|
|
32786
|
+
};
|
|
32787
|
+
const TOOL_INVOCATION_EVENT = "toggl_tool_invocation";
|
|
32788
|
+
/** Bound collector I/O so analytics cannot hang a completed CLI/MCP exit path. */
|
|
32789
|
+
const CAPTURE_TIMEOUT_MS = 2e3;
|
|
32790
|
+
const FLUSH_TIMEOUT_MS = 2e3;
|
|
32791
|
+
/** Actions are catalog identifiers, so anything else is a caller bug, not data. */
|
|
32792
|
+
const ACTION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
32793
|
+
let enabled = false;
|
|
32794
|
+
let apiKey = null;
|
|
32795
|
+
let host = DEFAULT_POSTHOG_HOST;
|
|
32796
|
+
let sessionId = "";
|
|
32797
|
+
let distinctId = "";
|
|
32798
|
+
let client = "mcp";
|
|
32799
|
+
let packageVersion$1 = "0.0.0";
|
|
32800
|
+
const pending = /* @__PURE__ */ new Set();
|
|
32801
|
+
function envFlagDisabled(raw) {
|
|
32802
|
+
if (raw === void 0 || raw === "") return false;
|
|
32803
|
+
const normalized = raw.trim().toLowerCase();
|
|
32804
|
+
return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
|
|
32805
|
+
}
|
|
32806
|
+
function getProductAnalyticsDistinctId() {
|
|
32807
|
+
return distinctId;
|
|
32808
|
+
}
|
|
32809
|
+
/**
|
|
32810
|
+
* Initialize product analytics for a public MCP/CLI process.
|
|
32811
|
+
* No-ops when TOGGL_PRODUCT_ANALYTICS is 0/false/off, or when the API key is "off".
|
|
32812
|
+
*/
|
|
32813
|
+
function initProductAnalytics(context) {
|
|
32814
|
+
if (envFlagDisabled(process.env.TOGGL_PRODUCT_ANALYTICS)) {
|
|
32815
|
+
enabled = false;
|
|
32816
|
+
return false;
|
|
32817
|
+
}
|
|
32818
|
+
const key = (process.env.TOGGL_POSTHOG_API_KEY ?? SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN).trim();
|
|
32819
|
+
if (!key || key.toLowerCase() === "off") {
|
|
32820
|
+
enabled = false;
|
|
32821
|
+
return false;
|
|
32822
|
+
}
|
|
32823
|
+
apiKey = key;
|
|
32824
|
+
host = (process.env.TOGGL_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST).replace(/\/$/, "");
|
|
32825
|
+
sessionId = context.sessionId ?? crypto.randomUUID();
|
|
32826
|
+
distinctId = context.distinctId ?? `anon:${sessionId}`;
|
|
32827
|
+
client = context.client;
|
|
32828
|
+
packageVersion$1 = context.packageVersion;
|
|
32829
|
+
enabled = true;
|
|
32830
|
+
return true;
|
|
32831
|
+
}
|
|
32832
|
+
function normalizeAction(action) {
|
|
32833
|
+
if (typeof action !== "string") return null;
|
|
32834
|
+
const trimmed = action.trim();
|
|
32835
|
+
return ACTION_PATTERN.test(trimmed) ? trimmed : null;
|
|
32836
|
+
}
|
|
32837
|
+
/** Strict allowlist — never accept args/results. */
|
|
32838
|
+
function captureToolInvocation(event) {
|
|
32839
|
+
if (!enabled || !apiKey) return;
|
|
32840
|
+
const properties = {
|
|
32841
|
+
tool_name: event.toolName,
|
|
32842
|
+
action: normalizeAction(event.action),
|
|
32843
|
+
outcome: event.outcome,
|
|
32844
|
+
success: event.outcome === "success",
|
|
32845
|
+
client,
|
|
32846
|
+
session_id: sessionId,
|
|
32847
|
+
package_version: packageVersion$1
|
|
32848
|
+
};
|
|
32849
|
+
if (event.outcome === "error") properties.error_category = classifyErrorCategory(client, event.error);
|
|
32850
|
+
const task = sendCapture(TOOL_INVOCATION_EVENT, properties, event.distinctId).finally(() => {
|
|
32851
|
+
pending.delete(task);
|
|
32852
|
+
});
|
|
32853
|
+
pending.add(task);
|
|
32854
|
+
}
|
|
32855
|
+
async function sendCapture(event, properties, eventDistinctId) {
|
|
32856
|
+
if (!apiKey) return;
|
|
32857
|
+
const controller = new AbortController();
|
|
32858
|
+
const timer = setTimeout(() => controller.abort(), CAPTURE_TIMEOUT_MS);
|
|
32859
|
+
try {
|
|
32860
|
+
const response = await fetch(`${host}/i/v0/e/`, {
|
|
32861
|
+
method: "POST",
|
|
32862
|
+
headers: { "Content-Type": "application/json" },
|
|
32863
|
+
body: JSON.stringify({
|
|
32864
|
+
api_key: apiKey,
|
|
32865
|
+
event,
|
|
32866
|
+
distinct_id: eventDistinctId ?? distinctId,
|
|
32867
|
+
properties: {
|
|
32868
|
+
...properties,
|
|
32869
|
+
$lib: POSTHOG_LIB_BY_CLIENT[client]
|
|
32870
|
+
}
|
|
32871
|
+
}),
|
|
32872
|
+
signal: controller.signal
|
|
32873
|
+
});
|
|
32874
|
+
if (!response.ok) await response.text().catch(() => void 0);
|
|
32875
|
+
} catch {} finally {
|
|
32876
|
+
clearTimeout(timer);
|
|
32877
|
+
}
|
|
32878
|
+
}
|
|
32879
|
+
async function shutdownProductAnalytics() {
|
|
32880
|
+
if (pending.size === 0) {
|
|
32881
|
+
enabled = false;
|
|
32882
|
+
return;
|
|
32883
|
+
}
|
|
32884
|
+
const inflight = [...pending];
|
|
32885
|
+
let flushTimer;
|
|
32886
|
+
try {
|
|
32887
|
+
await Promise.race([Promise.allSettled(inflight), new Promise((resolve) => {
|
|
32888
|
+
flushTimer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
|
|
32889
|
+
})]);
|
|
32890
|
+
} finally {
|
|
32891
|
+
if (flushTimer !== void 0) clearTimeout(flushTimer);
|
|
32892
|
+
pending.clear();
|
|
32893
|
+
enabled = false;
|
|
32894
|
+
}
|
|
32895
|
+
}
|
|
32896
|
+
/**
|
|
32897
|
+
* Accounts mints the base62 nanoid as the JWT `sub`; it is the same value
|
|
32898
|
+
* accounts-be sends to PostHog as `distinct_id`, so it is short and URL-safe.
|
|
32899
|
+
*/
|
|
32900
|
+
const NANOID_PATTERN = /^[0-9A-Za-z_-]{6,64}$/;
|
|
32901
|
+
/**
|
|
32902
|
+
* Read `sub` out of a Toggl accounts access token without verifying it.
|
|
32903
|
+
*
|
|
32904
|
+
* Verification would need the accounts JWKS; the value is only used as an
|
|
32905
|
+
* analytics key, and a forged token would only mislabel the forger's own events.
|
|
32906
|
+
*/
|
|
32907
|
+
function decodeAccessTokenSubject(token) {
|
|
32908
|
+
if (!token) return null;
|
|
32909
|
+
const payloadSegment = token.split(".")[1];
|
|
32910
|
+
if (!payloadSegment) return null;
|
|
32911
|
+
try {
|
|
32912
|
+
const sub = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8")).sub;
|
|
32913
|
+
if (typeof sub !== "string" || !NANOID_PATTERN.test(sub)) return null;
|
|
32914
|
+
return sub;
|
|
32915
|
+
} catch {
|
|
32916
|
+
return null;
|
|
32917
|
+
}
|
|
32918
|
+
}
|
|
32919
|
+
/**
|
|
32920
|
+
* Stable PostHog identity for a signed-in MCP/CLI process.
|
|
32921
|
+
*
|
|
32922
|
+
* The nanoid comes from the persisted profile, so the same person keeps one
|
|
32923
|
+
* `distinct_id` across process restarts and across MCP and CLI — a per-process
|
|
32924
|
+
* UUID would make every invocation look like a new user and every funnel
|
|
32925
|
+
* one-step-deep.
|
|
32926
|
+
*
|
|
32927
|
+
* The profile's numeric `user_id` is `user_account_id`, which is deliberately
|
|
32928
|
+
* *not* used here: accounts-be, toggl_api and the Track webapp all identify a
|
|
32929
|
+
* person in PostHog by the nanoid, so a bigint key would orphan these events
|
|
32930
|
+
* from every other identity we already have for the same user.
|
|
32931
|
+
*/
|
|
32932
|
+
function resolveProductAnalyticsDistinctId(scope) {
|
|
32933
|
+
try {
|
|
32934
|
+
return decodeAccessTokenSubject(loadProfile(scope)?.access_token);
|
|
32935
|
+
} catch {
|
|
32936
|
+
return null;
|
|
32937
|
+
}
|
|
32938
|
+
}
|
|
32670
32939
|
//#endregion
|
|
32671
32940
|
//#region src/auth.ts
|
|
32672
32941
|
var WorkspaceSelectionRequiredError = class extends Error {
|
|
@@ -32723,11 +32992,11 @@ async function runAuthFlow(accountsApiUrl = "https://accounts.toggl.com", focusA
|
|
|
32723
32992
|
log
|
|
32724
32993
|
});
|
|
32725
32994
|
log("Exchanging authorization code...");
|
|
32726
|
-
const tokens = await exchangeAuthorizationCode(accountsApiUrl, code, verifier, redirectUri, clientId);
|
|
32995
|
+
const tokens = await exchangeAuthorizationCode(accountsApiUrl, code, verifier, "mcp", redirectUri, clientId);
|
|
32727
32996
|
log("Fetching account info...");
|
|
32728
|
-
const user = await fetchAccountsApiMe(accountsApiUrl, tokens.access_token);
|
|
32997
|
+
const user = await fetchAccountsApiMe(accountsApiUrl, tokens.access_token, "mcp");
|
|
32729
32998
|
if (!user.user_account_id) throw new Error("Could not determine user ID from API response.");
|
|
32730
|
-
const workspaces = await fetchAccessibleWorkspaces(accountsApiUrl, tokens.access_token);
|
|
32999
|
+
const workspaces = await fetchAccessibleWorkspaces(accountsApiUrl, tokens.access_token, "mcp");
|
|
32731
33000
|
pendingContext = {
|
|
32732
33001
|
access_token: tokens.access_token,
|
|
32733
33002
|
refresh_token: tokens.refresh_token,
|
|
@@ -32825,7 +33094,8 @@ function createAuthenticatedClientProxy() {
|
|
|
32825
33094
|
tokenProvider: getValidToken,
|
|
32826
33095
|
userId: config.user_id,
|
|
32827
33096
|
organizationId: String(config.organization_id),
|
|
32828
|
-
workspaceId: String(config.workspace_id)
|
|
33097
|
+
workspaceId: String(config.workspace_id),
|
|
33098
|
+
clientSurface: "mcp"
|
|
32829
33099
|
});
|
|
32830
33100
|
cachedKey = key;
|
|
32831
33101
|
return cachedClient;
|
|
@@ -45216,7 +45486,7 @@ import_main.default.config({
|
|
|
45216
45486
|
quiet: true,
|
|
45217
45487
|
ignore: ["MISSING_ENV_FILE"]
|
|
45218
45488
|
});
|
|
45219
|
-
const packageVersion = "1.
|
|
45489
|
+
const packageVersion = "1.8.1";
|
|
45220
45490
|
function resolvePublicUrls() {
|
|
45221
45491
|
return {
|
|
45222
45492
|
focusApiUrl: process.env.TOGGL_FOCUS_API_URL ?? "https://focus.toggl.com",
|
|
@@ -45278,6 +45548,6 @@ function bootstrapServer(options = {}) {
|
|
|
45278
45548
|
if (options.beforeExit) process.on("beforeExit", async () => await options.beforeExit?.());
|
|
45279
45549
|
}
|
|
45280
45550
|
//#endregion
|
|
45281
|
-
export { runMcpCli as n, bootstrapServer as t };
|
|
45551
|
+
export { initProductAnalytics as a, shutdownProductAnalytics as c, getProductAnalyticsDistinctId as i, runMcpCli as n, redactUnsafeSentryMessages as o, captureToolInvocation as r, resolveProductAnalyticsDistinctId as s, bootstrapServer as t };
|
|
45282
45552
|
|
|
45283
|
-
//# sourceMappingURL=src-
|
|
45553
|
+
//# sourceMappingURL=src-Dh4qJKIU.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@togglhq/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Toggl 2.0 MCP server for Claude Code, Claude Desktop, and other MCP clients.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"node": ">=20"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
+
"@sentry/node": "10.47.0",
|
|
51
52
|
"date-fns": "4.1.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
|
-
"@sentry/node": "10.47.0",
|
|
55
55
|
"@toggl/cli-core": "workspace:^",
|
|
56
56
|
"@toggl/focus-queries": "workspace:^",
|
|
57
57
|
"@toggl/operations": "workspace:^",
|