@askthew/mcp-plugin 0.4.10 → 0.4.11

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/dist/index.js CHANGED
@@ -1,18 +1,11 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { spawn } from "node:child_process";
4
- import fs from "node:fs";
5
3
  import { createRequire } from "node:module";
6
- import path from "node:path";
7
- import { fileURLToPath } from "node:url";
8
4
  import { z } from "zod";
5
+ import { AskTheWCloudClient, completeSignup, startSignup } from "./cloud-client.js";
6
+ import { Outbox, sendOutboxPayload } from "./outbox.js";
7
+ import { redactSignalPayload } from "./redaction.js";
9
8
  import { resolvePluginScope } from "./scope.js";
10
- import { resolveMcpMode } from "./lib/free-tier-policy.js";
11
- import { LocalStore } from "./lib/local-store.js";
12
- import { buildTelemetryPayload, flushTelemetryOutbox } from "./lib/telemetry.js";
13
- import { ensureLocalIdentity } from "./lib/local-identity.js";
14
- import { paidFeatureNudge, toolJson } from "./lib/upgrade-nudge.js";
15
- import { configPath, readJsonFile } from "./lib/paths.js";
16
9
  const requirePackageJson = createRequire(import.meta.url);
17
10
  function packageVersion() {
18
11
  try {
@@ -27,12 +20,8 @@ const evidenceRoleSchema = z.enum(["user", "assistant", "system"]);
27
20
  const evidenceEntrySchema = z.object({
28
21
  role: evidenceRoleSchema,
29
22
  excerpt: z.string().min(1).max(2000),
30
- kind: z.enum(["excerpt", "diff", "prompt_diff"]).optional(),
31
- diff: z.string().max(12000).optional(),
32
- before: z.string().max(6000).optional(),
33
- after: z.string().max(6000).optional(),
34
23
  });
35
- const sessionSignalKindSchema = z.enum([
24
+ const signalKindSchema = z.enum([
36
25
  "setup_complete",
37
26
  "session_checkpoint",
38
27
  "direction_change",
@@ -40,1394 +29,207 @@ const sessionSignalKindSchema = z.enum([
40
29
  "verification_result",
41
30
  "final_summary",
42
31
  ]);
32
+ const idempotencyKeySchema = z
33
+ .string()
34
+ .min(16)
35
+ .refine((value) => !/^[A-Za-z0-9_.:/-]+-\d+$/.test(value), {
36
+ message: "Use a ULID or 16+ char random key, not sessionId-sequence.",
37
+ });
43
38
  export const codingSessionSignalSchema = z.object({
44
39
  sessionId: z.string().min(1),
45
40
  sequence: z.number().int().nonnegative(),
46
- kind: sessionSignalKindSchema,
41
+ kind: signalKindSchema,
47
42
  summary: z.string().min(1).max(2000),
48
- evidence: z
49
- .array(evidenceEntrySchema)
50
- .default([]),
43
+ idempotencyKey: idempotencyKeySchema,
44
+ clientId: z.enum(["claude_code", "codex", "cursor", "web_agent"]).optional(),
45
+ scopeKey: z.string().min(1).default("global"),
46
+ evidence: z.array(evidenceEntrySchema).default([]),
51
47
  filesTouched: z.array(z.string().min(1).max(500)).default([]),
52
48
  commandsRun: z.array(z.string().min(1).max(500)).default([]),
53
49
  metadata: z.record(z.string(), z.unknown()).default({}),
54
50
  });
55
- export const provenanceSignalSchema = z.object({
56
- source: z.string().min(1),
57
- decision: z.string().min(1),
58
- rationale: z.string().min(1),
59
- confidence: z.number().min(0).max(1),
60
- framework: z.string().optional(),
61
- filesAffected: z.array(z.string()).default([]),
62
- sessionId: z.string().min(1),
63
- pendingApproval: z.boolean().optional(),
64
- originatingPrompt: z.string().optional(),
65
- installToken: z.string().optional(),
66
- metadata: z.record(z.string(), z.unknown()).default({}),
51
+ const decisionSchema = z.object({
52
+ content: z.string().min(1).max(4000),
53
+ sourceSignalIds: z.array(z.string().uuid()).default([]),
54
+ status: z.enum(["proposed", "committed", "shipped", "abandoned"]).default("proposed"),
55
+ outcomeId: z.string().uuid().optional(),
67
56
  });
68
- const REDACTION_PATTERNS = [
69
- { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
70
- { name: "aws_secret_key", pattern: /\b[A-Za-z0-9+/]{40}\b/g },
71
- { name: "gcp_api_key", pattern: /\bAIza[0-9A-Za-z\-_]{35}\b/g },
72
- { name: "azure_storage_key", pattern: /[A-Za-z0-9+/]{86}==/g },
73
- { name: "stripe_key", pattern: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
74
- { name: "sendgrid_key", pattern: /\bSG\.[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{22,}\b/g },
75
- { name: "twilio_key", pattern: /\bSK[0-9a-fA-F]{32}\b/g },
76
- { name: "slack_token", pattern: /\bxox[baprs]-[0-9A-Za-z\-]{10,}\b/g },
77
- { name: "slack_webhook", pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g },
78
- { name: "openai_env_assignment", pattern: /\bOPENAI_API_KEY\s*[=:]\s*["']?sk-[A-Za-z0-9_-]{20,}["']?/g },
79
- { name: "openai_key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
80
- { name: "bearer_token", pattern: /\bBearer\s+[A-Za-z0-9._-]+\b/g },
81
- { name: "anthropic_key", pattern: /\bsk-ant-[A-Za-z0-9\-_]{32,}\b/g },
82
- { name: "github_pat_classic", pattern: /\bghp_[A-Za-z0-9]{36}\b/g },
83
- { name: "github_pat_fine", pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
84
- { name: "github_oauth", pattern: /\bgho_[A-Za-z0-9]{36}\b/g },
85
- { name: "github_app_token", pattern: /\bghs_[A-Za-z0-9]{36}\b/g },
86
- { name: "gitlab_pat", pattern: /\bglpat-[A-Za-z0-9\-_]{20,}\b/g },
87
- { name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
88
- { name: "pem_private_key", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
89
- { name: "db_dsn_with_creds", pattern: /(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis):\/\/[^:]+:[^@\s]+@[^\s"'`]+/gi },
90
- { name: "atw_token", pattern: /\b(?:atw|askthew)_[a-z]+_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_[0-9a-f]{16,}\b/gi },
91
- { name: "uuid_token", pattern: /\b\w{2,20}_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:_[A-Za-z0-9]{8,})?\b/g },
92
- { name: "env_var_assignment", pattern: /(?:^|[\s;|&])(?:export\s+)?[A-Z][A-Z0-9_]{4,}(?:_KEY|_TOKEN|_SECRET|_PASSWORD|_PASS|_CREDENTIAL|_API_KEY|_PRIVATE|_AUTH)\s*[=:]\s*["']?[A-Za-z0-9+/=._~-]{8,}["']?/gm },
93
- { name: "email", pattern: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g },
94
- { name: "us_phone", pattern: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g },
95
- { name: "ssn", pattern: /\b(?!000|666|9\d{2})\d{3}[-\s](?!00)\d{2}[-\s](?!0{4})\d{4}\b/g },
96
- { name: "credit_card", pattern: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b/g },
97
- ];
98
- const SENSITIVE_QUERY_PARAMS = new Set([
99
- "token",
100
- "key",
101
- "api_key",
102
- "apikey",
103
- "secret",
104
- "password",
105
- "passwd",
106
- "pass",
107
- "auth",
108
- "access_token",
109
- "refresh_token",
110
- "id_token",
111
- "client_secret",
112
- "authorization",
113
- "credential",
114
- "sig",
115
- "signature",
116
- ]);
117
- const SENSITIVE_FLAG_NAMES = new Set([
118
- "token",
119
- "password",
120
- "passwd",
121
- "pass",
122
- "secret",
123
- "key",
124
- "api-key",
125
- "apikey",
126
- "api_key",
127
- "auth",
128
- "access-token",
129
- "private-key",
130
- "credential",
131
- "credentials",
132
- "authorization",
133
- "client-secret",
134
- "client_secret",
135
- ]);
136
- const SENSITIVE_PATH_SEGMENTS = new Set([
137
- ".env",
138
- ".ssh",
139
- ".aws",
140
- ".gcp",
141
- ".azure",
142
- ".gnupg",
143
- ".pgp",
144
- "secrets",
145
- "secret",
146
- "credentials",
147
- "credential",
148
- "private",
149
- "keys",
150
- "certs",
151
- "certificates",
152
- "vault",
153
- ".netrc",
154
- ".npmrc",
155
- ".pypirc",
156
- ".docker",
157
- "kubeconfig",
158
- ".kube",
159
- ]);
160
- const ENTROPY_THRESHOLD = 4.5;
161
- const ENTROPY_TOKEN_PATTERN = /[A-Za-z0-9+/=_\-]{20,}/g;
162
- export function loadAskTheWConfig(env = process.env) {
163
- return readJsonFile(configPath(env)) ?? {};
164
- }
165
- function isRedactionEnabled() {
166
- return loadAskTheWConfig().redaction?.enabled !== false;
167
- }
168
- function sanitizeUrl(raw) {
169
- try {
170
- const url = new URL(raw);
171
- if (url.username || url.password) {
172
- url.username = "[REDACTED]";
173
- url.password = "[REDACTED]";
174
- }
175
- for (const key of Array.from(url.searchParams.keys())) {
176
- if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) {
177
- url.searchParams.set(key, "[REDACTED]");
178
- }
179
- }
180
- return url.toString().replace(/%5BREDACTED%5D/gi, "[REDACTED]");
181
- }
182
- catch {
183
- return raw;
184
- }
185
- }
186
- function sanitizeUrls(text) {
187
- return text.replace(/https?:\/\/[^\s"'`<>)\\]*/g, (match) => sanitizeUrl(match));
188
- }
189
- function scrubCommandFlags(command) {
190
- let result = command.replace(/(-{1,2})([\w-]+)([ =])(["'])([^"']+)\4/g, (match, dashes, flagName, separator, quote) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
191
- ? `${dashes}${flagName}${separator}${quote}[REDACTED]${quote}`
192
- : match);
193
- result = result.replace(/(-{1,2})([\w-]+)([ =])([^\s"'`]+)/g, (match, dashes, flagName, separator) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
194
- ? `${dashes}${flagName}${separator}[REDACTED]`
195
- : match);
196
- return result;
197
- }
198
- function shannonEntropy(value) {
199
- const freq = new Map();
200
- for (const char of value) {
201
- freq.set(char, (freq.get(char) ?? 0) + 1);
202
- }
203
- let entropy = 0;
204
- for (const count of freq.values()) {
205
- const probability = count / value.length;
206
- entropy -= probability * Math.log2(probability);
207
- }
208
- return entropy;
209
- }
210
- function looksLikeSecret(token) {
211
- const hasDigit = /\d/.test(token);
212
- const hasUpper = /[A-Z]/.test(token);
213
- const hasLower = /[a-z]/.test(token);
214
- const hasSpecial = /[+/=_\-]/.test(token);
215
- return hasDigit && hasLower && (hasUpper || hasSpecial);
216
- }
217
- function redactHighEntropyTokens(text) {
218
- return text.replace(ENTROPY_TOKEN_PATTERN, (match) => {
219
- if (match.includes("/") && match.length < 60) {
220
- return match;
221
- }
222
- if (!looksLikeSecret(match)) {
223
- return match;
224
- }
225
- return shannonEntropy(match) >= ENTROPY_THRESHOLD ? "[REDACTED]" : match;
226
- });
227
- }
228
- function redactTargetedPatterns(text) {
229
- return REDACTION_PATTERNS.reduce((accumulator, entry) => {
230
- entry.pattern.lastIndex = 0;
231
- return accumulator.replace(entry.pattern, "[REDACTED]");
232
- }, text);
233
- }
234
- function redactRawSignalText(text) {
235
- return redactTargetedPatterns(sanitizeUrls(text));
236
- }
237
- function redactOperationalContext(text) {
238
- return redactHighEntropyTokens(scrubCommandFlags(redactRawSignalText(text)));
239
- }
240
- function sanitizeFilePath(filePath) {
241
- let result = filePath.replace(/^(?:[A-Za-z]:\\|\/(?:Users|home|root|var|etc)\/[^/]+\/)/, "~/");
242
- result = redactRawSignalText(result);
243
- const segments = result.split(/[/\\]/);
244
- if (segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()))) {
245
- const basename = segments[segments.length - 1] ?? result;
246
- return `[SENSITIVE_PATH]/${basename}`;
247
- }
248
- return result;
249
- }
250
- function redactMetadata(value, key) {
251
- if (typeof value === "string") {
252
- const redacted = redactOperationalContext(value);
253
- if (key && /(?:^|_)(?:path|root|file|dir|directory)(?:$|_)/i.test(key)) {
254
- return sanitizeFilePath(redacted);
255
- }
256
- return redacted;
257
- }
258
- if (Array.isArray(value)) {
259
- return value.map((entry) => redactMetadata(entry));
260
- }
261
- if (typeof value === "object" && value !== null) {
262
- return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [entryKey, redactMetadata(entry, entryKey)]));
263
- }
264
- return value;
265
- }
266
- export function redactProvenanceSignal(input) {
267
- const parsed = provenanceSignalSchema.parse(input);
268
- if (!isRedactionEnabled()) {
269
- return parsed;
270
- }
57
+ function toolJson(value) {
271
58
  return {
272
- ...parsed,
273
- decision: redactRawSignalText(parsed.decision),
274
- rationale: redactRawSignalText(parsed.rationale),
275
- framework: typeof parsed.framework === "string" ? redactRawSignalText(parsed.framework) : undefined,
276
- filesAffected: parsed.filesAffected.map((filePath) => sanitizeFilePath(filePath)),
277
- originatingPrompt: typeof parsed.originatingPrompt === "string"
278
- ? redactOperationalContext(parsed.originatingPrompt)
279
- : undefined,
280
- installToken: typeof parsed.installToken === "string"
281
- ? redactOperationalContext(parsed.installToken)
282
- : undefined,
283
- metadata: redactMetadata(parsed.metadata),
284
- };
285
- }
286
- export function redactCodingSessionSignal(input) {
287
- const parsed = codingSessionSignalSchema.parse(input);
288
- if (!isRedactionEnabled()) {
289
- return parsed;
290
- }
291
- return {
292
- ...parsed,
293
- summary: redactRawSignalText(parsed.summary),
294
- evidence: parsed.evidence.map((entry) => ({
295
- ...entry,
296
- excerpt: redactOperationalContext(entry.excerpt),
297
- diff: typeof entry.diff === "string" ? redactOperationalContext(entry.diff) : undefined,
298
- before: typeof entry.before === "string" ? redactOperationalContext(entry.before) : undefined,
299
- after: typeof entry.after === "string" ? redactOperationalContext(entry.after) : undefined,
300
- })),
301
- filesTouched: parsed.filesTouched.map((filePath) => sanitizeFilePath(filePath)),
302
- commandsRun: parsed.commandsRun.map((command) => redactOperationalContext(command)),
303
- metadata: redactMetadata(parsed.metadata),
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: JSON.stringify(value, null, 2),
63
+ },
64
+ ],
304
65
  };
305
66
  }
306
- function apiBaseUrl(override) {
307
- return override?.trim() || process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com";
308
- }
309
- function normalizeClientId(value) {
310
- return String(value ?? "")
311
- .trim()
312
- .toLowerCase()
313
- .replace(/[^a-z0-9]+/g, "_")
314
- .replace(/^_+|_+$/g, "")
315
- .slice(0, 64);
316
- }
317
- export function normalizeInstallTokenInput(token) {
318
- return String(token ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
319
- }
320
- const echoSchema = z.enum(["summary", "full"]).optional();
321
- const cursorSchema = z.string().optional();
322
- const idempotencyKeySchema = z.string().min(1).max(200).optional();
323
- const maxCharsSchema = z.number().int().positive().max(100000).optional();
324
- const LIMITED_LOCAL_TOOL_LIMIT = 3;
325
- const LIMIT_REACHED_MESSAGE = "Limit reached. Upgrade to the paid plan.";
326
- function traceId() {
327
- return `trace_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
328
- }
329
- function structuredError(input) {
330
- return {
67
+ function needsSignup() {
68
+ return toolJson({
331
69
  ok: false,
332
- code: input.code,
333
- message: input.message,
334
- retryable: Boolean(input.retryable),
335
- hint: input.hint ?? "",
336
- traceId: input.traceId ?? traceId(),
337
- ...(typeof input.status === "number" ? { status: input.status } : {}),
338
- ...(input.extra ?? {}),
339
- };
340
- }
341
- function withResponseShape(route, responseShape = "v2") {
342
- const [path, query = ""] = route.split("?");
343
- const searchParams = new URLSearchParams(query);
344
- searchParams.set("response_shape", responseShape);
345
- const nextQuery = searchParams.toString();
346
- return nextQuery ? `${path}?${nextQuery}` : path;
347
- }
348
- function routeWithQuery(route, params) {
349
- const searchParams = new URLSearchParams();
350
- for (const [key, value] of Object.entries(params)) {
351
- if (value === undefined || value === null || value === "") {
352
- continue;
353
- }
354
- searchParams.set(key, String(value));
355
- }
356
- const query = searchParams.toString();
357
- return query ? `${route}?${query}` : route;
358
- }
359
- function credentials(overrides) {
360
- const legacyHostType = process.env.ASKTHEW_HOST_TYPE?.trim();
361
- const hostType = legacyHostType === "claude_code" || legacyHostType === "codex" || legacyHostType === "cursor"
362
- ? legacyHostType
363
- : undefined;
364
- const overrideHostType = overrides?.hostType;
365
- const clientId = normalizeClientId(overrides?.clientId) ||
366
- normalizeClientId(process.env.ASKTHEW_CLIENT_ID?.trim()) ||
367
- overrideHostType ||
368
- hostType ||
369
- "mcp_client";
370
- return {
371
- installToken: normalizeInstallTokenInput(overrides?.installToken) ||
372
- normalizeInstallTokenInput(process.env.ASKTHEW_INSTALL_TOKEN),
373
- userId: overrides?.userId?.trim() || process.env.ASKTHEW_USER_ID?.trim(),
374
- apiKey: overrides?.apiKey?.trim() || process.env.ASKTHEW_API_KEY?.trim(),
375
- serverName: overrides?.serverName?.trim() || process.env.ASKTHEW_SERVER_NAME?.trim(),
376
- clientId,
377
- clientLabel: overrides?.clientLabel?.trim() || process.env.ASKTHEW_CLIENT_LABEL?.trim(),
378
- hostType: overrideHostType || hostType,
379
- };
380
- }
381
- function hasServerIdentity(overrides) {
382
- const { installToken, userId } = credentials(overrides);
383
- return Boolean(installToken || userId);
384
- }
385
- async function postToServer(route, payload, options, request) {
386
- if (!hasServerIdentity(options?.credentials)) {
387
- return null;
388
- }
389
- const { installToken, userId, apiKey, clientId, clientLabel, hostType } = credentials(options?.credentials);
390
- const fetcher = options?.fetchImpl ?? fetch;
391
- const method = request?.method ?? "POST";
392
- const bodyPayload = {
393
- ...payload,
394
- installToken: installToken || undefined,
395
- userId: userId || undefined,
396
- clientId,
397
- clientLabel: clientLabel || undefined,
398
- hostType: hostType || undefined,
399
- };
400
- const response = await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}${route}`, {
401
- method,
402
- headers: {
403
- ...(method === "GET" ? {} : { "Content-Type": "application/json" }),
404
- ...(request?.idempotencyKey ? { "Idempotency-Key": request.idempotencyKey } : {}),
405
- ...(installToken
406
- ? { Authorization: `Bearer ${installToken}` }
407
- : apiKey
408
- ? { Authorization: `Bearer ${apiKey}` }
409
- : {}),
410
- },
411
- ...(method === "GET" ? {} : { body: JSON.stringify(bodyPayload) }),
412
- }).catch(() => null);
413
- if (!response) {
414
- return structuredError({
415
- code: "network_error",
416
- message: "Ask The W server could not be reached.",
417
- retryable: true,
418
- hint: "Check your network connection or retry the same idempotency key.",
419
- });
420
- }
421
- const body = await response.json().catch(() => null);
422
- if (!response.ok) {
423
- if (body && typeof body === "object") {
424
- const record = body;
425
- return {
426
- ...structuredError({
427
- code: typeof record.code === "string" ? record.code : "http_error",
428
- message: typeof record.message === "string"
429
- ? record.message
430
- : typeof record.error === "string"
431
- ? record.error
432
- : `Ask The W request failed with HTTP ${response.status}.`,
433
- retryable: response.status === 429 || response.status >= 500,
434
- hint: typeof record.hint === "string"
435
- ? record.hint
436
- : response.status >= 500
437
- ? "Retry with the same Idempotency-Key if this was a write."
438
- : "Check the tool input and retry.",
439
- traceId: typeof record.traceId === "string" ? record.traceId : undefined,
440
- status: response.status,
441
- }),
442
- ...record,
443
- };
444
- }
445
- return structuredError({
446
- code: "http_error",
447
- message: `Ask The W request failed with HTTP ${response.status}.`,
448
- retryable: response.status === 429 || response.status >= 500,
449
- hint: response.status >= 500 ? "Retry with the same Idempotency-Key if this was a write." : "Check the tool input and retry.",
450
- status: response.status,
451
- });
452
- }
453
- return body;
454
- }
455
- function runtimeMetadata(options) {
456
- const scope = resolvePluginScope(process.cwd());
457
- const { serverName, clientId, clientLabel } = credentials(options?.credentials);
458
- const extraMetadata = typeof options?.runtimeMetadata === "function"
459
- ? options.runtimeMetadata()
460
- : (options?.runtimeMetadata ?? {});
461
- return {
462
- repository: scope.repoName,
463
- repo_name: scope.repoName,
464
- scope_key: localScopeKey(),
465
- ...(scope.repoRoot ? { repo_root: sanitizeFilePath(scope.repoRoot) } : {}),
466
- ...(scope.appPath ? { app_path: sanitizeFilePath(scope.appPath) } : {}),
467
- ...(scope.serviceName ? { service_name: scope.serviceName } : {}),
468
- ...(serverName ? { server_name: serverName } : {}),
469
- ...(clientId ? { client_id: clientId } : {}),
470
- ...(clientLabel ? { client_label: clientLabel } : {}),
471
- ...extraMetadata,
472
- };
473
- }
474
- function localScopeKey(cwd = process.cwd()) {
475
- const scope = resolvePluginScope(cwd);
476
- return [scope.repoRoot || scope.repoName || cwd, scope.appPath ?? "", scope.serviceName ?? ""]
477
- .filter(Boolean)
478
- .join("::")
479
- .replace(/\s+/g, " ")
480
- .slice(0, 500);
481
- }
482
- function startupSessionId(options) {
483
- const scope = resolvePluginScope(process.cwd());
484
- const { clientId } = credentials(options?.credentials);
485
- const scopeKey = [scope.repoName, scope.appPath, scope.serviceName]
486
- .filter((part) => Boolean(part))
487
- .join(":") || "workspace";
488
- return ["mcp-startup", clientId || "mcp_client", scopeKey]
489
- .join(":")
490
- .replace(/[^a-zA-Z0-9:_-]+/g, "_")
491
- .slice(0, 240);
492
- }
493
- function loginCommandHint() {
494
- for (const value of [
495
- process.env.ASKTHEW_EMAIL,
496
- process.env.GIT_AUTHOR_EMAIL,
497
- process.env.GIT_COMMITTER_EMAIL,
498
- process.env.EMAIL,
499
- ]) {
500
- const email = String(value ?? "").trim();
501
- if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
502
- return `npx -y --prefer-online --package @askthew/mcp-plugin@latest askthew-mcp identify --email ${email}`;
503
- }
504
- }
505
- return "npx -y --prefer-online --package @askthew/mcp-plugin@latest askthew-mcp identify --email <your-email>";
70
+ code: "needs_signup",
71
+ message: "Ask The W needs signup before this tool can run. Call askthew_start_signup({email}), then askthew_complete_signup({email, code}).",
72
+ });
506
73
  }
507
- async function sendStartupHeartbeat(options) {
508
- if (!hasServerIdentity(options?.credentials)) {
509
- return;
510
- }
511
- const { installToken, apiKey, clientId, clientLabel, hostType, serverName } = credentials(options?.credentials);
512
- if (!installToken) {
513
- return;
514
- }
74
+ function defaultScopeKey() {
515
75
  const scope = resolvePluginScope(process.cwd());
516
- const fetcher = options?.fetchImpl ?? fetch;
517
- await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}/api/connectors/mcp/heartbeat`, {
518
- method: "POST",
519
- headers: {
520
- "Content-Type": "application/json",
521
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
522
- },
523
- body: JSON.stringify({
524
- installToken,
525
- clientId,
526
- ...(clientLabel ? { clientLabel } : {}),
527
- hostType,
528
- ...(serverName ? { serverName } : {}),
529
- repoName: scope.repoName,
530
- ...(scope.repoRoot ? { repoRoot: scope.repoRoot } : {}),
531
- ...(scope.appPath ? { appPath: scope.appPath } : {}),
532
- ...(scope.serviceName ? { serviceName: scope.serviceName } : {}),
533
- }),
534
- }).catch(() => null);
535
- }
536
- async function sendStartupSetupSignal(options) {
537
- const sessionSignal = {
538
- sessionId: startupSessionId(options),
539
- sequence: 0,
540
- kind: "setup_complete",
541
- summary: "Ask The W plugin server started for this coding-agent session.",
542
- evidence: [],
543
- filesTouched: [],
544
- commandsRun: [],
545
- metadata: {
546
- ...runtimeMetadata(options),
547
- automated: true,
548
- operational: true,
549
- origin: "mcp_server_startup",
550
- },
551
- };
552
- await postToServer("/api/ingest/mcp", {
553
- sessionSignal: redactCodingSessionSignal(sessionSignal),
554
- }, options);
555
- }
556
- async function sendStartupSignals(options) {
557
- await sendStartupHeartbeat(options).catch(() => null);
558
- await sendStartupSetupSignal(options).catch(() => null);
76
+ if (scope.repoRoot)
77
+ return `repo:${scope.repoRoot}`;
78
+ if (scope.serviceName)
79
+ return `project:${scope.serviceName}`;
80
+ return "global";
559
81
  }
560
82
  export function createAskTheWMcpServer(options = {}) {
561
- const initialMode = resolveMcpMode();
562
- if (initialMode.mode === "free_pending_auth") {
563
- ensureLocalIdentity({
564
- emailClaim: process.env.ASKTHEW_EMAIL,
565
- apiUrl: options.apiBaseUrl,
566
- telemetryOptOut: process.env.ASKTHEW_TELEMETRY === "off",
567
- });
568
- }
569
- const resolvedMode = resolveMcpMode();
570
- const optionInstallToken = normalizeInstallTokenInput(options.credentials?.installToken);
571
- const mode = optionInstallToken
572
- ? { mode: "paid", installToken: optionInstallToken, reason: "options_install_token" }
573
- : resolvedMode;
574
- const localStore = mode.mode === "paid" ? null : LocalStore.open();
575
- if (localStore && mode.mode === "free" && mode.cliCredentials) {
576
- void flushTelemetryOutbox({
577
- store: localStore,
578
- credentials: mode.cliCredentials,
579
- apiUrl: options.apiBaseUrl,
580
- fetchImpl: options.fetchImpl,
581
- }).catch(() => null);
582
- }
583
83
  const server = new McpServer({
584
- name: "Ask The W Coding Agent Connector",
84
+ name: "Ask The W MCP",
585
85
  version: packageVersion(),
586
86
  });
587
- if (options.sendStartupHeartbeat !== false && mode.mode === "paid") {
588
- void sendStartupSignals(options);
589
- }
590
- const apiToolResponse = async (route, payload = {}, method = "GET", request) => {
591
- const upstream = await postToServer(route, payload, options, { method, idempotencyKey: request?.idempotencyKey });
592
- return {
593
- content: [
594
- {
595
- type: "text",
596
- text: JSON.stringify(upstream, null, 2),
597
- },
598
- ],
599
- };
600
- };
601
- const localResponse = (value) => toolJson(value);
602
- const localToolError = (input) => localResponse(structuredError(input));
603
- const budgetedLocalResponse = (value, maxChars) => {
604
- if (!maxChars || JSON.stringify(value, null, 2).length <= maxChars) {
605
- return localResponse(value);
606
- }
607
- const next = { ...value, truncated: true, maxChars };
608
- for (const key of ["signals", "decisions", "decisionCandidates", "matches"]) {
609
- const list = next[key];
610
- if (Array.isArray(list)) {
611
- while (list.length > 0 && JSON.stringify(next, null, 2).length > maxChars) {
612
- list.pop();
613
- }
614
- next[key] = list;
615
- }
616
- }
617
- if (typeof next.rendered === "string" && JSON.stringify(next, null, 2).length > maxChars) {
618
- const overhead = JSON.stringify({ ...next, rendered: "" }, null, 2).length + 80;
619
- next.rendered = `${next.rendered.slice(0, Math.max(0, maxChars - overhead)).trimEnd()}\n\n[truncated to max_chars=${maxChars}]`;
620
- }
621
- return localResponse(next);
622
- };
623
- const currentScopeKey = () => localScopeKey();
624
- const compactWriteResponse = (input) => localResponse({
625
- ok: input.ok !== false,
626
- id: input.id ?? null,
627
- ...(input.decisionId ? { decisionId: input.decisionId } : {}),
628
- ...(input.sessionId ? { sessionId: input.sessionId } : {}),
629
- ...(typeof input.sequence === "number" ? { sequence: input.sequence } : {}),
630
- ...(typeof input.signalCount === "number" ? { signalCount: input.signalCount } : {}),
631
- ...(input.warnings && input.warnings.length > 0 ? { warnings: input.warnings } : {}),
632
- });
633
- const upstreamId = (upstream) => {
634
- if (!upstream || typeof upstream !== "object")
635
- return null;
636
- const record = upstream;
637
- return (record.id ??
638
- record.signalId ??
639
- record.entry?.id ??
640
- record.decision?.id ??
641
- record.data?.id ??
642
- record.data?.outcome?.id ??
643
- record.data?.decision?.id ??
644
- null);
645
- };
646
- const upstreamSequence = (upstream) => {
647
- if (!upstream || typeof upstream !== "object")
648
- return null;
649
- const record = upstream;
650
- const sequence = record.sequence ?? record.entry?.sequence ?? record.data?.sequence ?? null;
651
- return typeof sequence === "number" ? sequence : null;
652
- };
653
- const upstreamFailure = (upstream) => upstream && typeof upstream === "object" && upstream.ok === false
654
- ? upstream
655
- : null;
656
- const requireFreeIdentity = () => {
657
- if (mode.mode === "unauthenticated" || mode.mode === "free_pending_auth") {
658
- const loginCommand = loginCommandHint();
659
- return localToolError({
660
- code: "free_tier_login_required",
661
- message: "Free local mode needs a local install identity before capture.",
662
- retryable: false,
663
- hint: `Run \`${loginCommand}\` or reinstall with \`--free --email <your-email>\`, then restart or reload the MCP host.`,
664
- extra: {
665
- loginCommand,
666
- supportEmail: "support@askthew.com",
667
- },
668
- });
669
- }
670
- return null;
671
- };
672
- const limitReachedResponse = (tool) => localResponse({
673
- ok: false,
674
- code: "free_tier_limit_reached",
675
- tool,
676
- message: LIMIT_REACHED_MESSAGE,
677
- limit: LIMITED_LOCAL_TOOL_LIMIT,
678
- pricingUrl: "https://askthew.com/pricing",
679
- upgradeUrl: `https://askthew.com/plugin?utm_source=mcp-plugin&utm_medium=tool-limit&utm_campaign=mcp-free&tool=${tool}`,
680
- supportEmail: "support@askthew.com",
87
+ const outbox = options.outbox ?? new Outbox();
88
+ const client = () => new AskTheWCloudClient({
89
+ apiUrl: options.apiUrl,
90
+ fetcher: options.fetcher,
91
+ env: options.env,
681
92
  });
682
- const consumeLimitedLocalUse = (tool) => {
683
- if (!localStore)
684
- return null;
685
- const key = `usage:${tool}`;
686
- const current = Number(localStore.getMeta(key) || "0");
687
- const count = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0;
688
- if (count >= LIMITED_LOCAL_TOOL_LIMIT) {
689
- return limitReachedResponse(tool);
690
- }
691
- localStore.setMeta(key, String(count + 1));
692
- return null;
693
- };
694
- server.tool("capture_session_signal", {
695
- sessionId: z.string().min(1),
696
- sequence: z.number().int().nonnegative(),
697
- kind: sessionSignalKindSchema,
698
- summary: z.string().min(1).max(2000),
699
- evidence: z
700
- .array(evidenceEntrySchema)
701
- .default([]),
702
- filesTouched: z.array(z.string().min(1).max(500)).default([]),
703
- commandsRun: z.array(z.string().min(1).max(500)).default([]),
704
- metadata: z.record(z.string(), z.unknown()).default({}),
705
- idempotencyKey: idempotencyKeySchema,
706
- echo: echoSchema,
93
+ server.tool("askthew_start_signup", {
94
+ email: z.string().email(),
95
+ clientHint: z.enum(["claude_code", "codex", "cursor", "web_agent"]).optional(),
707
96
  }, async (payload) => {
708
- const sessionSignal = redactCodingSessionSignal({
709
- ...payload,
710
- metadata: {
711
- ...runtimeMetadata(options),
712
- ...(payload.metadata ?? {}),
713
- },
97
+ const body = await startSignup({
98
+ email: payload.email,
99
+ apiUrl: options.apiUrl,
100
+ clientHint: payload.clientHint,
101
+ fetcher: options.fetcher,
714
102
  });
715
- const scopeKey = currentScopeKey();
716
- if (mode.mode !== "paid" && localStore) {
717
- const loginRequired = requireFreeIdentity();
718
- if (loginRequired) {
719
- return loginRequired;
720
- }
721
- const signal = localStore.insertSignal({
722
- sessionId: sessionSignal.sessionId,
723
- sequence: sessionSignal.sequence,
724
- kind: sessionSignal.kind,
725
- summary: sessionSignal.summary,
726
- evidence: sessionSignal.evidence,
727
- filesTouched: sessionSignal.filesTouched,
728
- commandsRun: sessionSignal.commandsRun,
729
- metadata: sessionSignal.metadata,
730
- scopeKey,
731
- });
732
- const sessionSignalCount = localStore.listSignals({
733
- sessionId: signal.sessionId,
734
- scopeKey,
735
- limit: 100000,
736
- }).length;
737
- const existingDecision = localStore.getDecisionForSignal(signal.id);
738
- const decisionCandidate = candidateFromSignal(signal, existingDecision);
739
- const autoDecision = decisionCandidate
740
- ? localStore.createDecision({
741
- rawContent: signal.summary,
742
- headline: signal.summary,
743
- status: decisionCandidate.suggestedStatus,
744
- sessionId: signal.sessionId,
745
- files: signal.filesTouched,
746
- sourceSignalIds: [signal.id],
747
- scopeKey,
748
- })
749
- : null;
750
- if (sessionSignal.kind === "final_summary" && mode.cliCredentials) {
751
- localStore.enqueueTelemetry(buildTelemetryPayload({
752
- store: localStore,
753
- credentials: mode.cliCredentials,
754
- sessionId: sessionSignal.sessionId,
755
- }));
756
- void flushTelemetryOutbox({
757
- store: localStore,
758
- credentials: mode.cliCredentials,
759
- apiUrl: options.apiBaseUrl,
760
- fetchImpl: options.fetchImpl,
761
- }).catch(() => null);
762
- }
763
- if (!payload.echo) {
764
- return compactWriteResponse({
765
- id: signal.id,
766
- decisionId: autoDecision?.id,
767
- sessionId: signal.sessionId,
768
- sequence: signal.sequence,
769
- signalCount: sessionSignalCount,
770
- });
771
- }
772
- if (payload.echo === "summary") {
773
- return localResponse({
774
- ok: true,
775
- id: signal.id,
776
- sessionId: signal.sessionId,
777
- sequence: signal.sequence,
778
- signalCount: sessionSignalCount,
779
- summary: signal.summary,
780
- kind: signal.kind,
781
- ...(autoDecision ? { decisionId: autoDecision.id } : {}),
782
- });
783
- }
784
- return localResponse({
785
- ok: true,
786
- tier: "free",
787
- signal,
788
- ...(autoDecision ? { decision: decisionWithSignals(localStore, autoDecision) } : {}),
789
- note: localStore.usingJsonFallback
790
- ? "Captured locally in JSON fallback mode because SQLite was unavailable."
791
- : "Captured locally in SQLite. Aggregate telemetry only may flush on final_summary unless opted out.",
792
- });
793
- }
794
- const upstream = await postToServer(payload.echo === "full" ? "/api/ingest/mcp" : withResponseShape("/api/ingest/mcp"), {
795
- sessionSignal,
796
- }, options, { idempotencyKey: payload.idempotencyKey });
797
- const failure = upstreamFailure(upstream);
798
- if (failure)
799
- return localResponse(failure);
800
- if (!payload.echo) {
801
- return compactWriteResponse({
802
- id: upstreamId(upstream),
803
- sessionId: sessionSignal.sessionId,
804
- sequence: sessionSignal.sequence,
805
- signalCount: upstream && typeof upstream === "object" && typeof upstream.signalCount === "number"
806
- ? upstream.signalCount
807
- : 1,
808
- });
809
- }
810
- if (payload.echo === "summary") {
811
- return localResponse({
812
- ok: true,
813
- id: upstreamId(upstream),
814
- sessionId: sessionSignal.sessionId,
815
- sequence: sessionSignal.sequence,
816
- signalCount: upstream && typeof upstream === "object" && typeof upstream.signalCount === "number"
817
- ? upstream.signalCount
818
- : 1,
819
- summary: sessionSignal.summary,
820
- kind: sessionSignal.kind,
821
- });
822
- }
823
- return {
824
- content: [
825
- {
826
- type: "text",
827
- text: JSON.stringify({
828
- ok: true,
829
- signal: sessionSignal,
830
- upstream,
831
- note: "Ask The W stores this as source material and performs inference in the app.",
832
- }, null, 2),
833
- },
834
- ],
835
- };
836
- });
837
- server.tool("list_decisions", {
838
- limit: z.number().int().positive().max(300).optional(),
839
- cursor: z.string().optional(),
840
- sessionId: z.string().optional(),
841
- status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
842
- since: z.string().optional(),
843
- compact: z.boolean().optional(),
844
- max_chars: maxCharsSchema,
845
- }, async (payload) => {
846
- if (mode.mode !== "paid" && localStore) {
847
- const loginRequired = requireFreeIdentity();
848
- if (loginRequired)
849
- return loginRequired;
850
- const decisions = localStore.listDecisions({
851
- limit: payload.limit ?? 5,
852
- cursor: payload.cursor,
853
- sessionId: payload.sessionId,
854
- status: payload.status,
855
- since: payload.since,
856
- scopeKey: currentScopeKey(),
857
- });
858
- return budgetedLocalResponse({
859
- ok: true,
860
- tier: "free",
861
- decisions: payload.compact !== false
862
- ? decisions.map((decision) => ({
863
- id: decision.id,
864
- headline: decision.headline,
865
- status: decision.status,
866
- signalIds: decision.sourceSignalIds,
867
- }))
868
- : decisions.map((decision) => decisionWithSignals(localStore, decision)),
869
- nextCursor: decisions.length >= (payload.limit ?? 5) ? decisions.at(-1)?.createdAt ?? null : null,
870
- }, payload.max_chars ?? 8000);
871
- }
872
- return apiToolResponse(routeWithQuery("/api/decisions", {
873
- limit: payload.limit ?? 5,
874
- cursor: payload.cursor,
875
- sessionId: payload.sessionId,
876
- status: payload.status,
877
- since: payload.since,
878
- compact: payload.compact ?? true,
879
- max_chars: payload.max_chars ?? 8000,
880
- }));
103
+ return toolJson(body);
881
104
  });
882
- server.tool("get_decision", {
883
- id: z.string().min(1),
105
+ server.tool("askthew_complete_signup", {
106
+ email: z.string().email(),
107
+ code: z.string().regex(/^\d{6}$/),
108
+ clientHint: z.enum(["claude_code", "codex", "cursor", "web_agent"]).optional(),
884
109
  }, async (payload) => {
885
- if (mode.mode !== "paid" && localStore) {
886
- const loginRequired = requireFreeIdentity();
887
- if (loginRequired)
888
- return loginRequired;
889
- const decision = localStore.getDecision(payload.id);
890
- return decision
891
- ? localResponse({ ok: true, tier: "free", decision: decisionWithSignals(localStore, decision) })
892
- : localToolError({
893
- code: "not_found",
894
- message: "Decision not found in the local Ask The W store.",
895
- retryable: false,
896
- hint: "Check the decision id or search/list local decisions first.",
897
- });
898
- }
899
- return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`);
110
+ const body = await completeSignup({
111
+ email: payload.email,
112
+ code: payload.code,
113
+ apiUrl: options.apiUrl,
114
+ clientHint: payload.clientHint,
115
+ tokenPurpose: "device",
116
+ fetcher: options.fetcher,
117
+ env: options.env,
118
+ });
119
+ return toolJson(body);
900
120
  });
901
- server.tool("create_decision", {
902
- content: z.string().min(1),
903
- idempotencyKey: idempotencyKeySchema,
904
- echo: echoSchema,
905
- }, async (payload) => {
906
- if (mode.mode !== "paid" && localStore) {
907
- const loginRequired = requireFreeIdentity();
908
- if (loginRequired)
909
- return loginRequired;
910
- const scopeKey = currentScopeKey();
911
- if (payload.idempotencyKey) {
912
- const existingId = localStore.getMeta(`idempotency:create_decision:${scopeKey}:${payload.idempotencyKey}`);
913
- if (existingId) {
914
- const existing = localStore.getDecision(existingId);
915
- if (existing) {
916
- return payload.echo === "full"
917
- ? localResponse({ ok: true, tier: "free", decision: decisionWithSignals(localStore, existing), idempotent: true })
918
- : compactWriteResponse({ id: existing.id, sequence: localStore.stats().decisions });
919
- }
920
- }
921
- }
922
- const decision = localStore.createDecision({
923
- rawContent: payload.content,
924
- sessionId: localStore.mostRecentSessionId({ scopeKey }),
925
- scopeKey,
926
- });
927
- if (payload.idempotencyKey) {
928
- localStore.setMeta(`idempotency:create_decision:${scopeKey}:${payload.idempotencyKey}`, decision.id);
929
- }
930
- const warnings = detectDecisionConflicts({
931
- decision,
932
- decisions: localStore.listDecisions({ limit: 100000, scopeKey }),
121
+ server.tool("capture_session_signal", codingSessionSignalSchema.shape, async (payload) => {
122
+ const currentClient = client();
123
+ if (!currentClient.hasToken())
124
+ return needsSignup();
125
+ const parsed = codingSessionSignalSchema.safeParse({
126
+ ...payload,
127
+ scopeKey: payload.scopeKey || defaultScopeKey(),
128
+ });
129
+ if (!parsed.success) {
130
+ return toolJson({
131
+ ok: false,
132
+ code: "invalid_signal",
133
+ message: parsed.error.issues.map((issue) => issue.message).join("; "),
933
134
  });
934
- if (!payload.echo) {
935
- return compactWriteResponse({
936
- id: decision.id,
937
- sequence: localStore.stats().decisions,
938
- warnings,
939
- });
940
- }
941
- return localResponse({
135
+ }
136
+ const sanitized = redactSignalPayload(parsed.data);
137
+ const localOutboxId = outbox.append(sanitized);
138
+ const result = await sendOutboxPayload(currentClient, sanitized);
139
+ if (result.kind === "sent") {
140
+ outbox.markSent(localOutboxId);
141
+ const body = result.body;
142
+ return toolJson({
942
143
  ok: true,
943
- ...(payload.echo === "summary"
944
- ? { id: decision.id, sequence: localStore.stats().decisions, headline: decision.headline, warnings }
945
- : { tier: "free", decision: decisionWithSignals(localStore, decision), warnings }),
144
+ queued: false,
145
+ id: body.id,
146
+ sequence: body.sequence ?? sanitized.sequence,
946
147
  });
947
148
  }
948
- const upstream = await postToServer(payload.echo === "full" ? "/api/decisions" : withResponseShape("/api/decisions"), {
949
- content: payload.content,
950
- }, options, { method: "POST", idempotencyKey: payload.idempotencyKey });
951
- const failure = upstreamFailure(upstream);
952
- if (failure)
953
- return localResponse(failure);
954
- if (!payload.echo) {
955
- return compactWriteResponse({
956
- id: upstreamId(upstream),
957
- sequence: upstreamSequence(upstream) ?? 1,
149
+ if (result.kind === "failed") {
150
+ outbox.markFailed(localOutboxId, result.code, result.message);
151
+ return toolJson({
152
+ ok: false,
153
+ code: result.code,
154
+ message: result.message,
958
155
  });
959
156
  }
960
- return localResponse(payload.echo === "summary"
961
- ? { ok: true, id: upstreamId(upstream), sequence: upstreamSequence(upstream) ?? 1 }
962
- : upstream);
157
+ const retryEstimateSeconds = outbox.backoff(localOutboxId, 0, result.retryAfterSeconds);
158
+ return toolJson({
159
+ ok: true,
160
+ queued: true,
161
+ local_outbox_id: localOutboxId,
162
+ retry_estimate_seconds: retryEstimateSeconds,
163
+ });
963
164
  });
964
165
  server.tool("list_signals", {
965
- limit: z.number().int().positive().max(300).optional(),
966
- cursor: z.string().optional(),
967
166
  sessionId: z.string().optional(),
968
167
  since: z.string().optional(),
969
- compact: z.boolean().optional(),
970
- max_chars: maxCharsSchema,
168
+ limit: z.number().int().min(1).max(200).default(50),
971
169
  }, async (payload) => {
972
- if (mode.mode !== "paid" && localStore) {
973
- const loginRequired = requireFreeIdentity();
974
- if (loginRequired)
975
- return loginRequired;
976
- const signals = localStore.listSignals({
977
- limit: payload.limit ?? 10,
978
- cursor: payload.cursor,
979
- sessionId: payload.sessionId,
980
- since: payload.since,
981
- scopeKey: currentScopeKey(),
982
- });
983
- return budgetedLocalResponse({
984
- ok: true,
985
- tier: "free",
986
- signals: payload.compact !== false
987
- ? signals.map((signal) => compactSignal(signal, localStore.getDecisionForSignal(signal.id)))
988
- : signals.map((signal) => signalWithDecision(localStore, signal)),
989
- nextCursor: signals.length >= (payload.limit ?? 10) ? signals.at(-1)?.capturedAt ?? null : null,
990
- }, payload.max_chars ?? 8000);
991
- }
992
- return apiToolResponse(routeWithQuery("/api/signals", {
993
- limit: payload.limit ?? 10,
994
- cursor: payload.cursor,
995
- sessionId: payload.sessionId,
996
- since: payload.since,
997
- compact: payload.compact ?? true,
998
- max_chars: payload.max_chars ?? 8000,
999
- }));
170
+ const currentClient = client();
171
+ if (!currentClient.hasToken())
172
+ return needsSignup();
173
+ const result = await currentClient.listSignals(payload);
174
+ return toolJson(result.body);
1000
175
  });
1001
- server.tool("recap", "Get a concise recap of the latest local coding-agent session.", {
1002
- format: z.enum(["digest", "standup", "share"]).default("digest"),
1003
- sessionId: z.string().optional(),
1004
- compact: z.boolean().optional(),
1005
- max_chars: maxCharsSchema,
1006
- }, async (payload) => {
1007
- if (!localStore || mode.mode === "paid")
1008
- return localResponse(paidFeatureNudge("recap"));
1009
- const loginRequired = requireFreeIdentity();
1010
- if (loginRequired)
1011
- return loginRequired;
1012
- const limited = consumeLimitedLocalUse("recap");
1013
- if (limited)
1014
- return limited;
1015
- const scopeKey = currentScopeKey();
1016
- const sessionId = payload.sessionId ?? localStore.mostRecentSessionId({ scopeKey });
1017
- const signals = sessionId ? localStore.listSignals({ sessionId, scopeKey, limit: 100000 }) : [];
1018
- const decisions = sessionId ? localStore.listDecisions({ sessionId, scopeKey, limit: 100000 }) : [];
1019
- return budgetedLocalResponse({
1020
- ok: true,
1021
- tier: "free",
1022
- sessionId,
1023
- format: payload.format,
1024
- rendered: renderRecap({ format: payload.format, signals, decisions }),
1025
- ...(payload.compact
1026
- ? { signals: signals.map((signal) => compactSignal(signal, localStore.getDecisionForSignal(signal.id))) }
1027
- : {}),
1028
- }, payload.max_chars);
176
+ server.tool("create_decision", decisionSchema.shape, async (payload) => {
177
+ const currentClient = client();
178
+ if (!currentClient.hasToken())
179
+ return needsSignup();
180
+ const result = await currentClient.createDecision(payload);
181
+ return toolJson(result.body);
1029
182
  });
1030
- server.tool("coach", "Get one concise coaching nudge for the local coding-agent session.", {
1031
- sessionId: z.string().optional(),
1032
- max_chars: maxCharsSchema,
183
+ server.tool("list_decisions", {
184
+ limit: z.number().int().min(1).max(200).default(50),
1033
185
  }, async (payload) => {
1034
- if (!localStore || mode.mode === "paid")
1035
- return localResponse(paidFeatureNudge("coach"));
1036
- const loginRequired = requireFreeIdentity();
1037
- if (loginRequired)
1038
- return loginRequired;
1039
- const limited = consumeLimitedLocalUse("coach");
1040
- if (limited)
1041
- return limited;
1042
- const scopeKey = currentScopeKey();
1043
- const sessionId = payload.sessionId ?? localStore.mostRecentSessionId({ scopeKey });
1044
- const signals = sessionId ? localStore.listSignals({ sessionId, scopeKey, limit: 100000 }) : [];
1045
- const decisions = sessionId ? localStore.listDecisions({ sessionId, scopeKey, limit: 100000 }) : [];
1046
- const coaching = buildSessionCoach({ signals, decisions });
1047
- return budgetedLocalResponse({
1048
- ok: true,
1049
- tier: "free",
1050
- sessionId,
1051
- nudge: coaching.nudge,
1052
- rendered: coaching.nudge,
1053
- }, payload.max_chars);
186
+ const currentClient = client();
187
+ if (!currentClient.hasToken())
188
+ return needsSignup();
189
+ const result = await currentClient.listDecisions(payload);
190
+ return toolJson(result.body);
1054
191
  });
1055
- server.tool("promote_signal_to_decision", "Copy a captured signal summary into a linked local decision.", {
1056
- signalId: z.union([z.string(), z.number()]),
1057
- status: z.enum(["proposed", "committed", "shipped", "abandoned"]).default("proposed"),
1058
- why: z.string().optional(),
1059
- idempotencyKey: idempotencyKeySchema,
192
+ server.tool("recap", {
193
+ since: z.string().optional(),
194
+ maxSignals: z.number().int().min(1).max(200).optional(),
1060
195
  }, async (payload) => {
1061
- if (!localStore || mode.mode === "paid")
1062
- return localResponse(paidFeatureNudge("promote_signal_to_decision"));
1063
- const loginRequired = requireFreeIdentity();
1064
- if (loginRequired)
1065
- return loginRequired;
1066
- const numericSignalId = typeof payload.signalId === "number" ? payload.signalId : Number(payload.signalId);
1067
- if (!Number.isFinite(numericSignalId)) {
1068
- return localToolError({
1069
- code: "invalid_input",
1070
- message: "Invalid signalId.",
1071
- retryable: false,
1072
- hint: "Use the numeric local signal id.",
1073
- extra: { field: "signalId" },
1074
- });
1075
- }
1076
- const signal = localStore.getSignal(numericSignalId);
1077
- if (!signal) {
1078
- return localToolError({
1079
- code: "not_found",
1080
- message: "Signal not found in the local Ask The W store.",
1081
- retryable: false,
1082
- hint: "List signals first, then pass the numeric local signal id.",
1083
- extra: { tool: "promote_signal_to_decision" },
1084
- });
196
+ const currentClient = client();
197
+ if (!currentClient.hasToken())
198
+ return needsSignup();
199
+ try {
200
+ const result = await currentClient.recap(payload);
201
+ return toolJson(result.body);
1085
202
  }
1086
- const linkedDecision = localStore.getDecisionForSignal(signal.id);
1087
- if (linkedDecision) {
1088
- const decision = localStore.updateDecision(linkedDecision.id, {
1089
- ...(payload.why !== undefined ? { why: payload.why } : {}),
1090
- status: payload.status,
1091
- }) ?? linkedDecision;
1092
- return localResponse({
1093
- ok: true,
1094
- id: decision.id,
1095
- sequence: localStore.stats().decisions,
1096
- decision: decisionWithSignals(localStore, decision),
1097
- linkedSignalId: signal.id,
1098
- reused: true,
203
+ catch {
204
+ return toolJson({
205
+ ok: false,
206
+ code: "unreachable",
207
+ message: "Couldn't reach Ask The W. Captures are queued; recap will be available when you're back online.",
1099
208
  });
1100
209
  }
1101
- if (payload.idempotencyKey) {
1102
- const existingId = localStore.getMeta(`idempotency:promote_signal_to_decision:${payload.idempotencyKey}`);
1103
- if (existingId) {
1104
- const existing = localStore.getDecision(existingId);
1105
- if (existing) {
1106
- return localResponse({
1107
- ok: true,
1108
- id: existing.id,
1109
- sequence: localStore.stats().decisions,
1110
- decision: decisionWithSignals(localStore, existing),
1111
- linkedSignalId: signal.id,
1112
- idempotent: true,
1113
- });
1114
- }
1115
- }
1116
- }
1117
- const decision = localStore.createDecision({
1118
- rawContent: signal.summary,
1119
- headline: signal.summary,
1120
- why: payload.why ?? null,
1121
- status: payload.status,
1122
- sessionId: signal.sessionId,
1123
- files: signal.filesTouched,
1124
- sourceSignalIds: [signal.id],
1125
- scopeKey: signal.scopeKey ?? currentScopeKey(),
1126
- });
1127
- if (payload.idempotencyKey) {
1128
- localStore.setMeta(`idempotency:promote_signal_to_decision:${payload.idempotencyKey}`, decision.id);
1129
- }
1130
- const warnings = detectDecisionConflicts({
1131
- decision,
1132
- decisions: localStore.listDecisions({ limit: 100000, scopeKey: decision.scopeKey }),
1133
- });
1134
- return localResponse({
1135
- ok: true,
1136
- id: decision.id,
1137
- sequence: localStore.stats().decisions,
1138
- decision: decisionWithSignals(localStore, decision),
1139
- linkedSignalId: signal.id,
1140
- warnings,
1141
- });
1142
210
  });
1143
- return server;
1144
- }
1145
- function buildSessionCoach(input) {
1146
- const verificationCount = input.signals.filter((signal) => signal.kind === "verification_result").length;
1147
- const implementationCount = input.signals.filter((signal) => signal.kind === "implementation_update").length;
1148
- const directionCount = input.signals.filter((signal) => signal.kind === "direction_change").length;
1149
- const finalSummaryCount = input.signals.filter((signal) => signal.kind === "final_summary").length;
1150
- const decisionCount = input.decisions.length;
1151
- const hasVerification = verificationCount > 0;
1152
- const hasDecision = decisionCount > 0;
1153
- const hasDirection = directionCount > 0;
1154
- const nudge = implementationCount >= 3 && verificationCount === 0
1155
- ? `You captured ${implementationCount} implementation updates but no verification_result; run one check and capture it before ending.`
1156
- : input.signals.length >= 6 && finalSummaryCount === 0
1157
- ? `You captured ${input.signals.length} signals but no final_summary; close the session with the outcome and remaining risk.`
1158
- : decisionCount === 0 && directionCount > 0
1159
- ? "Direction changed, but no decision was captured; promote the clearest direction_change signal."
1160
- : !hasDecision
1161
- ? "Promote the clearest captured signal into a decision before the trail goes stale."
1162
- : !hasVerification
1163
- ? "Capture one verification result so the decision trail records whether the work actually held."
1164
- : implementationCount > decisionCount * 3
1165
- ? "There are many implementation updates per decision; collapse the important why into one decision."
1166
- : "The trail is usable. Keep the next decision tied to a verification result.";
1167
- return { nudge };
1168
- }
1169
- function decisionalWeight(signal) {
1170
- const kindWeight = signal.kind === "direction_change" ? 4 : signal.kind === "verification_result" ? 3 : signal.kind === "implementation_update" ? 2 : 1;
1171
- const textWeight = /\b(decid|chose|commit|reject|approve|ship|verify|blocked|risk)\b/i.test(signal.summary) ? 2 : 0;
1172
- return kindWeight + textWeight;
1173
- }
1174
- function renderRecap(input) {
1175
- if (input.format === "standup") {
1176
- const blockers = input.signals.filter((signal) => /\b(block|fail|error|risk|stuck)\b/i.test(signal.summary));
1177
- return [
1178
- "# Standup Recap",
1179
- "## Yesterday",
1180
- input.decisions.length ? `- Captured ${input.decisions.length} decisions.` : "- No decisions captured.",
1181
- "## Today",
1182
- input.signals.length ? `- Review ${input.signals.length} session signals and promote the strongest one.` : "- Capture the first useful signal.",
1183
- "## Blockers",
1184
- ...(blockers.length ? blockers.slice(0, 5).map((signal) => `- ${signal.summary}`) : ["- None captured."]),
1185
- ].join("\n");
1186
- }
1187
- if (input.format === "share") {
1188
- return [
1189
- "# Ask The W Session Share",
1190
- "",
1191
- `Signals captured: ${input.signals.length}`,
1192
- `Decisions captured: ${input.decisions.length}`,
1193
- "",
1194
- "## Highlights",
1195
- ...input.signals
1196
- .slice()
1197
- .sort((left, right) => decisionalWeight(right) - decisionalWeight(left))
1198
- .slice(0, 8)
1199
- .map((signal) => `- ${signal.summary}`),
1200
- "",
1201
- "_Captured by Ask The W._",
1202
- ].join("\n");
1203
- }
1204
- return [
1205
- "# Session Digest",
1206
- "",
1207
- ...input.signals
1208
- .slice()
1209
- .sort((left, right) => decisionalWeight(right) - decisionalWeight(left))
1210
- .slice(0, 28)
1211
- .map((signal, index) => `${index + 1}. [${signal.kind}] ${signal.summary}`),
1212
- ].join("\n").split("\n").slice(0, 30).join("\n");
1213
- }
1214
- function renderSessionFeed(signals) {
1215
- if (signals.length === 0) {
1216
- return "# Session\n\nNo local signals captured yet.";
1217
- }
1218
- return [
1219
- "# Session",
1220
- "",
1221
- ...signals.map((signal) => [
1222
- `## ${signal.sequence ?? signal.id}. ${signal.kind}`,
1223
- signal.summary,
1224
- `- captured: ${signal.capturedAt}`,
1225
- signal.filesTouched.length ? `- files: ${signal.filesTouched.join(", ")}` : "- files: none",
1226
- signal.commandsRun.length ? `- commands: ${signal.commandsRun.join(" | ")}` : "- commands: none",
1227
- ].join("\n")),
1228
- ].join("\n\n");
1229
- }
1230
- function compactSignal(signal, decision) {
1231
- return {
1232
- id: signal.id,
1233
- kind: signal.kind,
1234
- summary: signal.summary,
1235
- files: signal.filesTouched,
1236
- decisionId: decision?.id ?? null,
1237
- };
1238
- }
1239
- function decisionWithSignals(store, decision) {
1240
- return {
1241
- ...decision,
1242
- contributingSignals: store.listSignalsByIds(decision.sourceSignalIds),
1243
- };
1244
- }
1245
- function signalWithDecision(store, signal) {
1246
- const decision = store.getDecisionForSignal(signal.id);
1247
- return {
1248
- ...signal,
1249
- decisionId: decision?.id ?? null,
1250
- decision: decision
1251
- ? {
1252
- id: decision.id,
1253
- headline: decision.headline,
1254
- status: decision.status,
1255
- why: decision.why,
1256
- }
1257
- : null,
1258
- };
1259
- }
1260
- function candidateFromSignal(signal, linkedDecision) {
1261
- if (linkedDecision)
1262
- return null;
1263
- const text = [signal.summary, ...signal.evidence.map((entry) => {
1264
- if (entry && typeof entry === "object") {
1265
- const record = entry;
1266
- return [record.excerpt, record.diff, record.before, record.after].filter(Boolean).join(" ");
1267
- }
1268
- return String(entry ?? "");
1269
- })].join(" ");
1270
- const hasDecisionLanguage = /\b(decid(?:e|ed|ing)?|chose|choose|commit(?:ted)?|approved?|reject(?:ed)?|let'?s go with|go with|we will|we're going to|standardize|adopt|defer|drop|keep|remove|replace)\b/i.test(text);
1271
- if (!hasDecisionLanguage && signal.kind !== "direction_change") {
1272
- return null;
1273
- }
1274
- const because = /\bbecause\b/i.test(text);
1275
- return {
1276
- id: `candidate_${signal.id}`,
1277
- signalId: signal.id,
1278
- sessionId: signal.sessionId,
1279
- summary: signal.summary,
1280
- suggestedStatus: signal.kind === "verification_result" ? "shipped" : "proposed",
1281
- why: because ? "The signal includes an explicit because/reason clause." : "The signal uses decision language.",
1282
- files: signal.filesTouched,
1283
- capturedAt: signal.capturedAt,
1284
- };
1285
- }
1286
- function normalizedDecisionTerms(text) {
1287
- const stop = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "keep", "use", "adopt", "remove", "drop", "replace", "defer", "ship", "commit"]);
1288
- return String(text ?? "")
1289
- .toLowerCase()
1290
- .replace(/[^a-z0-9\s-]/g, " ")
1291
- .split(/\s+/)
1292
- .filter((term) => term.length >= 4 && !stop.has(term));
1293
- }
1294
- function decisionPolarity(text) {
1295
- if (/\b(remove|drop|abandon|defer|reject|disable|stop|sunset|do not|don't|won't|will not)\b/i.test(text))
1296
- return "negative";
1297
- if (/\b(keep|use|adopt|enable|add|ship|commit|standardize|choose|approve|go with)\b/i.test(text))
1298
- return "positive";
1299
- return "neutral";
1300
- }
1301
- function detectDecisionConflicts(input) {
1302
- const polarity = decisionPolarity(`${input.decision.headline} ${input.decision.rawContent}`);
1303
- if (polarity === "neutral")
1304
- return [];
1305
- const terms = new Set(normalizedDecisionTerms(`${input.decision.headline} ${input.decision.rawContent}`));
1306
- if (terms.size === 0)
1307
- return [];
1308
- return input.decisions
1309
- .filter((prior) => prior.id !== input.decision.id)
1310
- .filter((prior) => !input.decision.scopeKey || prior.scopeKey === input.decision.scopeKey)
1311
- .map((prior) => {
1312
- const priorPolarity = decisionPolarity(`${prior.headline} ${prior.rawContent}`);
1313
- const priorTerms = normalizedDecisionTerms(`${prior.headline} ${prior.rawContent}`);
1314
- const overlap = priorTerms.filter((term) => terms.has(term));
1315
- return {
1316
- prior,
1317
- priorPolarity,
1318
- overlap,
1319
- };
1320
- })
1321
- .filter((entry) => entry.priorPolarity !== "neutral" && entry.priorPolarity !== polarity && entry.overlap.length > 0)
1322
- .slice(0, 3)
1323
- .map((entry) => ({
1324
- code: "possible_conflict",
1325
- message: `This may conflict with "${entry.prior.headline}".`,
1326
- conflictingDecisionId: entry.prior.id,
1327
- overlappingTerms: entry.overlap.slice(0, 5),
1328
- }));
1329
- }
1330
- const isDirectIndexExecution = Boolean(process.argv[1]) &&
1331
- (() => {
1332
- const modulePath = fileURLToPath(import.meta.url);
1333
- const invokedPath = path.resolve(process.argv[1]);
211
+ server.tool("coach", {
212
+ focus: z.string().max(1000).optional(),
213
+ }, async (payload) => {
214
+ const currentClient = client();
215
+ if (!currentClient.hasToken())
216
+ return needsSignup();
1334
217
  try {
1335
- return fs.realpathSync(invokedPath) === fs.realpathSync(modulePath);
218
+ const result = await currentClient.coach(payload);
219
+ return toolJson(result.body);
1336
220
  }
1337
221
  catch {
1338
- return invokedPath === modulePath;
222
+ return toolJson({
223
+ ok: false,
224
+ code: "unreachable",
225
+ message: "Couldn't reach Ask The W. Captures are queued; coach will be available when you're back online.",
226
+ });
1339
227
  }
1340
- })();
1341
- export async function runInitializeHandshake(input = {}) {
1342
- const modulePath = input.entrypoint ?? fileURLToPath(import.meta.url);
1343
- const child = spawn(process.execPath, [modulePath], {
1344
- cwd: process.cwd(),
1345
- env: input.env ?? process.env,
1346
- stdio: ["pipe", "pipe", "pipe"],
1347
- });
1348
- let stdout = "";
1349
- let stderr = "";
1350
- child.stdout.setEncoding("utf8");
1351
- child.stderr.setEncoding("utf8");
1352
- child.stdout.on("data", (chunk) => {
1353
- stdout += chunk;
1354
228
  });
1355
- child.stderr.on("data", (chunk) => {
1356
- stderr += chunk;
1357
- });
1358
- const initialize = {
1359
- jsonrpc: "2.0",
1360
- id: 1,
1361
- method: "initialize",
1362
- params: {
1363
- protocolVersion: "2024-11-05",
1364
- capabilities: {},
1365
- clientInfo: { name: "askthew-initialize-handshake", version: "1.0.0" },
1366
- },
1367
- };
1368
- child.stdin.write(`${JSON.stringify(initialize)}\n`);
1369
- try {
1370
- return await new Promise((resolve, reject) => {
1371
- const timeout = setTimeout(() => {
1372
- reject(new Error(`Timed out waiting for initialize response. stdout=${stdout} stderr=${stderr}`));
1373
- }, input.timeoutMs ?? 3000);
1374
- const failOnExit = (code) => {
1375
- clearTimeout(timeout);
1376
- reject(new Error(`MCP stdio server exited before initialize. code=${code} stdout=${stdout} stderr=${stderr}`));
1377
- };
1378
- const check = () => {
1379
- const lineEnd = stdout.indexOf("\n");
1380
- if (lineEnd === -1)
1381
- return;
1382
- clearTimeout(timeout);
1383
- child.off("exit", failOnExit);
1384
- const response = JSON.parse(stdout.slice(0, lineEnd));
1385
- if (response.id !== 1 || response.jsonrpc !== "2.0" || !response.result) {
1386
- reject(new Error(`Unexpected initialize response: ${JSON.stringify(response)}`));
1387
- return;
1388
- }
1389
- resolve({
1390
- serverInfoVersion: typeof response.result?.serverInfo?.version === "string"
1391
- ? response.result.serverInfo.version
1392
- : undefined,
1393
- });
1394
- };
1395
- child.stdout.on("data", check);
1396
- child.once("exit", failOnExit);
1397
- check();
1398
- });
1399
- }
1400
- finally {
1401
- child.kill();
1402
- }
229
+ return server;
1403
230
  }
1404
- if (isDirectIndexExecution) {
1405
- if (process.argv[2] === "initialize-handshake") {
1406
- runInitializeHandshake()
1407
- .then(() => {
1408
- console.log("Ask The W MCP initialize handshake succeeded.");
1409
- })
1410
- .catch((error) => {
1411
- if (error instanceof Error) {
1412
- console.error(error.message);
1413
- }
1414
- else {
1415
- console.error("Ask The W MCP initialize handshake failed.", error);
1416
- }
1417
- process.exit(1);
1418
- });
1419
- }
1420
- else {
1421
- const server = createAskTheWMcpServer();
1422
- const transport = new StdioServerTransport();
1423
- server.connect(transport).catch((error) => {
1424
- if (error instanceof Error) {
1425
- console.error(error.message);
1426
- }
1427
- else {
1428
- console.error("Ask The W MCP server failed to start.", error);
1429
- }
1430
- process.exit(1);
1431
- });
1432
- }
231
+ export async function runStdioServer(options = {}) {
232
+ const server = createAskTheWMcpServer(options);
233
+ const transport = new StdioServerTransport();
234
+ await server.connect(transport);
1433
235
  }