@askthew/mcp-plugin 0.4.0 → 0.4.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.
Files changed (45) hide show
  1. package/README.md +24 -13
  2. package/dist/auth-pending.test.d.ts +1 -0
  3. package/dist/auth-pending.test.js +56 -0
  4. package/dist/cli-actions.test.d.ts +1 -0
  5. package/dist/cli-actions.test.js +71 -0
  6. package/dist/cli.d.ts +9 -0
  7. package/dist/cli.js +293 -37
  8. package/dist/cli.test.d.ts +1 -0
  9. package/dist/cli.test.js +274 -0
  10. package/dist/free-tier-policy.test.d.ts +1 -0
  11. package/dist/free-tier-policy.test.js +57 -0
  12. package/dist/index.d.ts +47 -13
  13. package/dist/index.js +1103 -106
  14. package/dist/index.test.js +609 -6
  15. package/dist/install.d.ts +40 -0
  16. package/dist/install.js +155 -18
  17. package/dist/install.test.js +62 -2
  18. package/dist/lib/auth-pending.d.ts +23 -0
  19. package/dist/lib/auth-pending.js +36 -0
  20. package/dist/lib/cli-actions.d.ts +28 -0
  21. package/dist/lib/cli-actions.js +104 -0
  22. package/dist/lib/free-install-registration.d.ts +27 -0
  23. package/dist/lib/free-install-registration.js +52 -0
  24. package/dist/lib/free-tier-policy.d.ts +5 -1
  25. package/dist/lib/free-tier-policy.js +16 -1
  26. package/dist/lib/local-identity.d.ts +44 -0
  27. package/dist/lib/local-identity.js +81 -0
  28. package/dist/lib/local-store.d.ts +33 -2
  29. package/dist/lib/local-store.js +191 -19
  30. package/dist/lib/paths.d.ts +2 -0
  31. package/dist/lib/paths.js +6 -0
  32. package/dist/lib/telemetry.js +28 -2
  33. package/dist/lib/timeline-insights.d.ts +23 -0
  34. package/dist/lib/timeline-insights.js +115 -0
  35. package/dist/lib/upgrade-nudge.d.ts +1 -1
  36. package/dist/lib/upgrade-nudge.js +8 -1
  37. package/dist/local-identity.test.d.ts +1 -0
  38. package/dist/local-identity.test.js +29 -0
  39. package/dist/local-store.test.js +34 -0
  40. package/dist/scope.d.ts +1 -1
  41. package/dist/scope.js +56 -2
  42. package/dist/scope.test.js +17 -0
  43. package/dist/timeline-insights.test.d.ts +1 -0
  44. package/dist/timeline-insights.test.js +85 -0
  45. package/package.json +2 -2
