@askthew/mcp-plugin 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import * as fs from "node:fs";
4
- import * as path from "node:path";
5
- import { resolveFunctionalAreaFromToml, resolvePluginScope } from "./scope.js";
3
+ import { resolvePluginScope } from "./scope.js";
4
+ import { resolveMcpMode } from "./lib/free-tier-policy.js";
5
+ import { LocalStore } from "./lib/local-store.js";
6
+ import { analyzeLocalPatterns } from "./lib/tip-engine.js";
7
+ import { buildTelemetryPayload, flushTelemetryOutbox } from "./lib/telemetry.js";
8
+ import { paidDescription, paidFeatureNudge, toolJson } from "./lib/upgrade-nudge.js";
6
9
  const evidenceRoleSchema = z.enum(["user", "assistant", "system"]);
7
10
  const sessionSignalKindSchema = z.enum([
8
11
  "setup_complete",
@@ -41,85 +44,228 @@ export const provenanceSignalSchema = z.object({
41
44
  metadata: z.record(z.string(), z.unknown()).default({}),
42
45
  });
43
46
  const REDACTION_PATTERNS = [
44
- /\bAKIA[0-9A-Z]{16}\b/g,
45
- /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g,
46
- /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g,
47
- /\b[A-Za-z0-9_-]{32,}\b/g,
47
+ { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
48
+ { name: "aws_secret_key", pattern: /\b[A-Za-z0-9+/]{40}\b/g },
49
+ { name: "gcp_api_key", pattern: /\bAIza[0-9A-Za-z\-_]{35}\b/g },
50
+ { name: "azure_storage_key", pattern: /[A-Za-z0-9+/]{86}==/g },
51
+ { name: "stripe_key", pattern: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
52
+ { name: "sendgrid_key", pattern: /\bSG\.[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{22,}\b/g },
53
+ { name: "twilio_key", pattern: /\bSK[0-9a-fA-F]{32}\b/g },
54
+ { name: "slack_token", pattern: /\bxox[baprs]-[0-9A-Za-z\-]{10,}\b/g },
55
+ { name: "slack_webhook", pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g },
56
+ { name: "openai_key", pattern: /\bsk-[A-Za-z0-9]{20,}\b/g },
57
+ { name: "anthropic_key", pattern: /\bsk-ant-[A-Za-z0-9\-_]{32,}\b/g },
58
+ { name: "github_pat_classic", pattern: /\bghp_[A-Za-z0-9]{36}\b/g },
59
+ { name: "github_pat_fine", pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
60
+ { name: "github_oauth", pattern: /\bgho_[A-Za-z0-9]{36}\b/g },
61
+ { name: "github_app_token", pattern: /\bghs_[A-Za-z0-9]{36}\b/g },
62
+ { name: "gitlab_pat", pattern: /\bglpat-[A-Za-z0-9\-_]{20,}\b/g },
63
+ { name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
64
+ { name: "pem_private_key", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
65
+ { name: "db_dsn_with_creds", pattern: /(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis):\/\/[^:]+:[^@\s]+@[^\s"'`]+/gi },
66
+ { 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 },
67
+ { 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 },
68
+ { 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 },
69
+ { name: "email", pattern: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g },
70
+ { name: "us_phone", pattern: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g },
71
+ { name: "ssn", pattern: /\b(?!000|666|9\d{2})\d{3}[-\s](?!00)\d{2}[-\s](?!0{4})\d{4}\b/g },
72
+ { 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 },
48
73
  ];
49
- function redactSecrets(text) {
50
- return REDACTION_PATTERNS.reduce((accumulator, pattern) => accumulator.replace(pattern, "[REDACTED]"), text);
51
- }
52
- function redactMetadata(value) {
53
- if (typeof value === "string") {
54
- return redactSecrets(value);
55
- }
56
- if (Array.isArray(value)) {
57
- return value.map((entry) => redactMetadata(entry));
74
+ const SENSITIVE_QUERY_PARAMS = new Set([
75
+ "token",
76
+ "key",
77
+ "api_key",
78
+ "apikey",
79
+ "secret",
80
+ "password",
81
+ "passwd",
82
+ "pass",
83
+ "auth",
84
+ "access_token",
85
+ "refresh_token",
86
+ "id_token",
87
+ "client_secret",
88
+ "authorization",
89
+ "credential",
90
+ "sig",
91
+ "signature",
92
+ ]);
93
+ const SENSITIVE_FLAG_NAMES = new Set([
94
+ "token",
95
+ "password",
96
+ "passwd",
97
+ "pass",
98
+ "secret",
99
+ "key",
100
+ "api-key",
101
+ "apikey",
102
+ "api_key",
103
+ "auth",
104
+ "access-token",
105
+ "private-key",
106
+ "credential",
107
+ "credentials",
108
+ "authorization",
109
+ "client-secret",
110
+ "client_secret",
111
+ ]);
112
+ const SENSITIVE_PATH_SEGMENTS = new Set([
113
+ ".env",
114
+ ".ssh",
115
+ ".aws",
116
+ ".gcp",
117
+ ".azure",
118
+ ".gnupg",
119
+ ".pgp",
120
+ "secrets",
121
+ "secret",
122
+ "credentials",
123
+ "credential",
124
+ "private",
125
+ "keys",
126
+ "certs",
127
+ "certificates",
128
+ "vault",
129
+ ".netrc",
130
+ ".npmrc",
131
+ ".pypirc",
132
+ ".docker",
133
+ "kubeconfig",
134
+ ".kube",
135
+ ]);
136
+ const ENTROPY_THRESHOLD = 4.5;
137
+ const ENTROPY_TOKEN_PATTERN = /[A-Za-z0-9+/=_\-]{20,}/g;
138
+ function sanitizeUrl(raw) {
139
+ try {
140
+ const url = new URL(raw);
141
+ if (url.username || url.password) {
142
+ url.username = "[REDACTED]";
143
+ url.password = "[REDACTED]";
144
+ }
145
+ for (const key of Array.from(url.searchParams.keys())) {
146
+ if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) {
147
+ url.searchParams.set(key, "[REDACTED]");
148
+ }
149
+ }
150
+ return url.toString().replace(/%5BREDACTED%5D/gi, "[REDACTED]");
58
151
  }
59
- if (typeof value === "object" && value !== null) {
60
- return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactMetadata(entry)]));
152
+ catch {
153
+ return raw;
61
154
  }
62
- return value;
63
155
  }
64
- export function inferFunctionalArea(signal) {
65
- const explicit = process.env.ASKTHEW_FUNCTIONAL_AREA?.trim();
66
- if (explicit) {
67
- return explicit;
68
- }
69
- const configuredArea = resolveFunctionalAreaFromToml(process.cwd());
70
- if (configuredArea) {
71
- return configuredArea;
72
- }
73
- const files = (signal.filesAffected ?? []).map((file) => file.toLowerCase());
74
- if (files.length > 0 && files.every((file) => file.includes("test"))) {
75
- return "QA";
156
+ function sanitizeUrls(text) {
157
+ return text.replace(/https?:\/\/[^\s"'`<>)\\]*/g, (match) => sanitizeUrl(match));
158
+ }
159
+ function scrubCommandFlags(command) {
160
+ let result = command.replace(/(-{1,2})([\w-]+)([ =])(["'])([^"']+)\4/g, (match, dashes, flagName, separator, quote) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
161
+ ? `${dashes}${flagName}${separator}${quote}[REDACTED]${quote}`
162
+ : match);
163
+ result = result.replace(/(-{1,2})([\w-]+)([ =])([^\s"'`]+)/g, (match, dashes, flagName, separator) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
164
+ ? `${dashes}${flagName}${separator}[REDACTED]`
165
+ : match);
166
+ return result;
167
+ }
168
+ function shannonEntropy(value) {
169
+ const freq = new Map();
170
+ for (const char of value) {
171
+ freq.set(char, (freq.get(char) ?? 0) + 1);
76
172
  }
77
- if (files.length > 0 && files.every((file) => file.includes("marketing") || file.includes("content"))) {
78
- return "Marketing";
173
+ let entropy = 0;
174
+ for (const count of freq.values()) {
175
+ const probability = count / value.length;
176
+ entropy -= probability * Math.log2(probability);
79
177
  }
80
- if (files.length > 0 && files.every((file) => file.includes("design"))) {
81
- return "Design";
178
+ return entropy;
179
+ }
180
+ function looksLikeSecret(token) {
181
+ const hasDigit = /\d/.test(token);
182
+ const hasUpper = /[A-Z]/.test(token);
183
+ const hasLower = /[a-z]/.test(token);
184
+ const hasSpecial = /[+/=_\-]/.test(token);
185
+ return hasDigit && hasLower && (hasUpper || hasSpecial);
186
+ }
187
+ function redactHighEntropyTokens(text) {
188
+ return text.replace(ENTROPY_TOKEN_PATTERN, (match) => {
189
+ if (match.includes("/") && match.length < 60) {
190
+ return match;
191
+ }
192
+ if (!looksLikeSecret(match)) {
193
+ return match;
194
+ }
195
+ return shannonEntropy(match) >= ENTROPY_THRESHOLD ? "[REDACTED]" : match;
196
+ });
197
+ }
198
+ function redactTargetedPatterns(text) {
199
+ return REDACTION_PATTERNS.reduce((accumulator, entry) => {
200
+ entry.pattern.lastIndex = 0;
201
+ return accumulator.replace(entry.pattern, "[REDACTED]");
202
+ }, text);
203
+ }
204
+ function redactRawSignalText(text) {
205
+ return redactTargetedPatterns(sanitizeUrls(text));
206
+ }
207
+ function redactOperationalContext(text) {
208
+ return redactHighEntropyTokens(scrubCommandFlags(redactRawSignalText(text)));
209
+ }
210
+ function sanitizeFilePath(filePath) {
211
+ let result = filePath.replace(/^(?:[A-Za-z]:\\|\/(?:Users|home|root|var|etc)\/[^/]+\/)/, "~/");
212
+ result = redactRawSignalText(result);
213
+ const segments = result.split(/[/\\]/);
214
+ if (segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()))) {
215
+ const basename = segments[segments.length - 1] ?? result;
216
+ return `[SENSITIVE_PATH]/${basename}`;
82
217
  }
83
- if (fs.existsSync(path.resolve(process.cwd(), "figma.config")) || fs.existsSync(path.resolve(process.cwd(), ".sketch"))) {
84
- return "Design";
218
+ return result;
219
+ }
220
+ function redactMetadata(value, key) {
221
+ if (typeof value === "string") {
222
+ const redacted = redactOperationalContext(value);
223
+ if (key && /(?:^|_)(?:path|root|file|dir|directory)(?:$|_)/i.test(key)) {
224
+ return sanitizeFilePath(redacted);
225
+ }
226
+ return redacted;
85
227
  }
86
- if (fs.existsSync(path.resolve(process.cwd(), "campaign.yml"))) {
87
- return "Marketing";
228
+ if (Array.isArray(value)) {
229
+ return value.map((entry) => redactMetadata(entry));
88
230
  }
89
- const repoMarkers = ["package.json", "pyproject.toml", "go.mod", "Cargo.toml"];
90
- if (repoMarkers.some((marker) => fs.existsSync(path.resolve(process.cwd(), marker)))) {
91
- return "Engineering";
231
+ if (typeof value === "object" && value !== null) {
232
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [entryKey, redactMetadata(entry, entryKey)]));
92
233
  }
93
- return "Engineering";
234
+ return value;
94
235
  }
95
236
  export function redactProvenanceSignal(input) {
96
237
  const parsed = provenanceSignalSchema.parse(input);
97
238
  return {
98
239
  ...parsed,
240
+ decision: redactRawSignalText(parsed.decision),
241
+ rationale: redactRawSignalText(parsed.rationale),
242
+ framework: typeof parsed.framework === "string" ? redactRawSignalText(parsed.framework) : undefined,
243
+ filesAffected: parsed.filesAffected.map((filePath) => sanitizeFilePath(filePath)),
99
244
  originatingPrompt: typeof parsed.originatingPrompt === "string"
100
- ? redactSecrets(parsed.originatingPrompt)
245
+ ? redactOperationalContext(parsed.originatingPrompt)
101
246
  : undefined,
102
- metadata: {
103
- ...parsed.metadata,
104
- functional_area: inferFunctionalArea(parsed),
105
- },
247
+ installToken: typeof parsed.installToken === "string"
248
+ ? redactOperationalContext(parsed.installToken)
249
+ : undefined,
250
+ metadata: redactMetadata(parsed.metadata),
106
251
  };
107
252
  }
108
253
  export function redactCodingSessionSignal(input) {
109
254
  const parsed = codingSessionSignalSchema.parse(input);
110
255
  return {
111
256
  ...parsed,
112
- summary: redactSecrets(parsed.summary),
257
+ summary: redactRawSignalText(parsed.summary),
113
258
  evidence: parsed.evidence.map((entry) => ({
114
259
  ...entry,
115
- excerpt: redactSecrets(entry.excerpt),
260
+ excerpt: redactOperationalContext(entry.excerpt),
116
261
  })),
117
- commandsRun: parsed.commandsRun.map((command) => redactSecrets(command)),
262
+ filesTouched: parsed.filesTouched.map((filePath) => sanitizeFilePath(filePath)),
263
+ commandsRun: parsed.commandsRun.map((command) => redactOperationalContext(command)),
118
264
  metadata: redactMetadata(parsed.metadata),
119
265
  };
120
266
  }
121
- function apiBaseUrl() {
122
- return process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com";
267
+ function apiBaseUrl(override) {
268
+ return override?.trim() || process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com";
123
269
  }
124
270
  function normalizeClientId(value) {
125
271
  return String(value ?? "")
@@ -132,75 +278,126 @@ function normalizeClientId(value) {
132
278
  export function normalizeInstallTokenInput(token) {
133
279
  return String(token ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
134
280
  }
135
- function credentials() {
281
+ function routeWithQuery(route, params) {
282
+ const searchParams = new URLSearchParams();
283
+ for (const [key, value] of Object.entries(params)) {
284
+ if (value === undefined || value === null || value === "") {
285
+ continue;
286
+ }
287
+ searchParams.set(key, String(value));
288
+ }
289
+ const query = searchParams.toString();
290
+ return query ? `${route}?${query}` : route;
291
+ }
292
+ function credentials(overrides) {
136
293
  const legacyHostType = process.env.ASKTHEW_HOST_TYPE?.trim();
137
294
  const hostType = legacyHostType === "claude_code" || legacyHostType === "codex" || legacyHostType === "cursor"
138
295
  ? legacyHostType
139
296
  : undefined;
140
- const clientId = normalizeClientId(process.env.ASKTHEW_CLIENT_ID?.trim()) || hostType || "mcp_client";
297
+ const overrideHostType = overrides?.hostType;
298
+ const clientId = normalizeClientId(overrides?.clientId) ||
299
+ normalizeClientId(process.env.ASKTHEW_CLIENT_ID?.trim()) ||
300
+ overrideHostType ||
301
+ hostType ||
302
+ "mcp_client";
141
303
  return {
142
- installToken: normalizeInstallTokenInput(process.env.ASKTHEW_INSTALL_TOKEN),
143
- userId: process.env.ASKTHEW_USER_ID?.trim(),
144
- apiKey: process.env.ASKTHEW_API_KEY?.trim(),
145
- serverName: process.env.ASKTHEW_SERVER_NAME?.trim(),
304
+ installToken: normalizeInstallTokenInput(overrides?.installToken) ||
305
+ normalizeInstallTokenInput(process.env.ASKTHEW_INSTALL_TOKEN),
306
+ userId: overrides?.userId?.trim() || process.env.ASKTHEW_USER_ID?.trim(),
307
+ apiKey: overrides?.apiKey?.trim() || process.env.ASKTHEW_API_KEY?.trim(),
308
+ serverName: overrides?.serverName?.trim() || process.env.ASKTHEW_SERVER_NAME?.trim(),
146
309
  clientId,
147
- clientLabel: process.env.ASKTHEW_CLIENT_LABEL?.trim(),
148
- hostType,
310
+ clientLabel: overrides?.clientLabel?.trim() || process.env.ASKTHEW_CLIENT_LABEL?.trim(),
311
+ hostType: overrideHostType || hostType,
149
312
  };
150
313
  }
151
- function hasServerIdentity() {
152
- const { installToken, userId } = credentials();
314
+ function hasServerIdentity(overrides) {
315
+ const { installToken, userId } = credentials(overrides);
153
316
  return Boolean(installToken || userId);
154
317
  }
155
- async function postToServer(route, payload) {
156
- if (!hasServerIdentity()) {
318
+ async function postToServer(route, payload, options, request) {
319
+ if (!hasServerIdentity(options?.credentials)) {
157
320
  return null;
158
321
  }
159
- const { installToken, userId, apiKey, clientId, clientLabel, hostType } = credentials();
160
- const response = await fetch(`${apiBaseUrl()}${route}`, {
161
- method: "POST",
322
+ const { installToken, userId, apiKey, clientId, clientLabel, hostType } = credentials(options?.credentials);
323
+ const fetcher = options?.fetchImpl ?? fetch;
324
+ const method = request?.method ?? "POST";
325
+ const bodyPayload = {
326
+ ...payload,
327
+ installToken: installToken || undefined,
328
+ userId: userId || undefined,
329
+ clientId,
330
+ clientLabel: clientLabel || undefined,
331
+ hostType: hostType || undefined,
332
+ };
333
+ const response = await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}${route}`, {
334
+ method,
162
335
  headers: {
163
- "Content-Type": "application/json",
164
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
336
+ ...(method === "GET" ? {} : { "Content-Type": "application/json" }),
337
+ ...(installToken
338
+ ? { Authorization: `Bearer ${installToken}` }
339
+ : apiKey
340
+ ? { Authorization: `Bearer ${apiKey}` }
341
+ : {}),
165
342
  },
166
- body: JSON.stringify({
167
- ...payload,
168
- installToken: installToken || undefined,
169
- userId: userId || undefined,
170
- clientId,
171
- clientLabel: clientLabel || undefined,
172
- hostType: hostType || undefined,
173
- }),
343
+ ...(method === "GET" ? {} : { body: JSON.stringify(bodyPayload) }),
174
344
  }).catch(() => null);
175
- if (!response || !response.ok) {
176
- return null;
345
+ if (!response) {
346
+ return {
347
+ ok: false,
348
+ error: "Ask The W server could not be reached.",
349
+ };
177
350
  }
178
- return response.json().catch(() => null);
351
+ const body = await response.json().catch(() => null);
352
+ if (!response.ok) {
353
+ return {
354
+ ok: false,
355
+ status: response.status,
356
+ ...(body && typeof body === "object" ? body : { error: "Ask The W request failed." }),
357
+ };
358
+ }
359
+ return body;
179
360
  }
180
- function runtimeMetadata() {
361
+ function runtimeMetadata(options) {
181
362
  const scope = resolvePluginScope(process.cwd());
182
- const { serverName, clientId, clientLabel } = credentials();
363
+ const { serverName, clientId, clientLabel } = credentials(options?.credentials);
364
+ const extraMetadata = typeof options?.runtimeMetadata === "function"
365
+ ? options.runtimeMetadata()
366
+ : (options?.runtimeMetadata ?? {});
183
367
  return {
184
368
  repository: scope.repoName,
185
369
  repo_name: scope.repoName,
186
- ...(scope.repoRoot ? { repo_root: scope.repoRoot } : {}),
187
- ...(scope.appPath ? { app_path: scope.appPath } : {}),
370
+ ...(scope.repoRoot ? { repo_root: sanitizeFilePath(scope.repoRoot) } : {}),
371
+ ...(scope.appPath ? { app_path: sanitizeFilePath(scope.appPath) } : {}),
188
372
  ...(scope.serviceName ? { service_name: scope.serviceName } : {}),
189
373
  ...(serverName ? { server_name: serverName } : {}),
190
374
  ...(clientId ? { client_id: clientId } : {}),
191
375
  ...(clientLabel ? { client_label: clientLabel } : {}),
376
+ ...extraMetadata,
192
377
  };
193
378
  }
194
- async function sendStartupHeartbeat() {
195
- if (!hasServerIdentity()) {
379
+ function startupSessionId(options) {
380
+ const scope = resolvePluginScope(process.cwd());
381
+ const { clientId } = credentials(options?.credentials);
382
+ const scopeKey = [scope.repoName, scope.appPath, scope.serviceName]
383
+ .filter((part) => Boolean(part))
384
+ .join(":") || "workspace";
385
+ return ["mcp-startup", clientId || "mcp_client", scopeKey]
386
+ .join(":")
387
+ .replace(/[^a-zA-Z0-9:_-]+/g, "_")
388
+ .slice(0, 240);
389
+ }
390
+ async function sendStartupHeartbeat(options) {
391
+ if (!hasServerIdentity(options?.credentials)) {
196
392
  return;
197
393
  }
198
- const { installToken, apiKey, clientId, clientLabel, hostType, serverName } = credentials();
394
+ const { installToken, apiKey, clientId, clientLabel, hostType, serverName } = credentials(options?.credentials);
199
395
  if (!installToken) {
200
396
  return;
201
397
  }
202
398
  const scope = resolvePluginScope(process.cwd());
203
- await fetch(`${apiBaseUrl()}/api/connectors/mcp/heartbeat`, {
399
+ const fetcher = options?.fetchImpl ?? fetch;
400
+ await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}/api/connectors/mcp/heartbeat`, {
204
401
  method: "POST",
205
402
  headers: {
206
403
  "Content-Type": "application/json",
@@ -219,12 +416,79 @@ async function sendStartupHeartbeat() {
219
416
  }),
220
417
  }).catch(() => null);
221
418
  }
222
- export function createAskTheWMcpServer() {
419
+ async function sendStartupSetupSignal(options) {
420
+ const sessionSignal = {
421
+ sessionId: startupSessionId(options),
422
+ sequence: 0,
423
+ kind: "setup_complete",
424
+ summary: "Ask The W MCP server started for this coding-agent session.",
425
+ evidence: [],
426
+ filesTouched: [],
427
+ commandsRun: [],
428
+ metadata: {
429
+ ...runtimeMetadata(options),
430
+ automated: true,
431
+ operational: true,
432
+ origin: "mcp_server_startup",
433
+ },
434
+ };
435
+ await postToServer("/api/ingest/mcp", {
436
+ sessionSignal: redactCodingSessionSignal(sessionSignal),
437
+ }, options);
438
+ }
439
+ async function sendStartupSignals(options) {
440
+ await sendStartupHeartbeat(options).catch(() => null);
441
+ await sendStartupSetupSignal(options).catch(() => null);
442
+ }
443
+ export function createAskTheWMcpServer(options = {}) {
444
+ const resolvedMode = resolveMcpMode();
445
+ const optionInstallToken = normalizeInstallTokenInput(options.credentials?.installToken);
446
+ const mode = optionInstallToken
447
+ ? { mode: "paid", installToken: optionInstallToken, reason: "options_install_token" }
448
+ : resolvedMode;
449
+ const localStore = mode.mode === "paid" ? null : LocalStore.open();
450
+ if (localStore) {
451
+ void flushTelemetryOutbox({
452
+ store: localStore,
453
+ credentials: mode.cliCredentials ?? {
454
+ userId: "unauthenticated",
455
+ cliToken: "",
456
+ cliTokenId: "none",
457
+ },
458
+ apiUrl: options.apiBaseUrl,
459
+ fetchImpl: options.fetchImpl,
460
+ }).catch(() => null);
461
+ }
223
462
  const server = new McpServer({
224
- name: "AskTheW Coding Agent Connector",
225
- version: "1.0.0",
463
+ name: "Ask The W Coding Agent Connector",
464
+ version: "0.4.0",
226
465
  });
227
- void sendStartupHeartbeat();
466
+ if (options.sendStartupHeartbeat !== false) {
467
+ void sendStartupSignals(options);
468
+ }
469
+ const apiToolResponse = async (route, payload = {}, method = "GET") => {
470
+ const upstream = await postToServer(route, payload, options, { method });
471
+ return {
472
+ content: [
473
+ {
474
+ type: "text",
475
+ text: JSON.stringify(upstream, null, 2),
476
+ },
477
+ ],
478
+ };
479
+ };
480
+ const localResponse = (value) => toolJson(value);
481
+ const requireFreeIdentity = () => {
482
+ if (mode.mode === "unauthenticated") {
483
+ return localResponse({
484
+ ok: false,
485
+ code: "free_tier_login_required",
486
+ message: "Run `npx @askthew/mcp-plugin auth login --email you@example.com` to unlock local capture.",
487
+ supportEmail: "support@askthew.com",
488
+ });
489
+ }
490
+ return null;
491
+ };
228
492
  server.tool("capture_session_signal", {
229
493
  sessionId: z.string().min(1),
230
494
  sequence: z.number().int().nonnegative(),
@@ -243,13 +507,50 @@ export function createAskTheWMcpServer() {
243
507
  const sessionSignal = redactCodingSessionSignal({
244
508
  ...payload,
245
509
  metadata: {
246
- ...runtimeMetadata(),
510
+ ...runtimeMetadata(options),
247
511
  ...(payload.metadata ?? {}),
248
512
  },
249
513
  });
514
+ if (mode.mode !== "paid" && localStore) {
515
+ const loginRequired = requireFreeIdentity();
516
+ if (loginRequired) {
517
+ return loginRequired;
518
+ }
519
+ const signal = localStore.insertSignal({
520
+ sessionId: sessionSignal.sessionId,
521
+ sequence: sessionSignal.sequence,
522
+ kind: sessionSignal.kind,
523
+ summary: sessionSignal.summary,
524
+ evidence: sessionSignal.evidence,
525
+ filesTouched: sessionSignal.filesTouched,
526
+ commandsRun: sessionSignal.commandsRun,
527
+ metadata: sessionSignal.metadata,
528
+ });
529
+ if (sessionSignal.kind === "final_summary" && mode.cliCredentials) {
530
+ localStore.enqueueTelemetry(buildTelemetryPayload({
531
+ store: localStore,
532
+ credentials: mode.cliCredentials,
533
+ sessionId: sessionSignal.sessionId,
534
+ }));
535
+ void flushTelemetryOutbox({
536
+ store: localStore,
537
+ credentials: mode.cliCredentials,
538
+ apiUrl: options.apiBaseUrl,
539
+ fetchImpl: options.fetchImpl,
540
+ }).catch(() => null);
541
+ }
542
+ return localResponse({
543
+ ok: true,
544
+ tier: "free",
545
+ signal,
546
+ note: localStore.usingJsonFallback
547
+ ? "Captured locally in JSON fallback mode because SQLite was unavailable."
548
+ : "Captured locally in SQLite. Aggregate telemetry only may flush on final_summary unless opted out.",
549
+ });
550
+ }
250
551
  const upstream = await postToServer("/api/ingest/mcp", {
251
552
  sessionSignal,
252
- });
553
+ }, options);
253
554
  return {
254
555
  content: [
255
556
  {
@@ -264,5 +565,340 @@ export function createAskTheWMcpServer() {
264
565
  ],
265
566
  };
266
567
  });
568
+ server.tool("list_decisions", {
569
+ limit: z.number().int().positive().max(300).optional(),
570
+ cursor: z.string().optional(),
571
+ }, async (payload) => {
572
+ if (mode.mode !== "paid" && localStore) {
573
+ const loginRequired = requireFreeIdentity();
574
+ if (loginRequired)
575
+ return loginRequired;
576
+ return localResponse({
577
+ ok: true,
578
+ tier: "free",
579
+ decisions: localStore.listDecisions({ limit: payload.limit ?? 50 }),
580
+ });
581
+ }
582
+ return apiToolResponse(routeWithQuery("/api/decisions", {
583
+ limit: payload.limit,
584
+ cursor: payload.cursor,
585
+ }));
586
+ });
587
+ server.tool("get_decision", {
588
+ id: z.string().min(1),
589
+ }, async (payload) => {
590
+ if (mode.mode !== "paid" && localStore) {
591
+ const loginRequired = requireFreeIdentity();
592
+ if (loginRequired)
593
+ return loginRequired;
594
+ const decision = localStore.getDecision(payload.id);
595
+ return localResponse(decision ? { ok: true, tier: "free", decision } : { ok: false, code: "not_found" });
596
+ }
597
+ return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`);
598
+ });
599
+ server.tool("create_decision", {
600
+ content: z.string().min(1),
601
+ }, async (payload) => {
602
+ if (mode.mode !== "paid" && localStore) {
603
+ const loginRequired = requireFreeIdentity();
604
+ if (loginRequired)
605
+ return loginRequired;
606
+ return localResponse({
607
+ ok: true,
608
+ tier: "free",
609
+ decision: localStore.createDecision({
610
+ rawContent: payload.content,
611
+ sessionId: localStore.mostRecentSessionId(),
612
+ }),
613
+ });
614
+ }
615
+ return apiToolResponse("/api/decisions", {
616
+ content: payload.content,
617
+ }, "POST");
618
+ });
619
+ server.tool("update_decision", {
620
+ id: z.string().min(1),
621
+ headline: z.string().min(1).optional(),
622
+ why: z.string().optional(),
623
+ status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
624
+ alignment: z.enum(["aligned", "orthogonal", "conflicts", "ambiguous"]).optional(),
625
+ outcomeId: z.string().min(1).optional(),
626
+ }, async (payload) => {
627
+ if (mode.mode !== "paid" && localStore) {
628
+ const loginRequired = requireFreeIdentity();
629
+ if (loginRequired)
630
+ return loginRequired;
631
+ const decision = localStore.updateDecision(payload.id, {
632
+ ...(payload.headline ? { headline: payload.headline } : {}),
633
+ ...(payload.why !== undefined ? { why: payload.why } : {}),
634
+ ...(payload.status ? { status: payload.status } : {}),
635
+ ...(payload.alignment !== undefined ? { alignment: payload.alignment } : {}),
636
+ });
637
+ return localResponse(decision ? { ok: true, tier: "free", decision } : { ok: false, code: "not_found" });
638
+ }
639
+ return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`, {
640
+ headline: payload.headline,
641
+ why: payload.why,
642
+ status: payload.status,
643
+ alignment: payload.alignment,
644
+ outcomeId: payload.outcomeId,
645
+ }, "PATCH");
646
+ });
647
+ server.tool("delete_decision", {
648
+ id: z.string().min(1),
649
+ confirmText: z.string().min(1),
650
+ }, async (payload) => {
651
+ if (mode.mode !== "paid" && localStore) {
652
+ const loginRequired = requireFreeIdentity();
653
+ if (loginRequired)
654
+ return loginRequired;
655
+ if (payload.confirmText !== payload.id) {
656
+ return localResponse({ ok: false, code: "confirmation_required", message: "confirmText must match the decision id." });
657
+ }
658
+ return localResponse({ ok: localStore.deleteDecision(payload.id), tier: "free" });
659
+ }
660
+ return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`, {
661
+ confirmText: payload.confirmText,
662
+ }, "DELETE");
663
+ });
664
+ server.tool("list_outcomes", paidDescription("List outcomes from your workspace.", mode.mode), {
665
+ limit: z.number().int().positive().max(300).optional(),
666
+ }, async (payload) => {
667
+ if (mode.mode === "free")
668
+ return localResponse(paidFeatureNudge("list_outcomes"));
669
+ return apiToolResponse(routeWithQuery("/api/outcomes", {
670
+ limit: payload.limit,
671
+ }));
672
+ });
673
+ server.tool("get_outcome", paidDescription("Get outcome detail from your workspace.", mode.mode), {
674
+ id: z.string().min(1),
675
+ }, async (payload) => mode.mode === "free"
676
+ ? localResponse(paidFeatureNudge("get_outcome"))
677
+ : apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`));
678
+ server.tool("list_outcome_signals", paidDescription("List signals linked to an outcome.", mode.mode), {
679
+ id: z.string().min(1),
680
+ }, async (payload) => mode.mode === "free"
681
+ ? localResponse(paidFeatureNudge("list_outcome_signals"))
682
+ : apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}/signals`));
683
+ server.tool("create_outcome", paidDescription("Create a new outcome.", mode.mode), {
684
+ name: z.string().min(1),
685
+ summary: z.string().optional(),
686
+ causalHypothesis: z.string().optional(),
687
+ suggestedAction: z.string().optional(),
688
+ }, async (payload) => {
689
+ if (mode.mode === "free")
690
+ return localResponse(paidFeatureNudge("create_outcome"));
691
+ return apiToolResponse("/api/outcomes", {
692
+ name: payload.name,
693
+ summary: payload.summary,
694
+ causalHypothesis: payload.causalHypothesis,
695
+ suggestedAction: payload.suggestedAction,
696
+ }, "POST");
697
+ });
698
+ server.tool("update_outcome", paidDescription("Update an outcome.", mode.mode), {
699
+ id: z.string().min(1),
700
+ name: z.string().min(1).optional(),
701
+ summary: z.string().optional(),
702
+ causalHypothesis: z.string().optional(),
703
+ suggestedAction: z.string().optional(),
704
+ status: z.enum(["active", "achieved", "abandoned", "archived"]).optional(),
705
+ }, async (payload) => {
706
+ if (mode.mode === "free")
707
+ return localResponse(paidFeatureNudge("update_outcome"));
708
+ return apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`, {
709
+ name: payload.name,
710
+ summary: payload.summary,
711
+ causalHypothesis: payload.causalHypothesis,
712
+ suggestedAction: payload.suggestedAction,
713
+ status: payload.status,
714
+ }, "PATCH");
715
+ });
716
+ server.tool("delete_outcome", paidDescription("Delete an outcome.", mode.mode), {
717
+ id: z.string().min(1),
718
+ confirmText: z.string().min(1),
719
+ }, async (payload) => {
720
+ if (mode.mode === "free")
721
+ return localResponse(paidFeatureNudge("delete_outcome"));
722
+ return apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`, {
723
+ confirmText: payload.confirmText,
724
+ }, "DELETE");
725
+ });
726
+ server.tool("get_north_star", paidDescription("Read the workspace north-star metric.", mode.mode), {}, async () => mode.mode === "free"
727
+ ? localResponse(paidFeatureNudge("get_north_star"))
728
+ : apiToolResponse("/api/north-star"));
729
+ server.tool("update_north_star", paidDescription("Update the workspace north-star metric.", mode.mode), {
730
+ metric: z.string().min(1),
731
+ current: z.string().min(1),
732
+ target: z.string().min(1),
733
+ reason: z.string().min(1),
734
+ }, async (payload) => {
735
+ if (mode.mode === "free")
736
+ return localResponse(paidFeatureNudge("update_north_star"));
737
+ return apiToolResponse("/api/north-star", {
738
+ metric: payload.metric,
739
+ current: payload.current,
740
+ target: payload.target,
741
+ reason: payload.reason,
742
+ }, "POST");
743
+ });
744
+ server.tool("list_signals", {
745
+ limit: z.number().int().positive().max(300).optional(),
746
+ cursor: z.string().optional(),
747
+ }, async (payload) => {
748
+ if (mode.mode !== "paid" && localStore) {
749
+ const loginRequired = requireFreeIdentity();
750
+ if (loginRequired)
751
+ return loginRequired;
752
+ return localResponse({
753
+ ok: true,
754
+ tier: "free",
755
+ signals: localStore.listSignals({ limit: payload.limit ?? 300 }),
756
+ });
757
+ }
758
+ return apiToolResponse(routeWithQuery("/api/signals", {
759
+ limit: payload.limit,
760
+ cursor: payload.cursor,
761
+ }));
762
+ });
763
+ server.tool("get_signal", {
764
+ id: z.string().min(1),
765
+ }, async (payload) => {
766
+ if (mode.mode !== "paid" && localStore) {
767
+ const loginRequired = requireFreeIdentity();
768
+ if (loginRequired)
769
+ return loginRequired;
770
+ const signal = localStore.getSignal(Number(payload.id));
771
+ return localResponse(signal ? { ok: true, tier: "free", signal } : { ok: false, code: "not_found" });
772
+ }
773
+ return apiToolResponse(`/api/signals/${encodeURIComponent(payload.id)}`);
774
+ });
775
+ server.tool("review_decisions", {
776
+ since: z.string().optional(),
777
+ status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
778
+ format: z.enum(["markdown", "json"]).default("markdown"),
779
+ limit: z.number().int().positive().max(300).default(50),
780
+ }, async (payload) => {
781
+ if (mode.mode === "paid") {
782
+ return apiToolResponse(routeWithQuery("/api/decisions", {
783
+ since: payload.since,
784
+ status: payload.status,
785
+ limit: payload.limit,
786
+ }));
787
+ }
788
+ if (!localStore)
789
+ return localResponse({ ok: false, code: "local_store_unavailable" });
790
+ const loginRequired = requireFreeIdentity();
791
+ if (loginRequired)
792
+ return loginRequired;
793
+ const decisions = localStore.listDecisions({
794
+ since: payload.since,
795
+ status: payload.status,
796
+ limit: payload.limit,
797
+ });
798
+ return localResponse({
799
+ ok: true,
800
+ tier: "free",
801
+ format: payload.format,
802
+ rendered: renderDecisionDigest(decisions),
803
+ decisions,
804
+ count: decisions.length,
805
+ copyHint: "Copy this output to back up your decisions - `export_decisions` is a paid feature.",
806
+ });
807
+ });
808
+ server.tool("review_session", {
809
+ sessionId: z.string().optional(),
810
+ format: z.enum(["markdown", "json"]).default("markdown"),
811
+ }, async (payload) => {
812
+ if (!localStore || mode.mode === "paid") {
813
+ return apiToolResponse(routeWithQuery("/api/signals", { sessionId: payload.sessionId }));
814
+ }
815
+ const loginRequired = requireFreeIdentity();
816
+ if (loginRequired)
817
+ return loginRequired;
818
+ const sessionId = payload.sessionId ?? localStore.mostRecentSessionId();
819
+ const signals = sessionId ? localStore.listSignals({ sessionId, limit: 100000 }) : [];
820
+ const counts = signals.reduce((accumulator, signal) => {
821
+ accumulator[signal.kind] = (accumulator[signal.kind] ?? 0) + 1;
822
+ return accumulator;
823
+ }, {});
824
+ return localResponse({
825
+ ok: true,
826
+ tier: "free",
827
+ sessionId,
828
+ format: payload.format,
829
+ rendered: renderSessionFeed(signals),
830
+ signals,
831
+ counts: {
832
+ totalSignals: signals.length,
833
+ byKind: counts,
834
+ },
835
+ });
836
+ });
837
+ server.tool("analyze_session", {
838
+ sessionId: z.string().optional(),
839
+ window: z.enum(["session", "day", "week"]).default("session"),
840
+ }, async (payload) => {
841
+ if (!localStore || mode.mode === "paid") {
842
+ return localResponse(paidFeatureNudge("analyze_session"));
843
+ }
844
+ const loginRequired = requireFreeIdentity();
845
+ if (loginRequired)
846
+ return loginRequired;
847
+ const sessionId = payload.sessionId ?? localStore.mostRecentSessionId();
848
+ const signals = sessionId ? localStore.listSignals({ sessionId, limit: 100000 }) : [];
849
+ const decisions = localStore.listDecisions({ limit: 100000 });
850
+ const tips = signals.length < 5 ? [] : analyzeLocalPatterns({ signals, decisions });
851
+ const verification = signals.filter((signal) => signal.kind === "verification_result");
852
+ const verificationPass = verification.filter((signal) => /pass|green|ok|success/i.test(signal.summary)).length;
853
+ return localResponse({
854
+ ok: true,
855
+ tier: "free",
856
+ window: payload.window,
857
+ guidance: tips.length === 0
858
+ ? "Capture a few signals first - `analyze_session` needs at least 5 signals to surface patterns."
859
+ : undefined,
860
+ windowSummary: {
861
+ signalCount: signals.length,
862
+ decisionsCreated: decisions.length,
863
+ verificationPassRate: verification.length ? verificationPass / verification.length : 0,
864
+ directionChanges: signals.filter((signal) => signal.kind === "direction_change").length,
865
+ filesTouched: new Set(signals.flatMap((signal) => signal.filesTouched)).size,
866
+ },
867
+ tips,
868
+ paidUpsell: "Zones, health-state, velocity trends, and LLM-synthesized cross-session insights are part of the paid tier. https://askthew.com/pricing",
869
+ supportEmail: "support@askthew.com",
870
+ });
871
+ });
872
+ server.tool("export_decisions", paidDescription("Export decisions from your workspace.", mode.mode), {
873
+ format: z.enum(["json", "markdown", "jsonl"]).default("json"),
874
+ }, async () => mode.mode === "free"
875
+ ? localResponse(paidFeatureNudge("export_decisions"))
876
+ : apiToolResponse("/api/export/timeline"));
267
877
  return server;
268
878
  }
879
+ function renderDecisionDigest(decisions) {
880
+ if (decisions.length === 0) {
881
+ return "# Decisions\n\nNo local decisions captured yet.";
882
+ }
883
+ return [
884
+ "# Decisions",
885
+ "",
886
+ ...decisions.map((decision) => [`## ${decision.headline}`, `- id: ${decision.id}`, `- status: ${decision.status}`, `- created: ${decision.createdAt}`, decision.why ? `- why: ${decision.why}` : "- why: not captured"].join("\n")),
887
+ ].join("\n\n");
888
+ }
889
+ function renderSessionFeed(signals) {
890
+ if (signals.length === 0) {
891
+ return "# Session\n\nNo local signals captured yet.";
892
+ }
893
+ return [
894
+ "# Session",
895
+ "",
896
+ ...signals.map((signal) => [
897
+ `## ${signal.sequence ?? signal.id}. ${signal.kind}`,
898
+ signal.summary,
899
+ `- captured: ${signal.capturedAt}`,
900
+ signal.filesTouched.length ? `- files: ${signal.filesTouched.join(", ")}` : "- files: none",
901
+ signal.commandsRun.length ? `- commands: ${signal.commandsRun.join(" | ")}` : "- commands: none",
902
+ ].join("\n")),
903
+ ].join("\n\n");
904
+ }