research-lens-mcp 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +41 -0
  2. package/build/index.js +381 -25
  3. package/package.json +7 -2
package/README.md CHANGED
@@ -45,6 +45,18 @@ Research Lens gives the assistant direct, structured access to your repository.
45
45
  | "Did the redesign work?" | `compare_rounds` | Sentiment, completion and SUS, round over round |
46
46
  | "Draft a finding on onboarding" | `draft_findings` | Evidence with citations, plus what the data does not support |
47
47
 
48
+ Teams shipping on a release schedule, and anyone whose decisions have to survive an audit, get five more:
49
+
50
+ | Ask | Tool | You get |
51
+ |---|---|---|
52
+ | "What's still open before we ship?" | `release_readiness` | Issues by the severity you tagged, participants affected, quotes behind each |
53
+ | "Did it improve since last release?" | `track_metric` | Completion and SUS per study in date order, never averaged across studies |
54
+ | "Has anyone studied this already?" | `prior_research` | Which past studies touched a topic, when, with how many participants |
55
+ | "Why did we make that change?" | `evidence_trail` | Every verbatim quote behind one issue, with participant, study, release and date |
56
+ | "Whose experience are we guessing about?" | `coverage_gaps` | Segments, regions and themes resting on too few people |
57
+
58
+ `release_readiness` ends with what it is *not*: not a ship decision, not a severity judgment, not frequency in the wild. `coverage_gaps` ends by naming the gap no tool can show you, the people your recruitment never reached.
59
+
48
60
  ## The constraint
49
61
 
50
62
  **The server retrieves and counts. It does not interpret.**
@@ -61,6 +73,35 @@ That line is enforced, not aspirational, and it shows up in four places:
61
73
 
62
74
  The tools never say what a finding *means*. That judgment stays with the researcher. This is the line between AI that accelerates research and AI that quietly replaces thinking with plausible text.
63
75
 
76
+ ## Consent, enforced at the point of query
77
+
78
+ Research platforms manage consent at the storage layer: who can log into the tool. Research
79
+ Lens enforces it at the **query** layer, where an AI agent actually reads. Each participant
80
+ carries what they agreed to, and the server obeys it.
81
+
82
+ | Consent | What happens |
83
+ |---|---|
84
+ | `full` | Quotes reproduced normally. The default, so existing studies are unaffected. |
85
+ | `internal` | Quotes shown to the research team, withheld when the output is external. |
86
+ | `counts` | Counted in every pattern, never quoted. They agreed to be studied, not published. |
87
+ | `withdrawn` | Removed from every count, quote and **denominator**. |
88
+
89
+ Set `RESEARCH_LENS_AUDIENCE=external` when the answer will leave the research team.
90
+
91
+ A withheld quote is always labelled:
92
+
93
+ ```
94
+ MIXED (1)
95
+ [P07 | R1 | 02:55] [quote withheld: consented to research-team use only, and this output is external]
96
+ ```
97
+
98
+ It is never silently dropped. "Nobody said this" and "someone said this and did not consent
99
+ to it leaving" must never look the same, because one is a finding and the other is a
100
+ compliance breach wearing a finding's clothes.
101
+
102
+ `consent_status` reports who agreed to what, who withdrew, and what is being withheld right
103
+ now. Consent is read from the data and never inferred.
104
+
64
105
  ## Install
65
106
 
66
107
  **Claude Desktop.** Open your config:
package/build/index.js CHANGED
@@ -29,6 +29,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
29
29
  import { basename, dirname, extname, join, resolve } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
32
+ const SEVERITY_ORDER = ["blocker", "major", "minor"];
32
33
  /** Where studies live. A folder of .json files, or a single .json file. */
33
34
  const DATA_PATH = process.env.RESEARCH_LENS_DATA?.trim();
34
35
  const SAMPLE_DIR = join(__dirname, "..", "data");
@@ -105,19 +106,80 @@ function pick(all, name) {
105
106
  }
106
107
  /** Flatten every excerpt with its session and study context attached. */