@@ -1,8 +1,113 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { codingSessionSignalSchema, createAskTheWMcpServer, normalizeInstallTokenInput, redactCodingSessionSignal, redactProvenanceSignal, } from "./index.js";
7
+ import { LocalStore } from "./lib/local-store.js";
8
+ import { credentialsPath, writePrivateJson } from "./lib/paths.js";
9
+ function toolResultJson(result) {
10
+ return JSON.parse(result.content[0].text);
11
+ }
12
+ async function withFreeEnv(fn) {
13
+ const previous = {
14
+ ASKTHEW_CLI_TOKEN: process.env.ASKTHEW_CLI_TOKEN,
15
+ ASKTHEW_USER_ID: process.env.ASKTHEW_USER_ID,
16
+ ASKTHEW_CLI_TOKEN_ID: process.env.ASKTHEW_CLI_TOKEN_ID,
17
+ ASKTHEW_DATA_DIR: process.env.ASKTHEW_DATA_DIR,
18
+ ASKTHEW_INSTALL_TOKEN: process.env.ASKTHEW_INSTALL_TOKEN,
19
+ ASKTHEW_FREE_MODE: process.env.ASKTHEW_FREE_MODE,
20
+ };
21
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-free-tools-"));
22
+ process.env.ASKTHEW_CLI_TOKEN = "cli_free_token";
23
+ process.env.ASKTHEW_USER_ID = "local-user";
24
+ process.env.ASKTHEW_CLI_TOKEN_ID = "cli-token-id";
25
+ process.env.ASKTHEW_DATA_DIR = dataDir;
26
+ delete process.env.ASKTHEW_INSTALL_TOKEN;
27
+ delete process.env.ASKTHEW_FREE_MODE;
28
+ try {
29
+ return await fn();
30
+ }
31
+ finally {
32
+ for (const [key, value] of Object.entries(previous)) {
33
+ if (value === undefined) {
34
+ delete process.env[key];
35
+ }
36
+ else {
37
+ process.env[key] = value;
38
+ }
39
+ }
40
+ fs.rmSync(dataDir, { recursive: true, force: true });
41
+ }
42
+ }
43
+ async function withPendingFreeEnv(fn) {
44
+ const previous = {
45
+ ASKTHEW_CLI_TOKEN: process.env.ASKTHEW_CLI_TOKEN,
46
+ ASKTHEW_USER_ID: process.env.ASKTHEW_USER_ID,
47
+ ASKTHEW_CLI_TOKEN_ID: process.env.ASKTHEW_CLI_TOKEN_ID,
48
+ ASKTHEW_DATA_DIR: process.env.ASKTHEW_DATA_DIR,
49
+ ASKTHEW_INSTALL_TOKEN: process.env.ASKTHEW_INSTALL_TOKEN,
50
+ ASKTHEW_FREE_MODE: process.env.ASKTHEW_FREE_MODE,
51
+ };
52
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-pending-free-tools-"));
53
+ process.env.ASKTHEW_FREE_MODE = "1";
54
+ process.env.ASKTHEW_DATA_DIR = dataDir;
55
+ delete process.env.ASKTHEW_CLI_TOKEN;
56
+ delete process.env.ASKTHEW_USER_ID;
57
+ delete process.env.ASKTHEW_CLI_TOKEN_ID;
58
+ delete process.env.ASKTHEW_INSTALL_TOKEN;
59
+ try {
60
+ return await fn();
61
+ }
62
+ finally {
63
+ for (const [key, value] of Object.entries(previous)) {
64
+ if (value === undefined) {
65
+ delete process.env[key];
66
+ }
67
+ else {
68
+ process.env[key] = value;
69
+ }
70
+ }
71
+ fs.rmSync(dataDir, { recursive: true, force: true });
72
+ }
73
+ }
74
+ async function withInstalledFreeEnv(fn) {
75
+ const previous = {
76
+ ASKTHEW_CLI_TOKEN: process.env.ASKTHEW_CLI_TOKEN,
77
+ ASKTHEW_USER_ID: process.env.ASKTHEW_USER_ID,
78
+ ASKTHEW_CLI_TOKEN_ID: process.env.ASKTHEW_CLI_TOKEN_ID,
79
+ ASKTHEW_DATA_DIR: process.env.ASKTHEW_DATA_DIR,
80
+ ASKTHEW_INSTALL_TOKEN: process.env.ASKTHEW_INSTALL_TOKEN,
81
+ ASKTHEW_FREE_MODE: process.env.ASKTHEW_FREE_MODE,
82
+ };
83
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-installed-free-tools-"));
84
+ process.env.ASKTHEW_FREE_MODE = "1";
85
+ process.env.ASKTHEW_DATA_DIR = dataDir;
86
+ delete process.env.ASKTHEW_CLI_TOKEN;
87
+ delete process.env.ASKTHEW_USER_ID;
88
+ delete process.env.ASKTHEW_CLI_TOKEN_ID;
89
+ delete process.env.ASKTHEW_INSTALL_TOKEN;
90
+ writePrivateJson(credentialsPath(), {
91
+ email: "ymtest89+test5@gmail.com",
92
+ userId: "local-user",
93
+ cliToken: "cli_free_token",
94
+ cliTokenId: "cli-token-id",
95
+ });
96
+ try {
97
+ return await fn();
98
+ }
99
+ finally {
100
+ for (const [key, value] of Object.entries(previous)) {
101
+ if (value === undefined) {
102
+ delete process.env[key];
103
+ }
104
+ else {
105
+ process.env[key] = value;
106
+ }
107
+ }
108
+ fs.rmSync(dataDir, { recursive: true, force: true });
109
+ }
110
+ }
6
111
  test("install token normalization accepts copied shell-quoted values", () => {
7
112
  assert.equal(normalizeInstallTokenInput("'atw_mcp_token'"), "atw_mcp_token");
8
113
  assert.equal(normalizeInstallTokenInput('"atw_mcp_token"'), "atw_mcp_token");
@@ -38,6 +143,19 @@ test("coding session signal redaction removes obvious secrets", () => {
38
143
  assert.match(redacted.commandsRun[0] ?? "", /\[REDACTED\]/);
39
144
  assert.deepEqual(redacted.metadata, { nested: { token: "[REDACTED]" } });
40
145
  });
146
+ test("capture redactor catches documented capture-path patterns", () => {
147
+ const redacted = redactCodingSessionSignal({
148
+ sessionId: "session-1",
149
+ sequence: 11,
150
+ kind: "session_checkpoint",
151
+ summary: "OPENAI_API_KEY=sk-proj_abcdefghijklmnopqrstuvwxyz123456 Bearer abc.def-ghi user@example.com AKIA1234567890ABCDEF eyJabcdefghijklmnopqrstuv.abcdefghijklmnopqrstuv.abcdefghijklmnopqrstuv",
152
+ evidence: [],
153
+ filesTouched: [],
154
+ commandsRun: [],
155
+ metadata: {},
156
+ });
157
+ assert.equal(redacted.summary, "[REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED]");
158
+ });
41
159
  test("redacts ATW hyphen-segmented install tokens in commands", () => {
42
160
  const redacted = redactCodingSessionSignal({
43
161
  sessionId: "session-1",
@@ -168,6 +286,13 @@ test("happy-path MCP source exposes capture_session_signal and v1 API tools", ()
168
286
  "create_decision",
169
287
  "update_decision",
170
288
  "delete_decision",
289
+ "review_decisions",
290
+ "review_session",
291
+ "recap",
292
+ "coach",
293
+ "promote_signal_to_decision",
294
+ "list_decision_candidates",
295
+ "search_trail",
171
296
  "list_outcomes",
172
297
  "get_outcome",
173
298
  "list_outcome_signals",
@@ -178,6 +303,7 @@ test("happy-path MCP source exposes capture_session_signal and v1 API tools", ()
178
303
  "update_north_star",
179
304
  "list_signals",
180
305
  "get_signal",
306
+ "find_signal_by_summary",
181
307
  ]) {
182
308
  assert.match(source, new RegExp(`"${toolName}"`));
183
309
  }
@@ -187,6 +313,65 @@ test("happy-path MCP source exposes capture_session_signal and v1 API tools", ()
187
313
  assert.doesNotMatch(source, /server\.tool\(\s*"get_session_decisions"/);
188
314
  assert.doesNotMatch(source, /server\.tool\(\s*"link_outcome"/);
189
315
  assert.doesNotMatch(source, /server\.tool\(\s*"get_decision_feed"/);
316
+ assert.doesNotMatch(source, /server\.tool\(\s*"analyze_session"/);
317
+ });
318
+ test("schema-handler contract registers every documented MCP tool", async () => {
319
+ await withFreeEnv(async () => {
320
+ const server = createAskTheWMcpServer({ sendStartupHeartbeat: false });
321
+ const tools = server._registeredTools;
322
+ const expected = [
323
+ "capture_session_signal",
324
+ "list_signals",
325
+ "get_signal",
326
+ "find_signal_by_summary",
327
+ "list_decisions",
328
+ "get_decision",
329
+ "create_decision",
330
+ "update_decision",
331
+ "delete_decision",
332
+ "review_decisions",
333
+ "review_session",
334
+ "view_timeline",
335
+ "recap",
336
+ "coach",
337
+ "promote_signal_to_decision",
338
+ "list_decision_candidates",
339
+ "search_trail",
340
+ "list_outcomes",
341
+ "get_outcome",
342
+ "list_outcome_signals",
343
+ "create_outcome",
344
+ "update_outcome",
345
+ "delete_outcome",
346
+ "get_north_star",
347
+ "update_north_star",
348
+ "export_decisions",
349
+ ];
350
+ for (const toolName of expected) {
351
+ assert.equal(typeof tools[toolName]?.handler, "function", `${toolName} handler`);
352
+ assert.ok(tools[toolName]?.inputSchema, `${toolName} schema`);
353
+ }
354
+ });
355
+ });
356
+ test("every write tool accepts an idempotency key parameter", () => {
357
+ const source = fs.readFileSync(path.resolve(process.cwd(), "src/index.ts"), "utf8");
358
+ for (const toolName of [
359
+ "capture_session_signal",
360
+ "create_decision",
361
+ "update_decision",
362
+ "delete_decision",
363
+ "create_outcome",
364
+ "update_outcome",
365
+ "delete_outcome",
366
+ "update_north_star",
367
+ "promote_signal_to_decision",
368
+ ]) {
369
+ const start = source.indexOf(`"${toolName}"`);
370
+ assert.notEqual(start, -1, `${toolName} registered`);
371
+ const nextTool = source.indexOf("server.tool(", start + 1);
372
+ const block = source.slice(start, nextTool === -1 ? undefined : nextTool);
373
+ assert.match(block, /idempotencyKey/, `${toolName} idempotencyKey`);
374
+ }
190
375
  });
191
376
  test("v1 API MCP tools dispatch to expected HTTP routes with install-token auth", async () => {
192
377
  const calls = [];
@@ -213,21 +398,22 @@ test("v1 API MCP tools dispatch to expected HTTP routes with install-token auth"
213
398
  });
214
399
  const tools = server._registeredTools;
215
400
  const cases = [
216
- { name: "list_decisions", payload: { limit: 5, cursor: "c1" }, method: "GET", path: "/api/decisions?limit=5&cursor=c1" },
401
+ { name: "list_decisions", payload: { limit: 5, cursor: "c1" }, method: "GET", path: "/api/decisions?limit=5&cursor=c1&compact=true&max_chars=8000" },
217
402
  { name: "get_decision", payload: { id: "d1" }, method: "GET", path: "/api/decisions/d1" },
218
- { name: "create_decision", payload: { content: "Adopt Bun" }, method: "POST", path: "/api/decisions" },
219
- { name: "update_decision", payload: { id: "d1", headline: "Adopt Bun v2" }, method: "PATCH", path: "/api/decisions/d1" },
403
+ { name: "create_decision", payload: { content: "Adopt Bun", idempotencyKey: "idem-create" }, method: "POST", path: "/api/decisions?response_shape=v2" },
404
+ { name: "update_decision", payload: { id: "d1", headline: "Adopt Bun v2", idempotencyKey: "idem-update" }, method: "PATCH", path: "/api/decisions/d1?response_shape=v2" },
220
405
  { name: "delete_decision", payload: { id: "d1", confirmText: "Adopt Bun v2" }, method: "DELETE", path: "/api/decisions/d1" },
221
406
  { name: "list_outcomes", payload: { limit: 10 }, method: "GET", path: "/api/outcomes?limit=10" },
222
407
  { name: "get_outcome", payload: { id: "o1" }, method: "GET", path: "/api/outcomes/o1" },
223
408
  { name: "list_outcome_signals", payload: { id: "o1" }, method: "GET", path: "/api/outcomes/o1/signals" },
224
409
  { name: "create_outcome", payload: { name: "Reduce churn" }, method: "POST", path: "/api/outcomes" },
225
- { name: "update_outcome", payload: { id: "o1", summary: "New summary" }, method: "PATCH", path: "/api/outcomes/o1" },
410
+ { name: "update_outcome", payload: { id: "o1", summary: "New summary", idempotencyKey: "idem-outcome" }, method: "PATCH", path: "/api/outcomes/o1?response_shape=v2" },
226
411
  { name: "delete_outcome", payload: { id: "o1", confirmText: "Reduce churn" }, method: "DELETE", path: "/api/outcomes/o1" },
227
412
  { name: "get_north_star", payload: {}, method: "GET", path: "/api/north-star" },
228
413
  { name: "update_north_star", payload: { metric: "Active users", current: "10", target: "100", reason: "API smoke" }, method: "POST", path: "/api/north-star" },
229
- { name: "list_signals", payload: { limit: 25, cursor: "2026-01-01T00:00:00.000Z" }, method: "GET", path: "/api/signals?limit=25&cursor=2026-01-01T00%3A00%3A00.000Z" },
414
+ { name: "list_signals", payload: { limit: 25, cursor: "2026-01-01T00:00:00.000Z" }, method: "GET", path: "/api/signals?limit=25&cursor=2026-01-01T00%3A00%3A00.000Z&compact=true&max_chars=8000" },
230
415
  { name: "get_signal", payload: { id: "s1" }, method: "GET", path: "/api/signals/s1" },
416
+ { name: "find_signal_by_summary", payload: { query: "adopt", limit: 5 }, method: "GET", path: "/api/signals?query=adopt&limit=5&compact=true&max_chars=8000" },
231
417
  ];
232
418
  for (const entry of cases) {
233
419
  assert.ok(tools[entry.name], `${entry.name} should be registered`);
@@ -240,6 +426,7 @@ test("v1 API MCP tools dispatch to expected HTTP routes with install-token auth"
240
426
  assert.equal(call.url, `https://askthew.example.com${entry.path}`);
241
427
  assert.deepEqual(call.headers, {
242
428
  ...(entry.method === "GET" ? {} : { "Content-Type": "application/json" }),
429
+ ...(entry.payload.idempotencyKey ? { "Idempotency-Key": entry.payload.idempotencyKey } : {}),
243
430
  Authorization: "Bearer atw_mcp_tools",
244
431
  });
245
432
  if (entry.method !== "GET") {
@@ -266,6 +453,25 @@ test("v1 API MCP tools return server errors as JSON text", async () => {
266
453
  assert.equal(parsed.status, 500);
267
454
  assert.equal(parsed.code, "nope");
268
455
  });
456
+ test("compact write tools relay upstream errors instead of pretending success", async () => {
457
+ const server = createAskTheWMcpServer({
458
+ apiBaseUrl: "https://askthew.example.com",
459
+ sendStartupHeartbeat: false,
460
+ credentials: {
461
+ installToken: "atw_mcp_tools",
462
+ clientId: "codex",
463
+ },
464
+ fetchImpl: (async () => new Response(JSON.stringify({ ok: false, code: "invalid_input", field: "content", hint: "Provide content." }), {
465
+ status: 422,
466
+ })),
467
+ });
468
+ const result = await server._registeredTools.create_decision.handler({ content: " " });
469
+ const parsed = toolResultJson(result);
470
+ assert.equal(parsed.ok, false);
471
+ assert.equal(parsed.status, 422);
472
+ assert.equal(parsed.code, "invalid_input");
473
+ assert.equal(parsed.field, "content");
474
+ });
269
475
  test("createAskTheWMcpServer sends startup heartbeat and automated setup signal", async () => {
270
476
  const calls = [];
271
477
  createAskTheWMcpServer({
@@ -341,9 +547,406 @@ test("createAskTheWMcpServer accepts hosted connector identity overrides", async
341
547
  commandsRun: [],
342
548
  metadata: {},
343
549
  });
344
- assert.equal(calls[0]?.url, "https://askthew.example.com/api/ingest/mcp");
550
+ assert.equal(calls[0]?.url, "https://askthew.example.com/api/ingest/mcp?response_shape=v2");
345
551
  assert.equal(calls[0]?.body.installToken, "atw_mcp_remote");
346
552
  assert.equal(calls[0]?.body.clientId, "claude_remote");
347
553
  assert.equal(calls[0]?.body.clientLabel, "Claude Desktop / Cowork");
348
554
  assert.equal(calls[0]?.body.sessionSignal.metadata.connector_mode, "remote_mcp");
349
555
  });
556
+ test("free capture defaults to compact response and echo full returns full local payload", async () => {
557
+ await withFreeEnv(async () => {
558
+ const server = createAskTheWMcpServer({ sendStartupHeartbeat: false });
559
+ const capture = server._registeredTools.capture_session_signal;
560
+ const basePayload = {
561
+ sessionId: "session-compact",
562
+ sequence: 1,
563
+ kind: "implementation_update",
564
+ summary: "Updated compact response shape.",
565
+ evidence: [],
566
+ filesTouched: ["src/index.ts"],
567
+ commandsRun: ["npm test"],
568
+ metadata: {},
569
+ };
570
+ const compact = toolResultJson(await capture.handler(basePayload));
571
+ assert.equal(compact.ok, true);
572
+ assert.equal(compact.sessionId, "session-compact");
573
+ assert.equal(compact.sequence, 1);
574
+ assert.equal(typeof compact.id, "number");
575
+ assert.ok(JSON.stringify(compact).length < 1024);
576
+ assert.equal("signal" in compact, false);
577
+ const full = toolResultJson(await capture.handler({ ...basePayload, sequence: 2, echo: "full" }));
578
+ assert.equal(full.ok, true);
579
+ assert.equal(full.signal.summary, "Updated compact response shape.");
580
+ assert.equal(full.signal.sessionId, "session-compact");
581
+ });
582
+ });
583
+ test("stale free install without identity self-heals into local capture without hosted workspace calls", async () => {
584
+ await withPendingFreeEnv(async () => {
585
+ const calls = [];
586
+ const server = createAskTheWMcpServer({
587
+ apiBaseUrl: "https://askthew.example.com",
588
+ fetchImpl: async (url) => {
589
+ calls.push({ url: String(url) });
590
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
591
+ },
592
+ });
593
+ await new Promise((resolve) => setTimeout(resolve, 0));
594
+ const tools = server._registeredTools;
595
+ const capture = toolResultJson(await tools.capture_session_signal.handler({
596
+ sessionId: "session-pending",
597
+ sequence: 1,
598
+ kind: "setup_complete",
599
+ summary: "Stale free install should capture locally.",
600
+ evidence: [],
601
+ filesTouched: [],
602
+ commandsRun: [],
603
+ metadata: {},
604
+ }));
605
+ assert.equal(capture.ok, true);
606
+ assert.equal(capture.id, 1);
607
+ assert.equal(calls.length, 1);
608
+ assert.match(calls[0].url, /\/api\/cli\/v1\/free-installs\/register$/);
609
+ const store = LocalStore.open();
610
+ try {
611
+ const stats = store.stats();
612
+ assert.equal(stats.signals, 1);
613
+ assert.equal(stats.decisions, 0);
614
+ }
615
+ finally {
616
+ store.close();
617
+ }
618
+ });
619
+ });
620
+ test("authenticated free mode keeps capture, decisions, and review local without hosted calls", async () => {
621
+ await withFreeEnv(async () => {
622
+ const calls = [];
623
+ const server = createAskTheWMcpServer({
624
+ apiBaseUrl: "https://askthew.example.com",
625
+ fetchImpl: async (url) => {
626
+ calls.push({ url: String(url) });
627
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
628
+ },
629
+ });
630
+ await new Promise((resolve) => setTimeout(resolve, 0));
631
+ const tools = server._registeredTools;
632
+ const capture = toolResultJson(await tools.capture_session_signal.handler({
633
+ sessionId: "session-local-only",
634
+ sequence: 1,
635
+ kind: "implementation_update",
636
+ summary: "Captured locally after free auth.",
637
+ evidence: [],
638
+ filesTouched: ["packages/mcp-plugin/src/index.ts"],
639
+ commandsRun: [],
640
+ metadata: {},
641
+ }));
642
+ const decision = toolResultJson(await tools.create_decision.handler({ content: "Keep free mode local-only." }));
643
+ const review = toolResultJson(await tools.review_session.handler({ sessionId: "session-local-only", format: "json" }));
644
+ assert.equal(capture.ok, true);
645
+ assert.match(decision.id, /^d_/);
646
+ assert.equal(review.ok, true);
647
+ assert.equal(calls.length, 0);
648
+ const store = LocalStore.open();
649
+ try {
650
+ const stats = store.stats();
651
+ assert.equal(stats.signals, 1);
652
+ assert.equal(stats.decisions, 1);
653
+ }
654
+ finally {
655
+ store.close();
656
+ }
657
+ });
658
+ });
659
+ test("installed free mode with credential file captures locally even if hosted app would return local-only 403", async () => {
660
+ await withInstalledFreeEnv(async () => {
661
+ const calls = [];
662
+ const server = createAskTheWMcpServer({
663
+ apiBaseUrl: "https://askthew.example.com",
664
+ fetchImpl: async (url) => {
665
+ calls.push({ url: String(url) });
666
+ return new Response(JSON.stringify({
667
+ ok: false,
668
+ code: "local_only_free_feature",
669
+ message: "This free MCP token is local-only and cannot read or write the shared Ask The W workspace.",
670
+ }), { status: 403 });
671
+ },
672
+ });
673
+ await new Promise((resolve) => setTimeout(resolve, 0));
674
+ const tools = server._registeredTools;
675
+ const capture = toolResultJson(await tools.capture_session_signal.handler({
676
+ sessionId: "session-installed-free",
677
+ sequence: 1,
678
+ kind: "setup_complete",
679
+ summary: "Installed free mode should write this locally.",
680
+ evidence: [],
681
+ filesTouched: [],
682
+ commandsRun: [],
683
+ metadata: {},
684
+ }));
685
+ assert.equal(capture.ok, true);
686
+ assert.equal(capture.sessionId, "session-installed-free");
687
+ assert.equal("code" in capture, false);
688
+ assert.equal(JSON.stringify(capture).includes("local_only_free_feature"), false);
689
+ assert.equal(calls.length, 0);
690
+ const store = LocalStore.open();
691
+ try {
692
+ const signals = store.listSignals({ sessionId: "session-installed-free", limit: 10 });
693
+ assert.equal(signals.length, 1);
694
+ assert.equal(signals[0]?.summary, "Installed free mode should write this locally.");
695
+ }
696
+ finally {
697
+ store.close();
698
+ }
699
+ });
700
+ });
701
+ test("free decisions, recap, coach, and promote return human-readable compact outputs", async () => {
702
+ await withFreeEnv(async () => {
703
+ const server = createAskTheWMcpServer({ sendStartupHeartbeat: false });
704
+ const tools = server._registeredTools;
705
+ await tools.capture_session_signal.handler({
706
+ sessionId: "session-free",
707
+ sequence: 1,
708
+ kind: "implementation_update",
709
+ summary: "Implemented the trial onboarding CTA.",
710
+ evidence: [],
711
+ filesTouched: ["apps/app/page.tsx"],
712
+ commandsRun: [],
713
+ metadata: {},
714
+ });
715
+ await tools.capture_session_signal.handler({
716
+ sessionId: "session-free",
717
+ sequence: 2,
718
+ kind: "verification_result",
719
+ summary: "npm test passed.",
720
+ evidence: [],
721
+ filesTouched: ["apps/app/page.tsx"],
722
+ commandsRun: ["npm test"],
723
+ metadata: {},
724
+ });
725
+ const created = toolResultJson(await tools.create_decision.handler({ content: "Keep the direct onboarding CTA." }));
726
+ assert.equal(created.ok, true);
727
+ assert.match(created.id, /^d_/);
728
+ assert.equal(created.sequence, 1);
729
+ const promoted = toolResultJson(await tools.promote_signal_to_decision.handler({ signalId: 1, status: "committed", why: "The captured implementation signal explains the change." }));
730
+ assert.equal(promoted.ok, true);
731
+ assert.equal(promoted.linkedSignalId, 1);
732
+ assert.equal(promoted.decision.rawContent, "Implemented the trial onboarding CTA.");
733
+ assert.deepEqual(promoted.decision.sourceSignalIds, [1]);
734
+ const digest = toolResultJson(await tools.recap.handler({ format: "digest" }));
735
+ const standup = toolResultJson(await tools.recap.handler({ format: "standup" }));
736
+ const share = toolResultJson(await tools.recap.handler({ format: "share" }));
737
+ assert.notEqual(digest.rendered, standup.rendered);
738
+ assert.notEqual(standup.rendered, share.rendered);
739
+ const coach = toolResultJson(await tools.coach.handler({ scope: "session" }));
740
+ assert.equal(coach.ok, true);
741
+ assert.equal(typeof coach.qualityScore, "number");
742
+ assert.match(coach.biggestGap, /\w/);
743
+ for (const scope of ["week", "patterns"]) {
744
+ const response = toolResultJson(await tools.coach.handler({ scope }));
745
+ assert.equal(response.ok, false);
746
+ assert.equal(response.code, "free_tier_paid_feature");
747
+ assert.equal(response.tool, "coach");
748
+ assert.match(response.upgradeUrl, /askthew\.com\/mcp/);
749
+ }
750
+ });
751
+ });
752
+ test("free local tools surface decision candidates, compact views, search, traversal, conflicts, and max_chars", async () => {
753
+ await withFreeEnv(async () => {
754
+ const server = createAskTheWMcpServer({ sendStartupHeartbeat: false });
755
+ const tools = server._registeredTools;
756
+ const captured = toolResultJson(await tools.capture_session_signal.handler({
757
+ sessionId: "session-candidates",
758
+ sequence: 1,
759
+ kind: "direction_change",
760
+ summary: "Let's go with token-budgeted local search because recap output is too large.",
761
+ evidence: [{
762
+ role: "assistant",
763
+ kind: "diff",
764
+ excerpt: "Changed search behavior.",
765
+ diff: "- old recap only\n+ new local search",
766
+ }],
767
+ filesTouched: ["packages/mcp-plugin/src/index.ts"],
768
+ commandsRun: [],
769
+ metadata: {},
770
+ }));
771
+ const candidates = toolResultJson(await tools.list_decision_candidates.handler({ sessionId: "session-candidates" }));
772
+ assert.equal(candidates.ok, true);
773
+ assert.equal(candidates.decisionCandidates.length, 1);
774
+ assert.equal(candidates.decisionCandidates[0].signalId, captured.id);
775
+ const promoted = toolResultJson(await tools.promote_signal_to_decision.handler({
776
+ signalId: captured.id,
777
+ status: "committed",
778
+ why: "The direction change has the rationale.",
779
+ }));
780
+ assert.equal(promoted.ok, true);
781
+ assert.equal(promoted.decision.contributingSignals[0].id, captured.id);
782
+ assert.equal(Boolean(promoted.decision.committedAt), true);
783
+ const signal = toolResultJson(await tools.get_signal.handler({ id: String(captured.id) }));
784
+ assert.equal(signal.signal.decision.id, promoted.id);
785
+ const decision = toolResultJson(await tools.get_decision.handler({ id: promoted.id }));
786
+ assert.equal(decision.decision.contributingSignals[0].id, captured.id);
787
+ const compact = toolResultJson(await tools.list_signals.handler({ sessionId: "session-candidates", compact: true }));
788
+ assert.deepEqual(Object.keys(compact.signals[0]).sort(), ["decisionId", "files", "id", "kind", "summary"]);
789
+ const found = toolResultJson(await tools.find_signal_by_summary.handler({ query: "token-budgeted" }));
790
+ assert.equal(found.ok, true);
791
+ assert.equal(found.signals[0].id, captured.id);
792
+ const search = toolResultJson(await tools.search_trail.handler({ query: "local search", compact: true }));
793
+ assert.equal(search.ok, true);
794
+ assert.equal(search.matches.length >= 1, true);
795
+ const conflict = toolResultJson(await tools.create_decision.handler({
796
+ content: "Drop token-budgeted local search.",
797
+ }));
798
+ assert.equal(conflict.ok, true);
799
+ assert.equal(conflict.warnings[0].code, "possible_conflict");
800
+ assert.equal(conflict.warnings[0].conflictingDecisionId, promoted.id);
801
+ const budgeted = toolResultJson(await tools.review_session.handler({
802
+ sessionId: "session-candidates",
803
+ format: "json",
804
+ max_chars: 500,
805
+ }));
806
+ assert.equal(budgeted.ok, true);
807
+ assert.equal(budgeted.truncated, true);
808
+ assert.equal(JSON.stringify(budgeted, null, 2).length <= 700, true);
809
+ });
810
+ });
811
+ test("free-tier smoke covers every free tool without overflow or silent failures", async () => {
812
+ await withFreeEnv(async () => {
813
+ const server = createAskTheWMcpServer({ sendStartupHeartbeat: false });
814
+ const tools = server._registeredTools;
815
+ const results = {};
816
+ const capture = toolResultJson(await tools.capture_session_signal.handler({
817
+ sessionId: "session-smoke",
818
+ sequence: 1,
819
+ kind: "implementation_update",
820
+ summary: "Implemented every free tool smoke path.",
821
+ evidence: [],
822
+ filesTouched: ["packages/mcp-plugin/src/index.ts"],
823
+ commandsRun: ["npm test --workspace @askthew/mcp-plugin"],
824
+ metadata: {},
825
+ }));
826
+ results.capture_session_signal = capture;
827
+ assert.equal(capture.ok, true);
828
+ results.list_signals = toolResultJson(await tools.list_signals.handler({ limit: 10 }));
829
+ assert.equal(results.list_signals.ok, true);
830
+ assert.equal(results.list_signals.signals.length, 1);
831
+ results.get_signal = toolResultJson(await tools.get_signal.handler({ id: String(capture.id) }));
832
+ assert.equal(results.get_signal.ok, true);
833
+ assert.equal(results.get_signal.signal.summary, "Implemented every free tool smoke path.");
834
+ const created = toolResultJson(await tools.create_decision.handler({ content: "Keep free-tool smoke coverage explicit.", echo: "summary" }));
835
+ results.create_decision = created;
836
+ assert.equal(created.ok, true);
837
+ assert.match(created.id, /^d_/);
838
+ results.get_decision = toolResultJson(await tools.get_decision.handler({ id: created.id }));
839
+ assert.equal(results.get_decision.ok, true);
840
+ results.update_decision = toolResultJson(await tools.update_decision.handler({
841
+ id: created.id,
842
+ headline: "Keep free-tool smoke coverage explicit",
843
+ why: "Acceptance requires every free tool to run.",
844
+ status: "committed",
845
+ echo: "summary",
846
+ }));
847
+ assert.equal(results.update_decision.ok, true);
848
+ results.list_decisions = toolResultJson(await tools.list_decisions.handler({ limit: 10 }));
849
+ assert.equal(results.list_decisions.ok, true);
850
+ assert.equal(results.list_decisions.decisions.length >= 1, true);
851
+ results.review_decisions = toolResultJson(await tools.review_decisions.handler({ format: "markdown", limit: 10 }));
852
+ assert.equal(results.review_decisions.ok, true);
853
+ assert.match(results.review_decisions.rendered, /# Decisions/);
854
+ results.review_session = toolResultJson(await tools.review_session.handler({ sessionId: "session-smoke", format: "markdown" }));
855
+ assert.equal(results.review_session.ok, true);
856
+ assert.match(results.review_session.rendered, /Session Review/);
857
+ results.view_timeline = toolResultJson(await tools.view_timeline.handler({ scope: "session" }));
858
+ assert.equal(results.view_timeline.ok, true);
859
+ assert.equal(results.view_timeline.points.some((point) => point.x === "Other"), false);
860
+ results.recap = toolResultJson(await tools.recap.handler({ sessionId: "session-smoke", format: "digest" }));
861
+ assert.equal(results.recap.ok, true);
862
+ assert.match(results.recap.rendered, /Session Digest/);
863
+ results.coach = toolResultJson(await tools.coach.handler({ sessionId: "session-smoke", scope: "session" }));
864
+ assert.equal(results.coach.ok, true);
865
+ assert.match(results.coach.rendered, /Decision quality score/);
866
+ results.promote_signal_to_decision = toolResultJson(await tools.promote_signal_to_decision.handler({
867
+ signalId: capture.id,
868
+ status: "committed",
869
+ why: "The signal records the implementation.",
870
+ }));
871
+ assert.equal(results.promote_signal_to_decision.ok, true);
872
+ assert.equal(results.promote_signal_to_decision.linkedSignalId, capture.id);
873
+ results.delete_decision = toolResultJson(await tools.delete_decision.handler({
874
+ id: created.id,
875
+ confirmText: created.id,
876
+ }));
877
+ assert.equal(results.delete_decision.ok, true);
878
+ for (const [toolName, result] of Object.entries(results)) {
879
+ assert.equal(result.ok, true, toolName);
880
+ assert.equal(JSON.stringify(result).length < 15000, true, `${toolName} should stay compact`);
881
+ }
882
+ });
883
+ });
884
+ test("free review_session markdown is capped and json is cursor-paginated with a 3-session limit", async () => {
885
+ await withFreeEnv(async () => {
886
+ const server = createAskTheWMcpServer({ sendStartupHeartbeat: false });
887
+ const tools = server._registeredTools;
888
+ for (let sessionIndex = 1; sessionIndex <= 4; sessionIndex += 1) {
889
+ for (let sequence = 1; sequence <= 55; sequence += 1) {
890
+ await tools.capture_session_signal.handler({
891
+ sessionId: `session-${sessionIndex}`,
892
+ sequence,
893
+ kind: sequence % 5 === 0 ? "verification_result" : "session_checkpoint",
894
+ summary: `Signal ${sequence} for session ${sessionIndex}`,
895
+ evidence: [],
896
+ filesTouched: [],
897
+ commandsRun: [],
898
+ metadata: {},
899
+ });
900
+ }
901
+ }
902
+ const markdown = toolResultJson(await tools.review_session.handler({ sessionId: "session-4", format: "markdown" }));
903
+ assert.equal(markdown.ok, true);
904
+ assert.equal(markdown.rendered.split("\n").length <= 300, true);
905
+ assert.match(markdown.rendered, /Signals By Kind/);
906
+ assert.equal("signals" in markdown, false);
907
+ const json = toolResultJson(await tools.review_session.handler({ sessionId: "session-4", format: "json" }));
908
+ assert.equal(json.ok, true);
909
+ assert.equal(json.signals.length, 50);
910
+ assert.equal(typeof json.nextCursor, "string");
911
+ const capped = toolResultJson(await tools.review_session.handler({ sessionId: "session-1", format: "markdown" }));
912
+ assert.equal(capped.ok, false);
913
+ assert.equal(capped.code, "free_tier_limit");
914
+ assert.equal(capped.limit, 3);
915
+ assert.match(capped.upgradeUrl, /askthew\.com\/mcp/);
916
+ });
917
+ });
918
+ test("paid tools return canonical paywall envelope before transport in free mode", async () => {
919
+ await withFreeEnv(async () => {
920
+ const server = createAskTheWMcpServer({ sendStartupHeartbeat: false });
921
+ const tools = server._registeredTools;
922
+ for (const toolName of [
923
+ "list_outcomes",
924
+ "get_outcome",
925
+ "list_outcome_signals",
926
+ "create_outcome",
927
+ "update_outcome",
928
+ "delete_outcome",
929
+ "get_north_star",
930
+ "update_north_star",
931
+ "export_decisions",
932
+ ]) {
933
+ const payload = toolName === "get_outcome" || toolName === "list_outcome_signals"
934
+ ? { id: "o1" }
935
+ : toolName === "create_outcome"
936
+ ? { name: "Reduce churn" }
937
+ : toolName === "update_outcome"
938
+ ? { id: "o1", summary: "New" }
939
+ : toolName === "delete_outcome"
940
+ ? { id: "o1", confirmText: "Reduce churn" }
941
+ : toolName === "update_north_star"
942
+ ? { metric: "Active users", current: "1", target: "10", reason: "test" }
943
+ : {};
944
+ const response = toolResultJson(await tools[toolName].handler(payload));
945
+ assert.equal(response.ok, false, toolName);
946
+ assert.equal(response.code, "free_tier_paid_feature", toolName);
947
+ assert.equal(response.tool, toolName, toolName);
948
+ assert.match(response.upgradeUrl, /askthew\.com\/mcp/, toolName);
949
+ assert.equal(response.supportEmail, "support@askthew.com", toolName);
950
+ }
951
+ });
952
+ });