exponential-mcp 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +20 -1
  2. package/dist/index.js +232 -12
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -106,12 +106,31 @@ exponential-mcp serve
106
106
  |------|-------------|
107
107
  | `get_workspaces` | List all workspaces |
108
108
  | `get_projects` | List projects (optionally by workspace) |
109
- | `get_actions` | List actions/tasks (filter by project or status) |
109
+ | `get_actions` | List actions/tasks (filter by project or status; no date filtering) |
110
+ | `get_todays_actions` | **What's on your plate now** — overdue / today / inbox, across all workspaces |
111
+ | `get_overdue_triage` | Why the overdue pile is that size: bulk-created cohorts vs real debt |
110
112
  | `create_action` | Create a new task (supports natural language) |
113
+ | `update_action` | Rename, re-prioritise, move project, or set dates (incl. `scheduledStart`) |
114
+ | `defer_actions` | Amnesty: clear dates, back to the project backlog untimed |
115
+ | `reschedule_actions` | Move actions to a new do-date |
111
116
  | `complete_action` | Mark an action as done |
112
117
  | `get_goals` | List OKRs with progress |
113
118
  | `search` | Search across everything |
114
119
 
120
+ ### Asking about the day
121
+
122
+ Use **`get_todays_actions`**, not `get_actions`, for anything about today,
123
+ priorities, or what the user is behind on. `get_actions` has no date filtering
124
+ at all, so it cannot distinguish overdue work from anything else.
125
+
126
+ When there is a lot of overdue work, follow up with **`get_overdue_triage`**
127
+ before proposing what to do. A large overdue count is usually a few bulk writes
128
+ — a generated project plan stamped every row with one timestamp — not a large
129
+ number of missed commitments. Those are **cohorts**, and the honest disposition
130
+ is `defer_actions` (amnesty); `reschedule_actions` would just re-inflict the
131
+ same pile tomorrow. Individually-dated **loose** actions are the ones that
132
+ deserve a real decision.
133
+
115
134
  ## Development
116
135
 