107
108
  function allExcerpts(studies) {
108
- return studies.flatMap((st) => st.sessions.flatMap((s) => s.excerpts.map((e) => ({
109
+ return studies.flatMap((st) => active(st.sessions).flatMap((s) => s.excerpts.map((e) => ({
109
110
  ...e,
110
111
  participant: s.id,
111
112
  round: s.round,
112
113
  segment: s.segment,
113
114
  task: s.task,
114
115
  study: st.key,
116
+ consent: consentOf(s),
115
117
  }))));
116
118
  }
117
- const sessionsOf = (studies) => studies.flatMap((s) => s.sessions);
119
+ const sessionsOf = (studies) => studies.flatMap((s) => active(s.sessions));
120
+ const norm = (s) => String(s ?? "").trim().toLowerCase();
121
+ /**
122
+ * Who is asking. An MCP client is a pipe to wherever the answer ends up, so the
123
+ * operator declares the audience once, the way an API caller declares its scope.
124
+ * Defaults to the research team, the most permissive setting, because an
125
+ * existing study with no consent field should keep working unchanged.
126
+ */
127
+ const AUDIENCE = norm(process.env.RESEARCH_LENS_AUDIENCE) === "external" ? "external" : "team";
128
+ const consentOf = (s) => s.consent ?? "full";
129
+ /** Withdrawn participants are removed from everything, including denominators. */
130
+ const active = (xs) => xs.filter((x) => consentOf(x) !== "withdrawn");
131
+ /** May this participant's words be reproduced, for this audience? */
132
+ function mayQuote(consent) {
133
+ if (consent === "withdrawn" || consent === "counts")
134
+ return false;
135
+ if (consent === "internal")
136
+ return AUDIENCE === "team";
137
+ return true;
138
+ }
139
+ /**
140
+ * Renders a quote, or says plainly that it is being withheld and why. Never
141
+ * silently omits: a reader must be able to tell the difference between
142
+ * "nobody said this" and "someone said this and did not consent to it leaving".
143
+ */
144
+ function quoteOrWithheld(h) {
145
+ if (mayQuote(h.consent))
146
+ return `"${h.quote}"`;
147
+ return h.consent === "counts"
148
+ ? `[quote withheld: participant consented to be counted, not quoted]`
149
+ : `[quote withheld: consented to research-team use only, and this output is external]`;
150
+ }
151
+ /**
152
+ * Themes are matched case-insensitively. An assistant will naturally write
153
+ * "Trust" for a theme filed as "trust", and returning "no quotes" for a
154
+ * capital letter would read as an absence of evidence, which is the most
155
+ * dangerous wrong answer this server could give.
156
+ */
157
+ const themeMatches = (rowTheme, wanted) => norm(rowTheme) === norm(wanted);
158
+ /** When a theme finds nothing, say what does exist rather than just "none". */
159
+ function themeSuggestion(rows, wanted) {
160
+ const all = [...new Set(rows.map((r) => r.theme))];
161
+ if (!all.length)
162
+ return "";
163
+ const q = norm(wanted);
164
+ const near = all.filter((t) => norm(t).includes(q) || q.includes(norm(t)));
165
+ return near.length
166
+ ? ` Did you mean: ${near.join(", ")}?`
167
+ : ` Themes in scope: ${all.join(", ")}.`;
168
+ }
169
+ /** Optional segment filter, case-insensitive, shared by the retrieval tools. */
170
+ function applySegment(rows, segment) {
171
+ if (!segment)
172
+ return { rows, label: "" };
173
+ const q = norm(segment);
174
+ const hit = rows.filter((r) => norm(r.segment) === q);
175
+ if (hit.length)
176
+ return { rows: hit, label: `, segment "${segment}"` };
177
+ const loose = rows.filter((r) => norm(r.segment).includes(q));
178
+ return { rows: loose, label: `, segment "${segment}"` };
179
+ }
118
180
  /** Prefix a citation with the study only when more than one is in scope. */
119
181
  const cite = (h, multi) => `${multi ? h.study + " | " : ""}${h.participant} | R${h.round} | ${h.t}`;
120
- const server = new Server({ name: "research-lens", version: "2.0.0" }, { capabilities: { tools: {} } });
182
+ const server = new Server({ name: "research-lens", version: "2.2.0" }, { capabilities: { tools: {} } });
121
183
  const STUDY_ARG = {
122
184
  study: {
123
185
  type: "string",
@@ -138,6 +200,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
138
200
  type: "object",
139
201
  properties: {
140
202
  query: { type: "string", description: "Words or phrase to find." },
203
+ segment: { type: "string", description: "Optional. Restrict to one user segment." },
141
204
  ...STUDY_ARG,
142
205
  },
143
206
  required: ["query"],
@@ -149,8 +212,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
149
212
  inputSchema: {
150
213
  type: "object",
151
214
  properties: {
152
- theme: { type: "string", description: "e.g. trust, onboarding, navigation" },
215
+ theme: { type: "string", description: "e.g. trust, onboarding, navigation. Case-insensitive." },
153
216
  round: { type: "number", description: "Optional: restrict to one research round." },
217
+ segment: { type: "string", description: "Optional. Restrict to one user segment." },
154
218
  ...STUDY_ARG,
155
219
  },
156
220
  required: ["theme"],
@@ -179,6 +243,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
179
243
  required: ["theme"],
180
244
  },
181
245
  },
246
+ {
247
+ name: "release_readiness",
248
+ description: "Issues found in research, grouped by the severity the researcher tagged, with how many participants hit each and the quotes behind them. Answers 'what is still open before we ship'. Ends with what the evidence does not cover.",
249
+ inputSchema: { type: "object", properties: { ...STUDY_ARG } },
250
+ },
251
+ {
252
+ name: "track_metric",
253
+ description: "One measure across studies in date order: task completion or mean SUS, optionally split by segment. Answers 'did it improve since last release'. Reports each study separately and never averages across them.",
254
+ inputSchema: {
255
+ type: "object",
256
+ properties: {
257
+ metric: { type: "string", description: "completion or sus. Defaults to both." },
258
+ segment: { type: "string", description: "Optional. Restrict to one user segment." },
259
+ },
260
+ },
261
+ },
262
+ {
263
+ name: "prior_research",
264
+ description: "Which past studies already touched a topic, when, and with how many participants. Call this before commissioning new research so a question that has already been answered is not paid for twice.",
265
+ inputSchema: {
266
+ type: "object",
267
+ properties: { topic: { type: "string", description: "A theme, issue or phrase." } },
268
+ required: ["topic"],
269
+ },
270
+ },
271
+ {
272
+ name: "evidence_trail",
273
+ description: "Every piece of evidence behind one named issue, across all studies, with participant, study, release and date. Use when someone asks months later why a change was made, or when a decision has to be defensible to an auditor or regulator.",
274
+ inputSchema: {
275
+ type: "object",
276
+ properties: { issue: { type: "string", description: "Issue name, or a theme." } },
277
+ required: ["issue"],
278
+ },
279
+ },
280
+ {
281
+ name: "consent_status",
282
+ description: "What each participant agreed to, and what is therefore withheld. Shows the current audience setting, who has withdrawn, and whose quotes cannot be reproduced. Call this before putting quotes in anything that leaves the research team.",
283
+ inputSchema: { type: "object", properties: { ...STUDY_ARG } },
284
+ },
285
+ {
286
+ name: "coverage_gaps",
287
+ description: "Who has NOT been researched: segments and regions with thin or no evidence, and themes resting on very few participants. Answers 'whose experience are we guessing about'. The most important tool for anyone about to generalise a finding.",
288
+ inputSchema: { type: "object", properties: { ...STUDY_ARG } },
289
+ },
182
290
  ],
183
291
  }));
184
292
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -222,28 +330,42 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
222
330
  }
223
331
  if (name === "search_transcripts") {
224
332
  const q = String(args.query ?? "").toLowerCase();
225
- const hits = rows.filter((r) => r.quote.toLowerCase().includes(q) || r.theme.toLowerCase().includes(q));
333
+ const seg = applySegment(rows, args.segment);
334
+ const hits = seg.rows.filter((r) => r.quote.toLowerCase().includes(q) || r.theme.toLowerCase().includes(q));
226
335
  if (!hits.length)
227
- return out(`No excerpts match "${args.query}"${scope}.`);
228
- return out(`${hits.length} excerpt(s) matching "${args.query}"${scope}:\n\n` +
336
+ return out(`No excerpts match "${args.query}"${scope}${seg.label}.`);
337
+ return out(`${hits.length} excerpt(s) matching "${args.query}"${scope}${seg.label}:\n\n` +
229
338
  hits
230
- .map((h) => `[${cite(h, multi)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n "${h.quote}"`)
339
+ .map((h) => `[${cite(h, multi)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n ${quoteOrWithheld(h)}`)
231
340
  .join("\n\n"));
232
341
  }
233
342
  if (name === "quotes_by_theme") {
234
- let hits = rows.filter((r) => r.theme === args.theme);
343
+ const seg = applySegment(rows, args.segment);
344
+ let hits = seg.rows.filter((r) => themeMatches(r.theme, args.theme));
235
345
  if (typeof args.round === "number")
236
346
  hits = hits.filter((r) => r.round === args.round);
237
- if (!hits.length)
238
- return out(`No quotes tagged "${args.theme}"${scope}.`);
347
+ if (!hits.length) {
348
+ // Distinguish "that theme does not exist" from "it exists, just not in
349
+ // that segment". Conflating them reads as an absence of evidence.
350
+ const themeExists = rows.some((r) => themeMatches(r.theme, args.theme));
351
+ if (themeExists && args.segment) {
352
+ const segs = [
353
+ ...new Set(rows.filter((r) => themeMatches(r.theme, args.theme)).map((r) => r.segment)),
354
+ ];
355
+ return out(`"${args.theme}" was raised${scope}, but not by any participant in segment "${args.segment}".\n` +
356
+ `Segments that did raise it: ${segs.join(", ")}.\n` +
357
+ `That the segment did not raise it is not evidence they do not care. They may not have been asked.`);
358
+ }
359
+ return out(`No quotes tagged "${args.theme}"${scope}${seg.label}.` + themeSuggestion(rows, args.theme));
360
+ }
239
361
  const groups = { positive: [], negative: [], mixed: [] };
240
362
  hits.forEach((h) => groups[h.sentiment]?.push(h));
241
363
  const body = Object.entries(groups)
242
364
  .filter(([, v]) => v.length)
243
365
  .map(([k, v]) => `${k.toUpperCase()} (${v.length})\n` +
244
- v.map((h) => ` [${cite(h, multi)}] "${h.quote}"`).join("\n"))
366
+ v.map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`).join("\n"))
245
367
  .join("\n\n");
246
- return out(`Theme "${args.theme}"${scope} — ${hits.length} excerpt(s)\n\n${body}`);
368
+ return out(`Theme "${args.theme}"${scope}${seg.label} — ${hits.length} excerpt(s)\n\n${body}`);
247
369
  }
248
370
  if (name === "pattern_frequency") {
249
371
  // Counts are never pooled across studies. Different studies have different
@@ -251,7 +373,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
251
373
  // would be a number that looks like evidence and is not one.
252
374
  const perStudy = (st) => {
253
375
  const rws = allExcerpts([st]);
254
- const total = new Set(st.sessions.map((s) => s.id)).size;
376
+ // active(), not st.sessions: a withdrawn participant must not sit in a
377
+ // denominator. Leaving them in silently understates every percentage.
378
+ const live = active(st.sessions);
379
+ const total = new Set(live.map((s) => s.id)).size;
255
380
  if (!total)
256
381
  return `${st.key}: no sessions.`;
257
382
  const byTheme = {};
@@ -268,7 +393,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
268
393
  .join(", ");
269
394
  return ` ${theme}: ${set.size}/${total} participants (${seg})`;
270
395
  });
271
- return `${st.key} — ${st.sessions.length} sessions\n${lines.join("\n")}`;
396
+ return `${st.key} — ${live.length} sessions\n${lines.join("\n")}`;
272
397
  };
273
398
  if (!sessions.length)
274
399
  return out(`No sessions${scope}.`);
@@ -304,17 +429,32 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
304
429
  sus: scored.length ? Math.round(scored.reduce((a, s) => a + s.sus, 0) / scored.length) : null,
305
430
  };
306
431
  };
307
- const first = summarise(rounds[0]);
308
- const last = summarise(rounds[rounds.length - 1]);
432
+ const perRound = rounds.map(summarise);
433
+ const first = perRound[0];
434
+ const last = perRound[perRound.length - 1];
309
435
  const susLine = (r) => (r.sus === null ? "not recorded" : String(r.sus));
310
436
  const susDelta = first.sus !== null && last.sus !== null
311
437
  ? `SUS ${last.sus - first.sus >= 0 ? "+" : ""}${last.sus - first.sus}`
312
438
  : "SUS not comparable";
313
- return out(`Theme "${theme}"${scope}, round ${rounds[0]} vs round ${rounds[rounds.length - 1]}\n\n` +
314
- `Round ${rounds[0]}: ${first.pos} positive / ${first.neg} negative | completed ${first.done}/${first.n} | mean SUS ${susLine(first)}\n` +
315
- `Round ${rounds[rounds.length - 1]}: ${last.pos} positive / ${last.neg} negative | completed ${last.done}/${last.n} | mean SUS ${susLine(last)}\n\n` +
316
- `Direction: sentiment ${last.pos - first.pos >= 0 ? "improved" : "declined"}, ${susDelta}.\n` +
317
- `Caution: ${first.n + last.n} sessions is a small n. Treat as directional, not significant.`);
439
+ // Every round is listed. With three or more, reporting only the first and
440
+ // last would silently hide a regression in the middle.
441
+ const header = rounds.length === 2
442
+ ? `round ${rounds[0]} vs round ${rounds[1]}`
443
+ : `rounds ${rounds.join(", ")}`;
444
+ const body = rounds
445
+ .map((rd, i) => {
446
+ const r = perRound[i];
447
+ return `Round ${rd}: ${r.pos} positive / ${r.neg} negative | completed ${r.done}/${r.n} | mean SUS ${susLine(r)}`;
448
+ })
449
+ .join("\n");
450
+ const totalSessions = perRound.reduce((a, r) => a + r.n, 0);
451
+ return out(`Theme "${theme}"${scope}, ${header}\n\n` +
452
+ body +
453
+ `\n\nDirection: sentiment ${last.pos - first.pos >= 0 ? "improved" : "declined"}, ${susDelta}.\n` +
454
+ `Caution: ${totalSessions} sessions is a small n. Treat as directional, not significant.` +
455
+ (rounds.length > 2
456
+ ? `\nDirection compares round ${rounds[0]} with round ${rounds[rounds.length - 1]} only. Read the middle rounds above before concluding it improved throughout.`
457
+ : ""));
318
458
  }
319
459
  if (name === "draft_findings") {
320
460
  // A finding belongs to a study. Drafting one over pooled studies would put a
@@ -323,9 +463,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
323
463
  return out(`A finding is drafted from one study at a time, so the participant count means something. Name one of: ${all.map((s) => s.key).join(", ")}.`);
324
464
  }
325
465
  const theme = String(args.theme);
326
- const hits = rows.filter((r) => r.theme === theme);
466
+ const hits = rows.filter((r) => themeMatches(r.theme, theme));
327
467
  if (!hits.length)
328
- return out(`No evidence tagged "${theme}"${scope}, so no finding can be drafted.`);
468
+ return out(`No evidence tagged "${theme}"${scope}, so no finding can be drafted.` +
469
+ themeSuggestion(rows, theme));
329
470
  const people = new Set(hits.map((h) => h.participant));
330
471
  const total = new Set(sessions.map((s) => s.id)).size;
331
472
  const neg = hits.filter((h) => h.sentiment === "negative");
@@ -333,7 +474,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
333
474
  const mix = hits.filter((h) => h.sentiment === "mixed");
334
475
  const block = (label, v) => v.length
335
476
  ? `${label} (${v.length}):\n` +
336
- v.map((h) => ` [${cite(h, multi)}] "${h.quote}"`).join("\n") +
477
+ v.map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`).join("\n") +
337
478
  "\n"
338
479
  : "";
339
480
  return out(`DRAFT FINDING — ${theme}${scope}\n\n` +
@@ -350,6 +491,221 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
350
491
  ` Any causal claim. This is correlation in a small sample.\n\n` +
351
492
  `The researcher writes the recommendation. This tool supplies evidence and its limits.`);
352
493
  }
494
+ // ---------------------------------------------------------------------
495
+ // Tools for teams who have to make a release decision, not just read a
496
+ // report. They still only retrieve and count. What they add is the honest
497
+ // other half: what the evidence does NOT cover.
498
+ // ---------------------------------------------------------------------
499
+ const studyLabel = (st) => {
500
+ const bits = [st.study?.product, st.study?.release, st.study?.date].filter(Boolean);
501
+ return bits.length ? `${st.key} (${bits.join(", ")})` : st.key;
502
+ };
503
+ if (name === "release_readiness") {
504
+ if (!rows.length)
505
+ return out(`No evidence${scope}.`);
506
+ const people = new Set(sessions.map((s) => s.id)).size;
507
+ const tagged = rows.filter((r) => r.severity);
508
+ if (!tagged.length) {
509
+ return out(`No severity tags${scope}, so this cannot report readiness.\n\n` +
510
+ `Add "severity": "blocker" | "major" | "minor" to the excerpts that represent problems. ` +
511
+ `Severity is the researcher's judgment, which is why this tool reads it rather than deciding it.`);
512
+ }
513
+ // Group by issue where tagged, otherwise fall back to the theme.
514
+ const byIssue = {};
515
+ tagged.forEach((r) => ((byIssue[r.issue || r.theme] ??= []).push(r)));
516
+ const sections = SEVERITY_ORDER.map((sev) => {
517
+ const issues = Object.entries(byIssue)
518
+ .filter(([, v]) => v.some((r) => r.severity === sev))
519
+ .map(([issue, v]) => {
520
+ const hits = v.filter((r) => r.severity === sev);
521
+ const who = new Set(hits.map((h) => h.participant));
522
+ const segs = [...new Set(hits.map((h) => h.segment))];
523
+ const quotes = hits
524
+ .slice(0, 3)
525
+ .map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`)
526
+ .join("\n");
527
+ return {
528
+ n: who.size,
529
+ text: ` ${issue}: ${who.size} of ${people} participants (${segs.join(", ")})\n` +
530
+ quotes +
531
+ (hits.length > 3 ? `\n ...and ${hits.length - 3} more` : ""),
532
+ };
533
+ })
534
+ .sort((a, b) => b.n - a.n);
535
+ if (!issues.length)
536
+ return null;
537
+ const total = new Set(tagged.filter((r) => r.severity === sev).map((r) => r.participant)).size;
538
+ return `${sev.toUpperCase()} — ${issues.length} issue(s), ${total} participant(s) affected\n${issues
539
+ .map((i) => i.text)
540
+ .join("\n\n")}`;
541
+ }).filter(Boolean);
542
+ const segs = [...new Set(sessions.map((s) => s.segment))];
543
+ const regions = [...new Set(sessions.map((s) => s.region).filter(Boolean))];
544
+ const thin = segs.filter((sg) => sessions.filter((s) => s.segment === sg).length < 5);
545
+ return out(`RELEASE READINESS${scope}\n` +
546
+ `Evidence from ${studies.length} study/studies, ${people} participants, ${sessions.length} sessions.\n` +
547
+ studies.map((st) => ` ${studyLabel(st)}`).join("\n") +
548
+ `\n\n${sections.join("\n\n")}\n\n` +
549
+ `WHAT THIS IS NOT\n` +
550
+ ` Not a ship decision. It counts participants who hit an issue; it does not weigh that against revenue, deadline or effort.\n` +
551
+ ` Not a severity judgment. Severity is read from the researcher's tags, not inferred.\n` +
552
+ ` Not frequency in the wild. ${people} participants in a lab is not ${people} percent of customers.\n\n` +
553
+ `THIN EVIDENCE, TREAT WITH CARE\n` +
554
+ (thin.length
555
+ ? thin
556
+ .map((sg) => ` ${sg}: only ${sessions.filter((s) => s.segment === sg).length} participant(s)`)
557
+ .join("\n")
558
+ : ` Every segment has 5 or more participants.`) +
559
+ (regions.length ? `\n Regions covered: ${regions.join(", ")}` : `\n No region recorded on any session.`));
560
+ }
561
+ if (name === "track_metric") {
562
+ const want = String(args.metric ?? "").toLowerCase();
563
+ const seg = args.segment ? String(args.segment).toLowerCase() : null;
564
+ const dated = [...all].sort((a, b) => String(a.study?.date ?? "").localeCompare(String(b.study?.date ?? "")));
565
+ const lines = dated.map((st) => {
566
+ let ss = st.sessions;
567
+ if (seg)
568
+ ss = ss.filter((s) => s.segment.toLowerCase() === seg);
569
+ if (!ss.length)
570
+ return `${studyLabel(st)}: no sessions${seg ? ` for segment "${args.segment}"` : ""}`;
571
+ const scored = ss.filter((s) => typeof s.sus === "number" && s.sus > 0);
572
+ const completion = `${ss.filter((s) => s.completed).length}/${ss.length}`;
573
+ const sus = scored.length
574
+ ? Math.round(scored.reduce((a, s) => a + s.sus, 0) / scored.length)
575
+ : null;
576
+ const parts = [];
577
+ if (!want || want.includes("complet"))
578
+ parts.push(`completion ${completion}`);
579
+ if (!want || want.includes("sus"))
580
+ parts.push(`mean SUS ${sus === null ? "not recorded" : sus}`);
581
+ return `${studyLabel(st)}: ${parts.join(" | ")} (n=${ss.length})`;
582
+ });
583
+ return out(`Metric over time${seg ? `, segment "${args.segment}"` : ""}, oldest first\n\n` +
584
+ lines.join("\n") +
585
+ `\n\nStudies are listed separately and never averaged together. Different participants, recruitment and tasks.\n` +
586
+ (dated.some((st) => !st.study?.date)
587
+ ? `Some studies have no "date" in their study block, so this order may be wrong. Add one to fix it.`
588
+ : `Ordered by the "date" field on each study.`));
589
+ }
590
+ if (name === "prior_research") {
591
+ const q = String(args.topic ?? "").toLowerCase();
592
+ const found = all
593
+ .map((st) => {
594
+ const rws = allExcerpts([st]).filter((r) => r.theme.toLowerCase().includes(q) ||
595
+ r.quote.toLowerCase().includes(q) ||
596
+ (r.issue ?? "").toLowerCase().includes(q));
597
+ return { st, rws, people: new Set(rws.map((r) => r.participant)) };
598
+ })
599
+ .filter((x) => x.rws.length);
600
+ if (!found.length) {
601
+ return out(`No existing study in this repository covers "${args.topic}".\n\n` +
602
+ `Searched ${all.length} study/studies: ${all.map((s) => s.key).join(", ")}.\n` +
603
+ `Absence here means nobody filed it, which is not the same as nobody having asked. Check outside the repository before concluding it is unresearched.`);
604
+ }
605
+ return out(`"${args.topic}" has already been researched in ${found.length} study/studies\n\n` +
606
+ found
607
+ .map((x) => `${studyLabel(x.st)}\n` +
608
+ ` ${x.people.size} participant(s), ${x.rws.length} excerpt(s)\n` +
609
+ ` themes: ${[...new Set(x.rws.map((r) => r.theme))].join(", ")}\n` +
610
+ ` e.g. [${cite({ ...x.rws[0], study: x.st.key }, true)}] ${quoteOrWithheld(x.rws[0])}`)
611
+ .join("\n\n") +
612
+ `\n\nRead these before commissioning new research on the topic.`);
613
+ }
614
+ if (name === "evidence_trail") {
615
+ const q = String(args.issue ?? "").toLowerCase();
616
+ const hits = allExcerpts(all).filter((r) => (r.issue ?? "").toLowerCase() === q || r.theme.toLowerCase() === q ||
617
+ (r.issue ?? "").toLowerCase().includes(q));
618
+ if (!hits.length) {
619
+ return out(`No evidence filed under "${args.issue}" in any study.`);
620
+ }
621
+ const byStudy = {};
622
+ hits.forEach((h) => ((byStudy[h.study] ??= []).push(h)));
623
+ const people = new Set(hits.map((h) => h.participant));
624
+ return out(`EVIDENCE TRAIL — ${args.issue}\n` +
625
+ `${hits.length} excerpt(s) from ${people.size} participant(s), across ${Object.keys(byStudy).length} study/studies.\n\n` +
626
+ Object.entries(byStudy)
627
+ .map(([key, v]) => {
628
+ const st = all.find((s) => s.key === key);
629
+ return (`${studyLabel(st)}\n` +
630
+ v
631
+ .map((h) => ` [${h.participant} | R${h.round} | ${h.t}] ${h.segment}` +
632
+ (h.severity ? ` | ${h.severity}` : "") +
633
+ `\n ${quoteOrWithheld(h)}`)
634
+ .join("\n"));
635
+ })
636
+ .join("\n\n") +
637
+ `\n\nEvery line above is a participant's own words with the session it came from. Nothing here is summarised or rephrased.`);
638
+ }
639
+ if (name === "consent_status") {
640
+ const everySession = studies.flatMap((st) => st.sessions.map((x) => ({ ...x, study: st.key })));
641
+ if (!everySession.length)
642
+ return out(`No sessions${scope}.`);
643
+ const group = {
644
+ full: [], internal: [], counts: [], withdrawn: [],
645
+ };
646
+ everySession.forEach((x) => group[consentOf(x)].push(x));
647
+ const line = (x) => ` ${x.id}${multi ? ` (${x.study})` : ""}${x.consent_note ? ` — ${x.consent_note}` : ""}`;
648
+ const section = (c, explain) => group[c].length ? `${c.toUpperCase()} (${group[c].length}) — ${explain}\n${group[c].map(line).join("\n")}` : "";
649
+ const withheld = everySession.filter((x) => consentOf(x) !== "withdrawn" && !mayQuote(consentOf(x))).length;
650
+ return out(`CONSENT${scope}\n` +
651
+ `Audience: ${AUDIENCE === "team" ? "research team (RESEARCH_LENS_AUDIENCE unset or \"team\")" : "external (RESEARCH_LENS_AUDIENCE=external)"}\n` +
652
+ `${everySession.length} participants on file, ${group.withdrawn.length} withdrawn, ` +
653
+ `${withheld} whose quotes are withheld at this audience setting.\n\n` +
654
+ [
655
+ section("full", "quotes may be reproduced"),
656
+ section("internal", `quotes shown to the research team only${AUDIENCE === "external" ? ", withheld here" : ""}`),
657
+ section("counts", "counted, never quoted"),
658
+ section("withdrawn", "removed from every count, quote and denominator"),
659
+ ]
660
+ .filter(Boolean)
661
+ .join("\n\n") +
662
+ `\n\nHOW THIS IS ENFORCED\n` +
663
+ ` Withdrawn participants are removed before anything is counted, so they do not sit in a denominator.\n` +
664
+ ` A withheld quote is always labelled as withheld, never silently dropped. "Nobody said this" and "someone said this and did not consent to it leaving" must never look the same.\n` +
665
+ ` Consent is read from the data. This server never infers it, and a participant with no consent field is treated as "full", which is how studies recorded before consent tracking behave.\n` +
666
+ ` Set RESEARCH_LENS_AUDIENCE=external when the output will leave the research team.`);
667
+ }
668
+ if (name === "coverage_gaps") {
669
+ if (!sessions.length)
670
+ return out(`No sessions${scope}.`);
671
+ const people = new Set(sessions.map((s) => s.id)).size;
672
+ const bySeg = {};
673
+ const byRegion = {};
674
+ sessions.forEach((s) => {
675
+ (bySeg[s.segment] ??= new Set()).add(s.id);
676
+ if (s.region)
677
+ (byRegion[s.region] ??= new Set()).add(s.id);
678
+ });
679
+ const byTheme = {};
680
+ rows.forEach((r) => (byTheme[r.theme] ??= new Set()).add(r.participant));
681
+ const fragile = Object.entries(byTheme)
682
+ .filter(([, v]) => v.size <= 2)
683
+ .sort((a, b) => a[1].size - b[1].size);
684
+ const thinSeg = Object.entries(bySeg).filter(([, v]) => v.size < 5);
685
+ return out(`COVERAGE GAPS${scope}\n${people} participants across ${sessions.length} sessions.\n\n` +
686
+ `SEGMENTS\n` +
687
+ Object.entries(bySeg)
688
+ .sort((a, b) => b[1].size - a[1].size)
689
+ .map(([k, v]) => ` ${k}: ${v.size}`)
690
+ .join("\n") +
691
+ (thinSeg.length
692
+ ? `\n Thin: ${thinSeg.map(([k, v]) => `${k} (${v.size})`).join(", ")}. A finding about these rests on very few people.`
693
+ : `\n No segment below 5 participants.`) +
694
+ `\n\nREGIONS\n` +
695
+ (Object.keys(byRegion).length
696
+ ? Object.entries(byRegion)
697
+ .map(([k, v]) => ` ${k}: ${v.size}`)
698
+ .join("\n")
699
+ : ` No region recorded on any session, so nothing can be said about geographic coverage.`) +
700
+ `\n\nTHEMES RESTING ON 1 OR 2 PARTICIPANTS\n` +
701
+ (fragile.length
702
+ ? fragile.map(([k, v]) => ` ${k}: ${v.size}`).join("\n") +
703
+ `\n These are leads, not findings. Do not put them in a readout as patterns.`
704
+ : ` Every theme has 3 or more participants behind it.`) +
705
+ `\n\nWHAT IS MISSING ENTIRELY\n` +
706
+ ` This tool can only report on people who were recruited. Anyone your recruitment never reached is invisible here, ` +
707
+ `including non-users, people who gave up before becoming customers, and anyone screened out. That is usually the biggest gap and no tool can show it to you.`);
708
+ }
353
709
  return out(`Unknown tool: ${name}`);
354
710
  });
355
711
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "research-lens-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "An MCP server that lets an AI assistant query a UX research repository with participant-level citations attached. It retrieves and counts; it does not interpret.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,12 @@
32
32
  "claude",
33
33
  "ai-research",
34
34
  "synthesis",
35
- "usability-testing"
35
+ "usability-testing",
36
+ "consent",
37
+ "gdpr",
38
+ "research-ethics",
39
+ "irb",
40
+ "participant-consent"
36
41
  ],
37
42
  "license": "MIT",
38
43
  "author": "Siddhi Manche",