@togglhq/mcp 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,185 @@
1
1
  #!/usr/bin/env node
2
- import { n as runMcpCli } from "./src-BSqkikrb.js";
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-BHdIWjE4.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
+ try {
139
+ const result = await toolHandler(...handlerArgs);
140
+ outcome = classifyToolResult(result);
141
+ if (IDENTITY_CHANGING_MCP_TOOLS.has(name) && outcome === "success") distinctId = resolveProductAnalyticsDistinctId("mcp") ?? distinctId;
142
+ return result;
143
+ } catch (error) {
144
+ outcome = "error";
145
+ throw error;
146
+ } finally {
147
+ captureToolInvocation({
148
+ toolName: name,
149
+ action,
150
+ outcome,
151
+ distinctId
152
+ });
153
+ }
154
+ };
155
+ return Reflect.apply(target, thisArg, [
156
+ name,
157
+ config,
158
+ wrappedHandler
159
+ ]);
160
+ } });
161
+ }
162
+ //#endregion
3
163
  //#region src/cli.ts
4
- runMcpCli();
164
+ const packageVersion = "1.8.0";
165
+ let sentryEnabled = false;
166
+ let productAnalyticsEnabled = false;
167
+ runMcpCli({
168
+ beforeRegister(server) {
169
+ productAnalyticsEnabled = initMcpProductAnalytics({ packageVersion });
170
+ sentryEnabled = initPublicSentry(process.env.NODE_ENV);
171
+ let next = server;
172
+ if (sentryEnabled) next = wrapPublicServerWithSentry(next);
173
+ if (productAnalyticsEnabled) wrapServerWithProductAnalytics(next);
174
+ return next;
175
+ },
176
+ beforeConnect() {
177
+ if (sentryEnabled) tagPublicSentrySession();
178
+ },
179
+ async beforeExit() {
180
+ if (sentryEnabled || productAnalyticsEnabled) await flushPublicAnalytics(sentryEnabled);
181
+ }
182
+ });
5
183
  //#endregion
6
184
  export {};
7
185
 
package/build/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import { n as runMcpCli, t as bootstrapServer } from "./src-BSqkikrb.js";
2
+ import { n as runMcpCli, t as bootstrapServer } from "./src-BHdIWjE4.js";
3
3
  export { bootstrapServer, runMcpCli };
@@ -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}`, { method: "POST" });
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}`, { method: "POST" });
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,207 @@ function listCachedMcpWorkspaces(profile) {
32667
32689
  active: workspace.workspace_id === auth.workspace_id
32668
32690
  }));
32669
32691
  }
