exponential-mcp 0.3.0 → 0.5.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 (2) hide show
  1. package/dist/index.js +124 -12
  2. package/package.json +5 -4
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;
26
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;
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,6 +87,7 @@ 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
  }
49
93
  /**
@@ -216,13 +260,54 @@ const TOOLS = [
216
260
  },
217
261
  {
218
262
  name: 'get_goals',
219
- description: 'List OKRs (Objectives and Key Results) with their progress',
263
+ description: "List objectives (goals). Each objective carries its linked projects, which is how work ladders up to an OKR: every action has a projectId. Objectives have INTEGER ids — key results, which are separate, have CUIDs (see get_key_results). Use tree: true for the annual → quarterly cascade.",
220
264
  inputSchema: {
221
265
  type: 'object',
222
266
  properties: {
223
267
  workspaceId: {
224
268
  type: 'string',
225
- description: 'Optional workspace ID'
269
+ description: 'Optional workspace ID. With it the list is workspace-wide (every member\'s objectives); without it you get your own.'
270
+ },
271
+ period: {
272
+ type: 'string',
273
+ description: 'Optional period filter, e.g. "Q3-2026" or "Annual-2026"'
274
+ },
275
+ status: {
276
+ type: 'string',
277
+ description: 'Optional status: planned, active, completed, archived, on-hold'
278
+ },
279
+ tree: {
280
+ type: 'boolean',
281
+ description: 'Return objectives nested parent → child (up to 5 levels), each with its projects and key results, instead of a flat list'
282
+ }
283
+ }
284
+ }
285
+ },
286
+ {
287
+ name: 'get_key_results',
288
+ description: "List key results — the measurable half of an OKR. By default groups them under their objectives (the richest read: one call for 'how is the quarter going'). Pass flat: true for a bare list. Key result ids are CUIDs; the goalId tying one to its objective is an integer.",
289
+ inputSchema: {
290
+ type: 'object',
291
+ properties: {
292
+ workspaceId: {
293
+ type: 'string',
294
+ description: "Optional workspace ID. With it the list is workspace-wide (every member's key results); without it you get your own."
295
+ },
296
+ period: {
297
+ type: 'string',
298
+ description: 'Optional period filter, e.g. "Q3-2026"'
299
+ },
300
+ goalId: {
301
+ type: 'number',
302
+ description: 'Optional objective ID (an integer). Only applies with flat: true.'
303
+ },
304
+ status: {
305
+ type: 'string',
306
+ description: 'Optional status: not-started, on-track, at-risk, off-track, achieved. Only applies with flat: true.'
307
+ },
308
+ flat: {
309
+ type: 'boolean',
310
+ description: 'Return a flat list of key results rather than grouping them by objective'
226
311
  }
227
312
  }
228
313
  }
@@ -267,7 +352,7 @@ async function main() {
267
352
  const trpcClient = client.client;
268
353
  const server = new Server({
269
354
  name: 'exponential-mcp',
270
- version: '0.1.0',
355
+ version: PKG_VERSION,
271
356
  }, {
272
357
  capabilities: {
273
358
  tools: {},
@@ -421,10 +506,17 @@ async function main() {
421
506
  ],
422
507
  };
423
508
  }
509
+ // Goes through the SDK's GoalsApi rather than a raw tRPC path. The old
510
+ // implementation called `goal.list`, which does not exist — the tool
511
+ // hard-errored with `No procedure found on path "goal.list"` on every
512
+ // invocation.
424
513
  case 'get_goals': {
425
- const goals = await trpcClient.goal.list.query({
426
- workspaceId: args?.workspaceId,
427
- });
514
+ const workspaceId = args?.workspaceId;
515
+ const period = args?.period;
516
+ const status = args?.status;
517
+ const goals = args?.tree
518
+ ? await client.goals.tree({ workspaceId, status })
519
+ : await client.goals.list({ workspaceId, period, status });
428
520
  return {
429
521
  content: [
430
522
  {
@@ -434,6 +526,26 @@ async function main() {
434
526
  ],
435
527
  };
436
528
  }
529
+ case 'get_key_results': {
530
+ const workspaceId = args?.workspaceId;
531
+ const period = args?.period;
532
+ const keyResults = args?.flat
533
+ ? await client.goals.keyResults.list({
534
+ workspaceId,
535
+ period,
536
+ goalId: args?.goalId,
537
+ status: args?.status,
538
+ })
539
+ : await client.goals.keyResults.byObjective({ workspaceId, period });
540
+ return {
541
+ content: [
542
+ {
543
+ type: 'text',
544
+ text: JSON.stringify(keyResults, null, 2),
545
+ },
546
+ ],
547
+ };
548
+ }
437
549
  case 'search': {
438
550
  const results = await trpcClient.search.global.query({
439
551
  query: args?.query,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exponential-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.10.0",
29
+ "exponential-sdk": "^1.12.0",
29
30
  "zod": "^3.22.0"
30
31
  },
31
32
  "devDependencies": {