117
136
  ```bash
package/dist/index.js CHANGED
@@ -9,21 +9,63 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextpro
9
9
  import { ExponentialClient, createConfigStore } from 'exponential-sdk';
10
10
  import { readFileSync, existsSync } from 'fs';
11
11
  import { homedir } from 'os';
12
- import { join } from 'path';
12
+ import { dirname, join } from 'path';
13
+ import { fileURLToPath } from 'url';
13
14
  const LEGACY_CONFIG_PATH = join(homedir(), '.config', 'exponential-mcp', 'config.json');
15
+ // Report the real package version rather than a hand-maintained literal that drifts.
16
+ const PKG_VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8')).version;
14
17
  const configStore = createConfigStore({ projectName: 'exponential-mcp' });
18
+ /**
19
+ * Read the `exp` claim out of a JWT, without verifying the signature — we only
20
+ * want to know whether it is worth sending, not whether it is trustworthy.
21
+ *
22
+ * Returns null for opaque tokens (`exp_agent_…` keys, which never expire) and
23
+ * for anything that does not parse as a JWT.
24
+ */
25
+ function getTokenExpiry(token) {
26
+ const parts = token.split('.');
27
+ if (parts.length !== 3) {
28
+ return null;
29
+ }
30
+ try {
31
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
32
+ return typeof payload?.exp === 'number' ? new Date(payload.exp * 1000) : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function assertTokenIsUsable(token, source) {
39
+ const expiry = getTokenExpiry(token);
40
+ if (!expiry || expiry.getTime() > Date.now()) {
41
+ return;
42
+ }
43
+ console.error(`Error: token expired on ${expiry.toISOString().slice(0, 10)} (${source}).`);
44
+ console.error('Run "npx exponential-mcp init" to store a fresh token.');
45
+ process.exit(1);
46
+ }
15
47
  function migrateLegacyConfig() {
16
48
  if (configStore.isAuthenticated() || !existsSync(LEGACY_CONFIG_PATH)) {
17
49
  return;
18
50
  }
19
51
  try {
20
52
  const legacy = JSON.parse(readFileSync(LEGACY_CONFIG_PATH, 'utf-8'));
21
- if (legacy?.apiKey) {
22
- configStore.saveConfig({
23
- token: legacy.apiKey,
24
- apiUrl: legacy.baseUrl || 'https://www.exponential.im',
25
- });
53
+ if (!legacy?.apiKey) {
54
+ return;
55
+ }
56
+ // Don't resurrect a dead token: the legacy file long outlives the JWT in it,
57
+ // so migrating one blindly turns every config reset into a silent 401.
58
+ const expiry = getTokenExpiry(legacy.apiKey);
59
+ if (expiry && expiry.getTime() <= Date.now()) {
60
+ console.error(`Note: ignoring legacy config at ${LEGACY_CONFIG_PATH} — its token expired on ${expiry
61
+ .toISOString()
62
+ .slice(0, 10)}.`);
63
+ return;
26
64
  }
65
+ configStore.saveConfig({
66
+ token: legacy.apiKey,
67
+ apiUrl: legacy.baseUrl || 'https://www.exponential.im',
68
+ });
27
69
  }
28
70
  catch {
29
71
  // Ignore legacy config parsing errors.
@@ -33,6 +75,7 @@ function loadClientConfig() {
33
75
  migrateLegacyConfig();
34
76
  if (configStore.isAuthenticated()) {
35
77
  const config = configStore.loadConfig();
78
+ assertTokenIsUsable(config.token, 'stored config');
36
79
  return { token: config.token, apiUrl: config.apiUrl };
37
80
  }
38
81
  const token = process.env.EXPONENTIAL_API_KEY || process.env.EXPONENTIAL_API_TOKEN;
@@ -44,8 +87,25 @@ function loadClientConfig() {
44
87
  console.error('Run "npx exponential-mcp init" to set up, or set EXPONENTIAL_API_KEY env var.');
45
88
  process.exit(1);
46
89
  }
90
+ assertTokenIsUsable(token, 'EXPONENTIAL_API_KEY');
47
91
  return { token, apiUrl };
48
92
  }
93
+ /**
94
+ * `undefined` = leave the field alone, `null` = clear it, otherwise a Date.
95
+ * MCP arguments arrive as JSON, so an explicit clear can be either a real null
96
+ * or the string "null" depending on how the model emits it.
97
+ */
98
+ function parseDateArg(raw) {
99
+ if (raw === undefined)
100
+ return undefined;
101
+ if (raw === null || raw === 'null')
102
+ return null;
103
+ const parsed = new Date(raw);
104
+ if (isNaN(parsed.getTime())) {
105
+ throw new Error(`Invalid date "${String(raw)}". Use an ISO datetime, or null to clear.`);
106
+ }
107
+ return parsed;
108
+ }
49
109
  // Tool definitions
50
110
  const TOOLS = [
51
111
  {
@@ -63,7 +123,7 @@ const TOOLS = [
63
123
  },
64
124
  {
65
125
  name: 'get_actions',
66
- description: 'List actions/tasks. Can filter by project or status.',
126
+ description: 'List actions/tasks, optionally filtered by project or status. This is a flat list with no date filtering — to answer "what should I work on today" or "what am I behind on", use get_todays_actions instead.',
67
127
  inputSchema: {
68
128
  type: 'object',
69
129
  properties: {
@@ -79,6 +139,97 @@ const TOOLS = [
79
139
  }
80
140
  }
81
141
  },
142
+ {
143
+ name: 'get_todays_actions',
144
+ description: "What is on the user's plate right now, split into overdue / today / inbox. This is the same set the /today page renders, across all workspaces. Use this FIRST for any question about today, this week, priorities, what to work on, or what the user is behind on — it is the only tool that surfaces overdue work. Returns action IDs, so pair it with update_action, defer_actions, or reschedule_actions to act on what it finds.",
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ workspaceId: {
149
+ type: 'string',
150
+ description: 'Optional workspace ID. Omit to span all workspaces (usually what you want).'
151
+ }
152
+ }
153
+ }
154
+ },
155
+ {
156
+ name: 'get_overdue_triage',
157
+ description: 'Explain WHY the overdue pile is the size it is, before proposing what to do about it. Splits overdue actions into "cohorts" — groups sharing one exact timestamp, the fingerprint of a bulk write like a generated project plan, which were almost certainly never individually due — and "loose" individually-dated actions, which are real missed commitments. Use this whenever the user has a lot of overdue work: recommend defer_actions (amnesty) for cohorts and a real decision for loose items. Rescheduling a cohort just re-inflicts the pile tomorrow.',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ workspaceId: {
162
+ type: 'string',
163
+ description: 'Optional workspace ID. Omit to span all workspaces.'
164
+ }
165
+ }
166
+ }
167
+ },
168
+ {
169
+ name: 'update_action',
170
+ description: 'Update an action: rename, re-prioritise, move project, change status, or set its dates. scheduledStart is the "do date" — when the user plans to work on it — and it is what /today partitions on, taking precedence over dueDate. To move something out of the overdue bucket you must set scheduledStart; changing dueDate alone will not do it.',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: {
174
+ id: { type: 'string', description: 'Action ID' },
175
+ name: { type: 'string', description: 'New name' },
176
+ description: { type: 'string', description: 'New description' },
177
+ projectId: { type: 'string', description: 'Move to this project ID' },
178
+ status: {
179
+ type: 'string',
180
+ enum: ['ACTIVE', 'COMPLETED', 'CANCELLED'],
181
+ description: 'New status'
182
+ },
183
+ dueDate: {
184
+ type: 'string',
185
+ description: 'Deadline as an ISO datetime, or null to clear'
186
+ },
187
+ scheduledStart: {
188
+ type: 'string',
189
+ description: 'Do-date as an ISO datetime (e.g. 2026-08-05T09:00:00Z), or null to clear'
190
+ },
191
+ scheduledEnd: {
192
+ type: 'string',
193
+ description: 'End of the time block as an ISO datetime, or null to clear'
194
+ }
195
+ },
196
+ required: ['id']
197
+ }
198
+ },
199
+ {
200
+ name: 'defer_actions',
201
+ description: 'Amnesty: clear the dates on these actions so they fall back to their project backlog untimed. Use for work that was never really due on the date it carries — most often a bulk-created cohort from get_overdue_triage. The actions stay ACTIVE and are not deleted or archived; they simply stop counting as overdue. Prefer this over reschedule_actions when the dates were never a real commitment.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ actionIds: {
206
+ type: 'array',
207
+ items: { type: 'string' },
208
+ description: 'Action IDs to defer'
209
+ }
210
+ },
211
+ required: ['actionIds']
212
+ }
213
+ },
214
+ {
215
+ name: 'reschedule_actions',
216
+ description: 'Move actions to a new do-date, for work that genuinely is still due, just later. Sets scheduledStart, pushing dueDate forward only where it would otherwise fall before it. If the actions were bulk-created and never individually due, use defer_actions instead — rescheduling them only re-inflicts the same pile tomorrow.',
217
+ inputSchema: {
218
+ type: 'object',
219
+ properties: {
220
+ actionIds: {
221
+ type: 'array',
222
+ items: { type: 'string' },
223
+ description: 'Action IDs to reschedule'
224
+ },
225
+ date: {
226
+ type: 'string',
227
+ description: 'New do-date as an ISO datetime (e.g. 2026-08-05T09:00:00Z)'
228
+ }
229
+ },
230
+ required: ['actionIds', 'date']
231
+ }
232
+ },
82
233
  {
83
234
  name: 'create_action',
84
235
  description: 'Create a new action/task. Supports natural language with dates and project names.',
@@ -160,7 +311,7 @@ async function main() {
160
311
  const trpcClient = client.client;
161
312
  const server = new Server({
162
313
  name: 'exponential-mcp',
163
- version: '0.1.0',
314
+ version: PKG_VERSION,
164
315
  }, {
165
316
  capabilities: {
166
317
  tools: {},
@@ -216,10 +367,34 @@ async function main() {
216
367
  ],
217
368
  };
218
369
  }
370
+ case 'get_todays_actions': {
371
+ const todays = await client.actions.getTodaysActions(args?.workspaceId);
372
+ return {
373
+ content: [
374
+ {
375
+ type: 'text',
376
+ text: JSON.stringify(todays, null, 2),
377
+ },
378
+ ],
379
+ };
380
+ }
381
+ case 'get_overdue_triage': {
382
+ const triage = await client.actions.getOverdueTriage(args?.workspaceId);
383
+ return {
384
+ content: [
385
+ {
386
+ type: 'text',
387
+ text: JSON.stringify(triage, null, 2),
388
+ },
389
+ ],
390
+ };
391
+ }
219
392
  case 'create_action': {
220
- // SDK does not yet expose quickCreate, so we use the underlying tRPC client.
393
+ // quickCreate's input field is `name`, not `text` -- it parses natural
394
+ // language out of the name itself (dates, project names) when
395
+ // parseNaturalLanguage is on, which it is by default.
221
396
  const action = await trpcClient.action.quickCreate.mutate({
222
- text: args?.text,
397
+ name: args?.text,
223
398
  });
224
399
  return {
225
400
  content: [
@@ -230,9 +405,54 @@ async function main() {
230
405
  ],
231
406
  };
232
407
  }
408
+ case 'update_action': {
409
+ const action = await client.actions.update({
410
+ id: args?.id,
411
+ name: args?.name,
412
+ description: args?.description,
413
+ projectId: args?.projectId,
414
+ status: args?.status,
415
+ dueDate: parseDateArg(args?.dueDate),
416
+ scheduledStart: parseDateArg(args?.scheduledStart),
417
+ scheduledEnd: parseDateArg(args?.scheduledEnd),
418
+ });
419
+ return {
420
+ content: [
421
+ {
422
+ type: 'text',
423
+ text: `Updated: ${action.name} (ID: ${action.id})`,
424
+ },
425
+ ],
426
+ };
427
+ }
428
+ case 'defer_actions': {
429
+ const result = await client.actions.bulkDefer(args?.actionIds);
430
+ return {
431
+ content: [
432
+ {
433
+ type: 'text',
434
+ text: result.message,
435
+ },
436
+ ],
437
+ };
438
+ }
439
+ case 'reschedule_actions': {
440
+ const when = new Date(args?.date);
441
+ if (isNaN(when.getTime())) {
442
+ throw new Error(`Invalid date "${String(args?.date)}". Use an ISO datetime.`);
443
+ }
444
+ const result = await client.actions.bulkReschedule(args?.actionIds, when);
445
+ return {
446
+ content: [
447
+ {
448
+ type: 'text',
449
+ text: `Rescheduled ${result.count} action${result.count === 1 ? '' : 's'} to ${when.toISOString()}`,
450
+ },
451
+ ],
452
+ };
453
+ }
233
454
  case 'complete_action': {
234
- // SDK does not yet expose action updates, so we use the underlying tRPC client.
235
- const action = await trpcClient.action.update.mutate({
455
+ const action = await client.actions.update({
236
456
  id: args?.id,
237
457
  status: 'COMPLETED',
238
458
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exponential-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Exponential - connect Claude to your projects, actions, and goals",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "build": "tsc",
12
12
  "dev": "tsc --watch",
13
13
  "start": "node dist/index.js",
14
- "cli": "node dist/cli.js"
14
+ "cli": "node dist/cli.js",
15
+ "prepublishOnly": "npm run build"
15
16
  },
16
17
  "keywords": [
17
18
  "mcp",
@@ -23,9 +24,9 @@
23
24
  "author": "Exponential",
24
25
  "license": "MIT",
25
26
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.0.0",
27
+ "@modelcontextprotocol/sdk": "^1.30.0",
27
28
  "commander": "^12.0.0",
28
- "exponential-sdk": "^1.0.0",
29
+ "exponential-sdk": "^1.11.0",
29
30
  "zod": "^3.22.0"
30
31
  },
31
32
  "devDependencies": {