32692
+ /**
32693
+ * Exception messages are the one channel `sendDefaultPii: false` does not cover,
32694
+ * and this repo's errors put user data in them: validation failures embed the
32695
+ * rejected input (valibot renders `issue.received`) and API failures embed the
32696
+ * response body. Sentry also captures uncaught exceptions and `cause` chains
32697
+ * through its own global handlers, which bypass any call-site scrubbing, so the
32698
+ * last word has to be `beforeSend`.
32699
+ *
32700
+ * Shared between the CLI and the public MCP build so the two cannot drift.
32701
+ */
32702
+ /**
32703
+ * Fixed categories are `<surface>.<snake_case>` — anchored at both ends, because
32704
+ * a prefix test would pass anything that merely *starts* that way. A real message
32705
+ * like `cli.ts:12: unexpected token "secret"` begins with `cli.` and would
32706
+ * otherwise sail through unredacted.
32707
+ */
32708
+ const SAFE_CATEGORY_PATTERN = /^(?:cli|mcp)\.[a-z][a-z0-9_]*$/;
32709
+ const REDACTED_SENTRY_MESSAGE = "redacted";
32710
+ function isSafeSentryMessage(value) {
32711
+ return typeof value === "string" && SAFE_CATEGORY_PATTERN.test(value);
32712
+ }
32713
+ /**
32714
+ * Replace every exception message that is not a known category, and any top-level
32715
+ * message. Stack frames are left alone: they carry file, function and line, never
32716
+ * payloads.
32717
+ */
32718
+ function redactUnsafeSentryMessages(event) {
32719
+ if (event.message !== void 0 && !isSafeSentryMessage(event.message)) event.message = REDACTED_SENTRY_MESSAGE;
32720
+ for (const value of event.exception?.values ?? []) if (!isSafeSentryMessage(value.value)) value.value = REDACTED_SENTRY_MESSAGE;
32721
+ return event;
32722
+ }
32723
+ const DEFAULT_POSTHOG_HOST = "https://eu.i.posthog.com";
32724
+ /**
32725
+ * The shared Toggl **production** PostHog project — the same token the Track
32726
+ * webapp (`posthogApiKey.production`), the marketing site (`GATSBY_POSTHOG_ID`)
32727
+ * and the Focus mobile app (`EXPO_PUBLIC_POSTHOG_KEY`) send to. MCP/CLI events
32728
+ * therefore land beside product events for the same people, which is why the
32729
+ * `distinct_id` has to be the accounts nanoid and why `$lib` has to separate the
32730
+ * developer tools out again.
32731
+ */
32732
+ const SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN = "phc_PiFIHfmVYQVICbsScOwEv7D9wBwoNvrUFZ1Fq9WxBrp";
32733
+ /**
32734
+ * PostHog splits usage and billing by `$lib`, so it has to name the sending
32735
+ * client rather than this module: sharing the Toggl production project with the
32736
+ * webapp, marketing site and mobile app means a single library value would leave
32737
+ * MCP and CLI volume indistinguishable in that breakdown.
32738
+ */
32739
+ const POSTHOG_LIB_BY_CLIENT = {
32740
+ mcp: "MCP",
32741
+ cli: "CLI"
32742
+ };
32743
+ const TOOL_INVOCATION_EVENT = "toggl_tool_invocation";
32744
+ /** Bound collector I/O so analytics cannot hang a completed CLI/MCP exit path. */
32745
+ const CAPTURE_TIMEOUT_MS = 2e3;
32746
+ const FLUSH_TIMEOUT_MS = 2e3;
32747
+ /** Actions are catalog identifiers, so anything else is a caller bug, not data. */
32748
+ const ACTION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
32749
+ let enabled = false;
32750
+ let apiKey = null;
32751
+ let host = DEFAULT_POSTHOG_HOST;
32752
+ let sessionId = "";
32753
+ let distinctId = "";
32754
+ let client = "mcp";
32755
+ let packageVersion$1 = "0.0.0";
32756
+ const pending = /* @__PURE__ */ new Set();
32757
+ function envFlagDisabled(raw) {
32758
+ if (raw === void 0 || raw === "") return false;
32759
+ const normalized = raw.trim().toLowerCase();
32760
+ return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
32761
+ }
32762
+ function getProductAnalyticsDistinctId() {
32763
+ return distinctId;
32764
+ }
32765
+ /**
32766
+ * Initialize product analytics for a public MCP/CLI process.
32767
+ * No-ops when TOGGL_PRODUCT_ANALYTICS is 0/false/off, or when the API key is "off".
32768
+ */
32769
+ function initProductAnalytics(context) {
32770
+ if (envFlagDisabled(process.env.TOGGL_PRODUCT_ANALYTICS)) {
32771
+ enabled = false;
32772
+ return false;
32773
+ }
32774
+ const key = (process.env.TOGGL_POSTHOG_API_KEY ?? SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN).trim();
32775
+ if (!key || key.toLowerCase() === "off") {
32776
+ enabled = false;
32777
+ return false;
32778
+ }
32779
+ apiKey = key;
32780
+ host = (process.env.TOGGL_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST).replace(/\/$/, "");
32781
+ sessionId = context.sessionId ?? crypto.randomUUID();
32782
+ distinctId = context.distinctId ?? `anon:${sessionId}`;
32783
+ client = context.client;
32784
+ packageVersion$1 = context.packageVersion;
32785
+ enabled = true;
32786
+ return true;
32787
+ }
32788
+ function normalizeAction(action) {
32789
+ if (typeof action !== "string") return null;
32790
+ const trimmed = action.trim();
32791
+ return ACTION_PATTERN.test(trimmed) ? trimmed : null;
32792
+ }
32793
+ /** Strict allowlist — never accept args/results. */
32794
+ function captureToolInvocation(event) {
32795
+ if (!enabled || !apiKey) return;
32796
+ const task = sendCapture(TOOL_INVOCATION_EVENT, {
32797
+ tool_name: event.toolName,
32798
+ action: normalizeAction(event.action),
32799
+ outcome: event.outcome,
32800
+ success: event.outcome === "success",
32801
+ client,
32802
+ session_id: sessionId,
32803
+ package_version: packageVersion$1
32804
+ }, event.distinctId).finally(() => {
32805
+ pending.delete(task);
32806
+ });
32807
+ pending.add(task);
32808
+ }
32809
+ async function sendCapture(event, properties, eventDistinctId) {
32810
+ if (!apiKey) return;
32811
+ const controller = new AbortController();
32812
+ const timer = setTimeout(() => controller.abort(), CAPTURE_TIMEOUT_MS);
32813
+ try {
32814
+ const response = await fetch(`${host}/i/v0/e/`, {
32815
+ method: "POST",
32816
+ headers: { "Content-Type": "application/json" },
32817
+ body: JSON.stringify({
32818
+ api_key: apiKey,
32819
+ event,
32820
+ distinct_id: eventDistinctId ?? distinctId,
32821
+ properties: {
32822
+ ...properties,
32823
+ $lib: POSTHOG_LIB_BY_CLIENT[client]
32824
+ }
32825
+ }),
32826
+ signal: controller.signal
32827
+ });
32828
+ if (!response.ok) await response.text().catch(() => void 0);
32829
+ } catch {} finally {
32830
+ clearTimeout(timer);
32831
+ }
32832
+ }
32833
+ async function shutdownProductAnalytics() {
32834
+ if (pending.size === 0) {
32835
+ enabled = false;
32836
+ return;
32837
+ }
32838
+ const inflight = [...pending];
32839
+ let flushTimer;
32840
+ try {
32841
+ await Promise.race([Promise.allSettled(inflight), new Promise((resolve) => {
32842
+ flushTimer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
32843
+ })]);
32844
+ } finally {
32845
+ if (flushTimer !== void 0) clearTimeout(flushTimer);
32846
+ pending.clear();
32847
+ enabled = false;
32848
+ }
32849
+ }
32850
+ /**
32851
+ * Accounts mints the base62 nanoid as the JWT `sub`; it is the same value
32852
+ * accounts-be sends to PostHog as `distinct_id`, so it is short and URL-safe.
32853
+ */
32854
+ const NANOID_PATTERN = /^[0-9A-Za-z_-]{6,64}$/;
32855
+ /**
32856
+ * Read `sub` out of a Toggl accounts access token without verifying it.
32857
+ *
32858
+ * Verification would need the accounts JWKS; the value is only used as an
32859
+ * analytics key, and a forged token would only mislabel the forger's own events.
32860
+ */
32861
+ function decodeAccessTokenSubject(token) {
32862
+ if (!token) return null;
32863
+ const payloadSegment = token.split(".")[1];
32864
+ if (!payloadSegment) return null;
32865
+ try {
32866
+ const sub = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8")).sub;
32867
+ if (typeof sub !== "string" || !NANOID_PATTERN.test(sub)) return null;
32868
+ return sub;
32869
+ } catch {
32870
+ return null;
32871
+ }
32872
+ }
32873
+ /**
32874
+ * Stable PostHog identity for a signed-in MCP/CLI process.
32875
+ *
32876
+ * The nanoid comes from the persisted profile, so the same person keeps one
32877
+ * `distinct_id` across process restarts and across MCP and CLI — a per-process
32878
+ * UUID would make every invocation look like a new user and every funnel
32879
+ * one-step-deep.
32880
+ *
32881
+ * The profile's numeric `user_id` is `user_account_id`, which is deliberately
32882
+ * *not* used here: accounts-be, toggl_api and the Track webapp all identify a
32883
+ * person in PostHog by the nanoid, so a bigint key would orphan these events
32884
+ * from every other identity we already have for the same user.
32885
+ */
32886
+ function resolveProductAnalyticsDistinctId(scope) {
32887
+ try {
32888
+ return decodeAccessTokenSubject(loadProfile(scope)?.access_token);
32889
+ } catch {
32890
+ return null;
32891
+ }
32892
+ }
32670
32893
  //#endregion
32671
32894
  //#region src/auth.ts
32672
32895
  var WorkspaceSelectionRequiredError = class extends Error {
@@ -32723,11 +32946,11 @@ async function runAuthFlow(accountsApiUrl = "https://accounts.toggl.com", focusA
32723
32946
  log
32724
32947
  });
32725
32948
  log("Exchanging authorization code...");
32726
- const tokens = await exchangeAuthorizationCode(accountsApiUrl, code, verifier, redirectUri, clientId);
32949
+ const tokens = await exchangeAuthorizationCode(accountsApiUrl, code, verifier, "mcp", redirectUri, clientId);
32727
32950
  log("Fetching account info...");
32728
- const user = await fetchAccountsApiMe(accountsApiUrl, tokens.access_token);
32951
+ const user = await fetchAccountsApiMe(accountsApiUrl, tokens.access_token, "mcp");
32729
32952
  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);
32953
+ const workspaces = await fetchAccessibleWorkspaces(accountsApiUrl, tokens.access_token, "mcp");
32731
32954
  pendingContext = {
32732
32955
  access_token: tokens.access_token,
32733
32956
  refresh_token: tokens.refresh_token,
@@ -32825,7 +33048,8 @@ function createAuthenticatedClientProxy() {
32825
33048
  tokenProvider: getValidToken,
32826
33049
  userId: config.user_id,
32827
33050
  organizationId: String(config.organization_id),
32828
- workspaceId: String(config.workspace_id)
33051
+ workspaceId: String(config.workspace_id),
33052
+ clientSurface: "mcp"
32829
33053
  });
32830
33054
  cachedKey = key;
32831
33055
  return cachedClient;
@@ -45216,7 +45440,7 @@ import_main.default.config({
45216
45440
  quiet: true,
45217
45441
  ignore: ["MISSING_ENV_FILE"]
45218
45442
  });
45219
- const packageVersion = "1.7.2";
45443
+ const packageVersion = "1.8.0";
45220
45444
  function resolvePublicUrls() {
45221
45445
  return {
45222
45446
  focusApiUrl: process.env.TOGGL_FOCUS_API_URL ?? "https://focus.toggl.com",
@@ -45278,6 +45502,6 @@ function bootstrapServer(options = {}) {
45278
45502
  if (options.beforeExit) process.on("beforeExit", async () => await options.beforeExit?.());
45279
45503
  }
45280
45504
  //#endregion
45281
- export { runMcpCli as n, bootstrapServer as t };
45505
+ 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
45506
 
45283
- //# sourceMappingURL=src-BSqkikrb.js.map
45507
+ //# sourceMappingURL=src-BHdIWjE4.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@togglhq/mcp",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
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:^",