research-lens-mcp 2.0.0 → 2.3.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 +50 -2
  2. package/build/index.js +437 -44
  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:
@@ -98,7 +139,14 @@ Node 18 or newer. Nothing to clone, nothing to build.
98
139
 
99
140
  ## Your data
100
141
 
101
- `RESEARCH_LENS_DATA` points at a folder. One `.json` file per study, added or edited any time — the server re-reads on every question, so no restart.
142
+ `RESEARCH_LENS_DATA` points at a folder of `.json` files, added or edited any time — the server
143
+ re-reads on every question, so no restart.
144
+
145
+ **Split a study across as many files as you like.** Files that declare the same `study.name` are
146
+ merged into one study, so one file per interview works exactly as well as one file per study. A
147
+ study split across files is still one study and its participants belong in one denominator.
148
+ Repeated participant-and-round pairs are skipped rather than double counted, and `list_studies`
149
+ reports which files were merged and anything it dropped.
102
150
 
103
151
  ```json
104
152
  {
@@ -131,7 +179,7 @@ Only `id`, `round`, `segment` and the excerpt fields are required. Everything el
131
179
  **From a spreadsheet.** Fill in [`template.csv`](template.csv), one row per quote, then:
132
180
 
133
181
  ```bash
134
- npx research-lens-mcp-import my-study.csv "Study name"
182
+ npx -p research-lens-mcp research-lens-import my-study.csv "Study name"
135
183
  ```
136
184
 
137
185
  It validates every row, names the row and the reason if something is wrong, and writes nothing until the file is clean.
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");
@@ -58,19 +59,20 @@ function studyFiles() {
58
59
  * reads better than whatever the file happens to be called; the filename is the
59
60
  * fallback. Collisions get a numeric suffix so two studies never share a handle.
60
61
  */
61
- function keyFor(parsed, file, taken) {
62
+ function keyFor(parsed, file) {
62
63
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
63
- const base = slug(String(parsed.study?.name ?? "")) || slug(basename(file, ".json")) || "study";
64
- if (!taken.some((t) => t.key === base))
65
- return base;
66
- let n = 2;
67
- while (taken.some((t) => t.key === `${base}-${n}`))
68
- n++;
69
- return `${base}-${n}`;
64
+ return slug(String(parsed.study?.name ?? "")) || slug(basename(file, ".json")) || "study";
70
65
  }
71
- /** Read every study fresh on each call, so edits take effect with no restart. */
66
+ /**
67
+ * Read every study fresh on each call, so edits take effect with no restart.
68
+ *
69
+ * Files that declare the same study name are MERGED into one study. A study
70
+ * split across one file per interview is still one study, and its participants
71
+ * belong in one denominator. Splitting the file should never silently split
72
+ * the evidence.
73
+ */
72
74
  function loadAll() {
73
- const out = [];
75
+ const byKey = new Map();
74
76
  for (const file of studyFiles()) {
75
77
  let parsed;
76
78
  try {
@@ -82,9 +84,29 @@ function loadAll() {
82
84
  if (!Array.isArray(parsed?.sessions)) {
83
85
  throw new DataError(`"${basename(file)}" has no "sessions" array.`);
84
86
  }
85
- out.push({ ...parsed, key: keyFor(parsed, file, out), file });
87
+ const key = keyFor(parsed, file);
88
+ const existing = byKey.get(key);
89
+ const stamp = (ss) => ss.map((x) => ({ ...x, round: x.round ?? 1, _file: basename(file) }));
90
+ if (!existing) {
91
+ byKey.set(key, { ...parsed, sessions: stamp(parsed.sessions), key, files: [basename(file)], duplicates: [] });
92
+ continue;
93
+ }
94
+ // Merge, but never count the same participant-round twice.
95
+ const seen = new Set(existing.sessions.map((s) => `${s.id}|${s.round}`));
96
+ for (const sess of parsed.sessions) {
97
+ const sig = `${sess.id}|${sess.round}`;
98
+ if (seen.has(sig)) {
99
+ existing.duplicates.push(`${sess.id} round ${sess.round} (${basename(file)})`);
100
+ continue;
101
+ }
102
+ seen.add(sig);
103
+ existing.sessions.push({ ...sess, round: sess.round ?? 1, _file: basename(file) });
104
+ }
105
+ existing.files.push(basename(file));
106
+ // Keep whichever study block carries the most context.
107
+ existing.study = { ...parsed.study, ...existing.study };
86
108
  }
87
- return out;
109
+ return [...byKey.values()];
88
110
  }
89
111
  /**
90
112
  * Pick the studies a call applies to. With no name given, every study is used,
@@ -105,19 +127,86 @@ function pick(all, name) {
105
127
  }
106
128
  /** Flatten every excerpt with its session and study context attached. */
107
129
  function allExcerpts(studies) {
108
- return studies.flatMap((st) => st.sessions.flatMap((s) => s.excerpts.map((e) => ({
130
+ return studies.flatMap((st) => active(st.sessions).flatMap((s) => s.excerpts.map((e) => ({
109
131
  ...e,
110
132
  participant: s.id,
111
133
  round: s.round,
112
134
  segment: s.segment,
113
135
  task: s.task,
114
136
  study: st.key,
137
+ consent: consentOf(s),
138
+ // An explicit source always shows. The filename only helps when a study
139
+ // was assembled from several files, so it stays hidden otherwise.
140
+ source: s.source ?? (st.files.length > 1 ? s._file ?? "" : ""),
115
141
  }))));
116
142
  }
117
- const sessionsOf = (studies) => studies.flatMap((s) => s.sessions);
143
+ const sessionsOf = (studies) => studies.flatMap((s) => active(s.sessions));
144
+ const norm = (s) => String(s ?? "").trim().toLowerCase();
145
+ /**
146
+ * Who is asking. An MCP client is a pipe to wherever the answer ends up, so the
147
+ * operator declares the audience once, the way an API caller declares its scope.
148
+ * Defaults to the research team, the most permissive setting, because an
149
+ * existing study with no consent field should keep working unchanged.
150
+ */
151
+ const AUDIENCE = norm(process.env.RESEARCH_LENS_AUDIENCE) === "external" ? "external" : "team";
152
+ const consentOf = (s) => s.consent ?? "full";
153
+ /** Withdrawn participants are removed from everything, including denominators. */
154
+ const active = (xs) => xs.filter((x) => consentOf(x) !== "withdrawn");
155
+ /** May this participant's words be reproduced, for this audience? */
156
+ function mayQuote(consent) {
157
+ if (consent === "withdrawn" || consent === "counts")
158
+ return false;
159
+ if (consent === "internal")
160
+ return AUDIENCE === "team";
161
+ return true;
162
+ }
163
+ /**
164
+ * Renders a quote, or says plainly that it is being withheld and why. Never
165
+ * silently omits: a reader must be able to tell the difference between
166
+ * "nobody said this" and "someone said this and did not consent to it leaving".
167
+ */
168
+ function quoteOrWithheld(h) {
169
+ if (mayQuote(h.consent))
170
+ return `"${h.quote}"`;
171
+ return h.consent === "counts"
172
+ ? `[quote withheld: participant consented to be counted, not quoted]`
173
+ : `[quote withheld: consented to research-team use only, and this output is external]`;
174
+ }
175
+ /**
176
+ * Themes are matched case-insensitively. An assistant will naturally write
177
+ * "Trust" for a theme filed as "trust", and returning "no quotes" for a
178
+ * capital letter would read as an absence of evidence, which is the most
179
+ * dangerous wrong answer this server could give.
180
+ */
181
+ const themeMatches = (rowTheme, wanted) => norm(rowTheme) === norm(wanted);
182
+ /** When a theme finds nothing, say what does exist rather than just "none". */
183
+ function themeSuggestion(rows, wanted) {
184
+ const all = [...new Set(rows.map((r) => r.theme))];
185
+ if (!all.length)
186
+ return "";
187
+ const q = norm(wanted);
188
+ const near = all.filter((t) => norm(t).includes(q) || q.includes(norm(t)));
189
+ return near.length
190
+ ? ` Did you mean: ${near.join(", ")}?`
191
+ : ` Themes in scope: ${all.join(", ")}.`;
192
+ }
193
+ /** Optional segment filter, case-insensitive, shared by the retrieval tools. */
194
+ function applySegment(rows, segment) {
195
+ if (!segment)
196
+ return { rows, label: "" };
197
+ const q = norm(segment);
198
+ const hit = rows.filter((r) => norm(r.segment) === q);
199
+ if (hit.length)
200
+ return { rows: hit, label: `, segment "${segment}"` };
201
+ const loose = rows.filter((r) => norm(r.segment).includes(q));
202
+ return { rows: loose, label: `, segment "${segment}"` };
203
+ }
118
204
  /** Prefix a citation with the study only when more than one is in scope. */
119
- 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: {} } });
205
+ const cite = (h, multi, showRound = true) => `${multi ? h.study + " | " : ""}${h.participant}` +
206
+ (showRound && h.round != null ? ` | R${h.round}` : "") +
207
+ ` | ${h.t}` +
208
+ (h.source ? ` | ${h.source}` : "");
209
+ const server = new Server({ name: "research-lens", version: "2.3.0" }, { capabilities: { tools: {} } });
121
210
  const STUDY_ARG = {
122
211
  study: {
123
212
  type: "string",
@@ -138,6 +227,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
138
227
  type: "object",
139
228
  properties: {
140
229
  query: { type: "string", description: "Words or phrase to find." },
230
+ segment: { type: "string", description: "Optional. Restrict to one user segment." },
141
231
  ...STUDY_ARG,
142
232
  },
143
233
  required: ["query"],
@@ -149,8 +239,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
149
239
  inputSchema: {
150
240
  type: "object",
151
241
  properties: {
152
- theme: { type: "string", description: "e.g. trust, onboarding, navigation" },
242
+ theme: { type: "string", description: "e.g. trust, onboarding, navigation. Case-insensitive." },
153
243
  round: { type: "number", description: "Optional: restrict to one research round." },
244
+ segment: { type: "string", description: "Optional. Restrict to one user segment." },
154
245
  ...STUDY_ARG,
155
246
  },
156
247
  required: ["theme"],
@@ -179,6 +270,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
179
270
  required: ["theme"],
180
271
  },
181
272
  },
273
+ {
274
+ name: "release_readiness",
275
+ 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.",
276
+ inputSchema: { type: "object", properties: { ...STUDY_ARG } },
277
+ },
278
+ {
279
+ name: "track_metric",
280
+ 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.",
281
+ inputSchema: {
282
+ type: "object",
283
+ properties: {
284
+ metric: { type: "string", description: "completion or sus. Defaults to both." },
285
+ segment: { type: "string", description: "Optional. Restrict to one user segment." },
286
+ },
287
+ },
288
+ },
289
+ {
290
+ name: "prior_research",
291
+ 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.",
292
+ inputSchema: {
293
+ type: "object",
294
+ properties: { topic: { type: "string", description: "A theme, issue or phrase." } },
295
+ required: ["topic"],
296
+ },
297
+ },
298
+ {
299
+ name: "evidence_trail",
300
+ 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.",
301
+ inputSchema: {
302
+ type: "object",
303
+ properties: { issue: { type: "string", description: "Issue name, or a theme." } },
304
+ required: ["issue"],
305
+ },
306
+ },
307
+ {
308
+ name: "consent_status",
309
+ 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.",
310
+ inputSchema: { type: "object", properties: { ...STUDY_ARG } },
311
+ },
312
+ {
313
+ name: "coverage_gaps",
314
+ 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.",
315
+ inputSchema: { type: "object", properties: { ...STUDY_ARG } },
316
+ },
182
317
  ],
183
318
  }));
184
319
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -199,16 +334,22 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
199
334
  const rows = allExcerpts(studies);
200
335
  const sessions = sessionsOf(studies);
201
336
  const multi = studies.length > 1;
337
+ // Rounds are only worth printing when a study actually ran more than one.
338
+ const showRound = new Set(sessions.map((x) => x.round ?? 1)).size > 1;
202
339
  const scope = args.study ? ` in "${args.study}"` : multi ? ` across ${studies.length} studies` : "";
203
340
  if (name === "list_studies") {
204
341
  const lines = all.map((st) => {
205
342
  const people = new Set(st.sessions.map((s) => s.id));
206
- const rounds = [...new Set(st.sessions.map((s) => s.round))].sort();
343
+ const rounds = [...new Set(st.sessions.map((s) => s.round ?? 1))].sort();
207
344
  const segments = [...new Set(st.sessions.map((s) => s.segment))];
208
345
  const themes = [...new Set(st.sessions.flatMap((s) => s.excerpts.map((e) => e.theme)))];
209
346
  const quotes = st.sessions.reduce((a, s) => a + s.excerpts.length, 0);
210
347
  return (`${st.key}\n` +
211
348
  ` name: ${st.study?.name ?? "(unnamed)"}\n` +
349
+ (st.files.length > 1 ? ` merged from: ${st.files.length} files (${st.files.join(", ")})\n` : "") +
350
+ (st.duplicates.length
351
+ ? ` SKIPPED as duplicates: ${st.duplicates.join("; ")}\n`
352
+ : "") +
212
353
  ` participants: ${people.size} sessions: ${st.sessions.length} quotes: ${quotes}\n` +
213
354
  ` rounds: ${rounds.join(", ") || "none"}\n` +
214
355
  ` segments: ${segments.join(", ") || "none"}\n` +
@@ -222,28 +363,42 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
222
363
  }
223
364
  if (name === "search_transcripts") {
224
365
  const q = String(args.query ?? "").toLowerCase();
225
- const hits = rows.filter((r) => r.quote.toLowerCase().includes(q) || r.theme.toLowerCase().includes(q));
366
+ const seg = applySegment(rows, args.segment);
367
+ const hits = seg.rows.filter((r) => r.quote.toLowerCase().includes(q) || r.theme.toLowerCase().includes(q));
226
368
  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` +
369
+ return out(`No excerpts match "${args.query}"${scope}${seg.label}.`);
370
+ return out(`${hits.length} excerpt(s) matching "${args.query}"${scope}${seg.label}:\n\n` +
229
371
  hits
230
- .map((h) => `[${cite(h, multi)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n "${h.quote}"`)
372
+ .map((h) => `[${cite(h, multi, showRound)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n ${quoteOrWithheld(h)}`)
231
373
  .join("\n\n"));
232
374
  }
233
375
  if (name === "quotes_by_theme") {
234
- let hits = rows.filter((r) => r.theme === args.theme);
376
+ const seg = applySegment(rows, args.segment);
377
+ let hits = seg.rows.filter((r) => themeMatches(r.theme, args.theme));
235
378
  if (typeof args.round === "number")
236
- hits = hits.filter((r) => r.round === args.round);
237
- if (!hits.length)
238
- return out(`No quotes tagged "${args.theme}"${scope}.`);
379
+ hits = hits.filter((r) => (r.round ?? 1) === args.round);
380
+ if (!hits.length) {
381
+ // Distinguish "that theme does not exist" from "it exists, just not in
382
+ // that segment". Conflating them reads as an absence of evidence.
383
+ const themeExists = rows.some((r) => themeMatches(r.theme, args.theme));
384
+ if (themeExists && args.segment) {
385
+ const segs = [
386
+ ...new Set(rows.filter((r) => themeMatches(r.theme, args.theme)).map((r) => r.segment)),
387
+ ];
388
+ return out(`"${args.theme}" was raised${scope}, but not by any participant in segment "${args.segment}".\n` +
389
+ `Segments that did raise it: ${segs.join(", ")}.\n` +
390
+ `That the segment did not raise it is not evidence they do not care. They may not have been asked.`);
391
+ }
392
+ return out(`No quotes tagged "${args.theme}"${scope}${seg.label}.` + themeSuggestion(rows, args.theme));
393
+ }
239
394
  const groups = { positive: [], negative: [], mixed: [] };
240
395
  hits.forEach((h) => groups[h.sentiment]?.push(h));
241
396
  const body = Object.entries(groups)
242
397
  .filter(([, v]) => v.length)
243
398
  .map(([k, v]) => `${k.toUpperCase()} (${v.length})\n` +
244
- v.map((h) => ` [${cite(h, multi)}] "${h.quote}"`).join("\n"))
399
+ v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n"))
245
400
  .join("\n\n");
246
- return out(`Theme "${args.theme}"${scope} — ${hits.length} excerpt(s)\n\n${body}`);
401
+ return out(`Theme "${args.theme}"${scope}${seg.label} — ${hits.length} excerpt(s)\n\n${body}`);
247
402
  }
248
403
  if (name === "pattern_frequency") {
249
404
  // Counts are never pooled across studies. Different studies have different
@@ -251,7 +406,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
251
406
  // would be a number that looks like evidence and is not one.
252
407
  const perStudy = (st) => {
253
408
  const rws = allExcerpts([st]);
254
- const total = new Set(st.sessions.map((s) => s.id)).size;
409
+ // active(), not st.sessions: a withdrawn participant must not sit in a
410
+ // denominator. Leaving them in silently understates every percentage.
411
+ const live = active(st.sessions);
412
+ const total = new Set(live.map((s) => s.id)).size;
255
413
  if (!total)
256
414
  return `${st.key}: no sessions.`;
257
415
  const byTheme = {};
@@ -268,7 +426,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
268
426
  .join(", ");
269
427
  return ` ${theme}: ${set.size}/${total} participants (${seg})`;
270
428
  });
271
- return `${st.key} — ${st.sessions.length} sessions\n${lines.join("\n")}`;
429
+ return `${st.key} — ${live.length} sessions\n${lines.join("\n")}`;
272
430
  };
273
431
  if (!sessions.length)
274
432
  return out(`No sessions${scope}.`);
@@ -288,13 +446,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
288
446
  return out(`Rounds can only be compared within one study. Name one of: ${all.map((s) => s.key).join(", ")}.`);
289
447
  }
290
448
  const theme = String(args.theme);
291
- const rounds = [...new Set(sessions.map((s) => s.round))].sort((a, b) => a - b);
449
+ // round is optional in the data; loadAll defaults it, but narrow for the type
450
+ const rounds = [...new Set(sessions.map((s) => s.round ?? 1))].sort((a, b) => a - b);
292
451
  if (rounds.length < 2) {
293
- return out(`Only round ${rounds[0] ?? "none"} exists${scope}, so there is nothing to compare.`);
452
+ return out(rounds.length === 0
453
+ ? `No sessions${scope}, so there is nothing to compare.`
454
+ : `This study has a single round${scope}, so there is nothing to compare. ` +
455
+ `compare_rounds is for studies run before and after a design change.`);
294
456
  }
295
457
  const summarise = (round) => {
296
- const hits = rows.filter((r) => r.theme === theme && r.round === round);
297
- const ss = sessions.filter((s) => s.round === round);
458
+ const hits = rows.filter((r) => r.theme === theme && (r.round ?? 1) === round);
459
+ const ss = sessions.filter((s) => (s.round ?? 1) === round);
298
460
  const scored = ss.filter((s) => typeof s.sus === "number" && s.sus > 0);
299
461
  return {
300
462
  pos: hits.filter((h) => h.sentiment === "positive").length,
@@ -304,17 +466,32 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
304
466
  sus: scored.length ? Math.round(scored.reduce((a, s) => a + s.sus, 0) / scored.length) : null,
305
467
  };
306
468
  };
307
- const first = summarise(rounds[0]);
308
- const last = summarise(rounds[rounds.length - 1]);
469
+ const perRound = rounds.map(summarise);
470
+ const first = perRound[0];
471
+ const last = perRound[perRound.length - 1];
309
472
  const susLine = (r) => (r.sus === null ? "not recorded" : String(r.sus));
310
473
  const susDelta = first.sus !== null && last.sus !== null
311
474
  ? `SUS ${last.sus - first.sus >= 0 ? "+" : ""}${last.sus - first.sus}`
312
475
  : "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.`);
476
+ // Every round is listed. With three or more, reporting only the first and
477
+ // last would silently hide a regression in the middle.
478
+ const header = rounds.length === 2
479
+ ? `round ${rounds[0]} vs round ${rounds[1]}`
480
+ : `rounds ${rounds.join(", ")}`;
481
+ const body = rounds
482
+ .map((rd, i) => {
483
+ const r = perRound[i];
484
+ return `Round ${rd}: ${r.pos} positive / ${r.neg} negative | completed ${r.done}/${r.n} | mean SUS ${susLine(r)}`;
485
+ })
486
+ .join("\n");
487
+ const totalSessions = perRound.reduce((a, r) => a + r.n, 0);
488
+ return out(`Theme "${theme}"${scope}, ${header}\n\n` +
489
+ body +
490
+ `\n\nDirection: sentiment ${last.pos - first.pos >= 0 ? "improved" : "declined"}, ${susDelta}.\n` +
491
+ `Caution: ${totalSessions} sessions is a small n. Treat as directional, not significant.` +
492
+ (rounds.length > 2
493
+ ? `\nDirection compares round ${rounds[0]} with round ${rounds[rounds.length - 1]} only. Read the middle rounds above before concluding it improved throughout.`
494
+ : ""));
318
495
  }
319
496
  if (name === "draft_findings") {
320
497
  // A finding belongs to a study. Drafting one over pooled studies would put a
@@ -323,9 +500,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
323
500
  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
501
  }
325
502
  const theme = String(args.theme);
326
- const hits = rows.filter((r) => r.theme === theme);
503
+ const hits = rows.filter((r) => themeMatches(r.theme, theme));
327
504
  if (!hits.length)
328
- return out(`No evidence tagged "${theme}"${scope}, so no finding can be drafted.`);
505
+ return out(`No evidence tagged "${theme}"${scope}, so no finding can be drafted.` +
506
+ themeSuggestion(rows, theme));
329
507
  const people = new Set(hits.map((h) => h.participant));
330
508
  const total = new Set(sessions.map((s) => s.id)).size;
331
509
  const neg = hits.filter((h) => h.sentiment === "negative");
@@ -333,7 +511,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
333
511
  const mix = hits.filter((h) => h.sentiment === "mixed");
334
512
  const block = (label, v) => v.length
335
513
  ? `${label} (${v.length}):\n` +
336
- v.map((h) => ` [${cite(h, multi)}] "${h.quote}"`).join("\n") +
514
+ v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n") +
337
515
  "\n"
338
516
  : "";
339
517
  return out(`DRAFT FINDING — ${theme}${scope}\n\n` +
@@ -350,6 +528,221 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
350
528
  ` Any causal claim. This is correlation in a small sample.\n\n` +
351
529
  `The researcher writes the recommendation. This tool supplies evidence and its limits.`);
352
530
  }
531
+ // ---------------------------------------------------------------------
532
+ // Tools for teams who have to make a release decision, not just read a
533
+ // report. They still only retrieve and count. What they add is the honest
534
+ // other half: what the evidence does NOT cover.
535
+ // ---------------------------------------------------------------------
536
+ const studyLabel = (st) => {
537
+ const bits = [st.study?.product, st.study?.release, st.study?.date].filter(Boolean);
538
+ return bits.length ? `${st.key} (${bits.join(", ")})` : st.key;
539
+ };
540
+ if (name === "release_readiness") {
541
+ if (!rows.length)
542
+ return out(`No evidence${scope}.`);
543
+ const people = new Set(sessions.map((s) => s.id)).size;
544
+ const tagged = rows.filter((r) => r.severity);
545
+ if (!tagged.length) {
546
+ return out(`No severity tags${scope}, so this cannot report readiness.\n\n` +
547
+ `Add "severity": "blocker" | "major" | "minor" to the excerpts that represent problems. ` +
548
+ `Severity is the researcher's judgment, which is why this tool reads it rather than deciding it.`);
549
+ }
550
+ // Group by issue where tagged, otherwise fall back to the theme.
551
+ const byIssue = {};
552
+ tagged.forEach((r) => ((byIssue[r.issue || r.theme] ??= []).push(r)));
553
+ const sections = SEVERITY_ORDER.map((sev) => {
554
+ const issues = Object.entries(byIssue)
555
+ .filter(([, v]) => v.some((r) => r.severity === sev))
556
+ .map(([issue, v]) => {
557
+ const hits = v.filter((r) => r.severity === sev);
558
+ const who = new Set(hits.map((h) => h.participant));
559
+ const segs = [...new Set(hits.map((h) => h.segment))];
560
+ const quotes = hits
561
+ .slice(0, 3)
562
+ .map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`)
563
+ .join("\n");
564
+ return {
565
+ n: who.size,
566
+ text: ` ${issue}: ${who.size} of ${people} participants (${segs.join(", ")})\n` +
567
+ quotes +
568
+ (hits.length > 3 ? `\n ...and ${hits.length - 3} more` : ""),
569
+ };
570
+ })
571
+ .sort((a, b) => b.n - a.n);
572
+ if (!issues.length)
573
+ return null;
574
+ const total = new Set(tagged.filter((r) => r.severity === sev).map((r) => r.participant)).size;
575
+ return `${sev.toUpperCase()} — ${issues.length} issue(s), ${total} participant(s) affected\n${issues
576
+ .map((i) => i.text)
577
+ .join("\n\n")}`;
578
+ }).filter(Boolean);
579
+ const segs = [...new Set(sessions.map((s) => s.segment))];
580
+ const regions = [...new Set(sessions.map((s) => s.region).filter(Boolean))];
581
+ const thin = segs.filter((sg) => sessions.filter((s) => s.segment === sg).length < 5);
582
+ return out(`RELEASE READINESS${scope}\n` +
583
+ `Evidence from ${studies.length} study/studies, ${people} participants, ${sessions.length} sessions.\n` +
584
+ studies.map((st) => ` ${studyLabel(st)}`).join("\n") +
585
+ `\n\n${sections.join("\n\n")}\n\n` +
586
+ `WHAT THIS IS NOT\n` +
587
+ ` Not a ship decision. It counts participants who hit an issue; it does not weigh that against revenue, deadline or effort.\n` +
588
+ ` Not a severity judgment. Severity is read from the researcher's tags, not inferred.\n` +
589
+ ` Not frequency in the wild. ${people} participants in a lab is not ${people} percent of customers.\n\n` +
590
+ `THIN EVIDENCE, TREAT WITH CARE\n` +
591
+ (thin.length
592
+ ? thin
593
+ .map((sg) => ` ${sg}: only ${sessions.filter((s) => s.segment === sg).length} participant(s)`)
594
+ .join("\n")
595
+ : ` Every segment has 5 or more participants.`) +
596
+ (regions.length ? `\n Regions covered: ${regions.join(", ")}` : `\n No region recorded on any session.`));
597
+ }
598
+ if (name === "track_metric") {
599
+ const want = String(args.metric ?? "").toLowerCase();
600
+ const seg = args.segment ? String(args.segment).toLowerCase() : null;
601
+ const dated = [...all].sort((a, b) => String(a.study?.date ?? "").localeCompare(String(b.study?.date ?? "")));
602
+ const lines = dated.map((st) => {
603
+ let ss = st.sessions;
604
+ if (seg)
605
+ ss = ss.filter((s) => s.segment.toLowerCase() === seg);
606
+ if (!ss.length)
607
+ return `${studyLabel(st)}: no sessions${seg ? ` for segment "${args.segment}"` : ""}`;
608
+ const scored = ss.filter((s) => typeof s.sus === "number" && s.sus > 0);
609
+ const completion = `${ss.filter((s) => s.completed).length}/${ss.length}`;
610
+ const sus = scored.length
611
+ ? Math.round(scored.reduce((a, s) => a + s.sus, 0) / scored.length)
612
+ : null;
613
+ const parts = [];
614
+ if (!want || want.includes("complet"))
615
+ parts.push(`completion ${completion}`);
616
+ if (!want || want.includes("sus"))
617
+ parts.push(`mean SUS ${sus === null ? "not recorded" : sus}`);
618
+ return `${studyLabel(st)}: ${parts.join(" | ")} (n=${ss.length})`;
619
+ });
620
+ return out(`Metric over time${seg ? `, segment "${args.segment}"` : ""}, oldest first\n\n` +
621
+ lines.join("\n") +
622
+ `\n\nStudies are listed separately and never averaged together. Different participants, recruitment and tasks.\n` +
623
+ (dated.some((st) => !st.study?.date)
624
+ ? `Some studies have no "date" in their study block, so this order may be wrong. Add one to fix it.`
625
+ : `Ordered by the "date" field on each study.`));
626
+ }
627
+ if (name === "prior_research") {
628
+ const q = String(args.topic ?? "").toLowerCase();
629
+ const found = all
630
+ .map((st) => {
631
+ const rws = allExcerpts([st]).filter((r) => r.theme.toLowerCase().includes(q) ||
632
+ r.quote.toLowerCase().includes(q) ||
633
+ (r.issue ?? "").toLowerCase().includes(q));
634
+ return { st, rws, people: new Set(rws.map((r) => r.participant)) };
635
+ })
636
+ .filter((x) => x.rws.length);
637
+ if (!found.length) {
638
+ return out(`No existing study in this repository covers "${args.topic}".\n\n` +
639
+ `Searched ${all.length} study/studies: ${all.map((s) => s.key).join(", ")}.\n` +
640
+ `Absence here means nobody filed it, which is not the same as nobody having asked. Check outside the repository before concluding it is unresearched.`);
641
+ }
642
+ return out(`"${args.topic}" has already been researched in ${found.length} study/studies\n\n` +
643
+ found
644
+ .map((x) => `${studyLabel(x.st)}\n` +
645
+ ` ${x.people.size} participant(s), ${x.rws.length} excerpt(s)\n` +
646
+ ` themes: ${[...new Set(x.rws.map((r) => r.theme))].join(", ")}\n` +
647
+ ` e.g. [${cite({ ...x.rws[0], study: x.st.key }, true, showRound)}] ${quoteOrWithheld(x.rws[0])}`)
648
+ .join("\n\n") +
649
+ `\n\nRead these before commissioning new research on the topic.`);
650
+ }
651
+ if (name === "evidence_trail") {
652
+ const q = String(args.issue ?? "").toLowerCase();
653
+ const hits = allExcerpts(all).filter((r) => (r.issue ?? "").toLowerCase() === q || r.theme.toLowerCase() === q ||
654
+ (r.issue ?? "").toLowerCase().includes(q));
655
+ if (!hits.length) {
656
+ return out(`No evidence filed under "${args.issue}" in any study.`);
657
+ }
658
+ const byStudy = {};
659
+ hits.forEach((h) => ((byStudy[h.study] ??= []).push(h)));
660
+ const people = new Set(hits.map((h) => h.participant));
661
+ return out(`EVIDENCE TRAIL — ${args.issue}\n` +
662
+ `${hits.length} excerpt(s) from ${people.size} participant(s), across ${Object.keys(byStudy).length} study/studies.\n\n` +
663
+ Object.entries(byStudy)
664
+ .map(([key, v]) => {
665
+ const st = all.find((s) => s.key === key);
666
+ return (`${studyLabel(st)}\n` +
667
+ v
668
+ .map((h) => ` [${h.participant} | R${h.round} | ${h.t}] ${h.segment}` +
669
+ (h.severity ? ` | ${h.severity}` : "") +
670
+ `\n ${quoteOrWithheld(h)}`)
671
+ .join("\n"));
672
+ })
673
+ .join("\n\n") +
674
+ `\n\nEvery line above is a participant's own words with the session it came from. Nothing here is summarised or rephrased.`);
675
+ }
676
+ if (name === "consent_status") {
677
+ const everySession = studies.flatMap((st) => st.sessions.map((x) => ({ ...x, study: st.key })));
678
+ if (!everySession.length)
679
+ return out(`No sessions${scope}.`);
680
+ const group = {
681
+ full: [], internal: [], counts: [], withdrawn: [],
682
+ };
683
+ everySession.forEach((x) => group[consentOf(x)].push(x));
684
+ const line = (x) => ` ${x.id}${multi ? ` (${x.study})` : ""}${x.consent_note ? ` — ${x.consent_note}` : ""}`;
685
+ const section = (c, explain) => group[c].length ? `${c.toUpperCase()} (${group[c].length}) — ${explain}\n${group[c].map(line).join("\n")}` : "";
686
+ const withheld = everySession.filter((x) => consentOf(x) !== "withdrawn" && !mayQuote(consentOf(x))).length;
687
+ return out(`CONSENT${scope}\n` +
688
+ `Audience: ${AUDIENCE === "team" ? "research team (RESEARCH_LENS_AUDIENCE unset or \"team\")" : "external (RESEARCH_LENS_AUDIENCE=external)"}\n` +
689
+ `${everySession.length} participants on file, ${group.withdrawn.length} withdrawn, ` +
690
+ `${withheld} whose quotes are withheld at this audience setting.\n\n` +
691
+ [
692
+ section("full", "quotes may be reproduced"),
693
+ section("internal", `quotes shown to the research team only${AUDIENCE === "external" ? ", withheld here" : ""}`),
694
+ section("counts", "counted, never quoted"),
695
+ section("withdrawn", "removed from every count, quote and denominator"),
696
+ ]
697
+ .filter(Boolean)
698
+ .join("\n\n") +
699
+ `\n\nHOW THIS IS ENFORCED\n` +
700
+ ` Withdrawn participants are removed before anything is counted, so they do not sit in a denominator.\n` +
701
+ ` 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` +
702
+ ` 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` +
703
+ ` Set RESEARCH_LENS_AUDIENCE=external when the output will leave the research team.`);
704
+ }
705
+ if (name === "coverage_gaps") {
706
+ if (!sessions.length)
707
+ return out(`No sessions${scope}.`);
708
+ const people = new Set(sessions.map((s) => s.id)).size;
709
+ const bySeg = {};
710
+ const byRegion = {};
711
+ sessions.forEach((s) => {
712
+ (bySeg[s.segment] ??= new Set()).add(s.id);
713
+ if (s.region)
714
+ (byRegion[s.region] ??= new Set()).add(s.id);
715
+ });
716
+ const byTheme = {};
717
+ rows.forEach((r) => (byTheme[r.theme] ??= new Set()).add(r.participant));
718
+ const fragile = Object.entries(byTheme)
719
+ .filter(([, v]) => v.size <= 2)
720
+ .sort((a, b) => a[1].size - b[1].size);
721
+ const thinSeg = Object.entries(bySeg).filter(([, v]) => v.size < 5);
722
+ return out(`COVERAGE GAPS${scope}\n${people} participants across ${sessions.length} sessions.\n\n` +
723
+ `SEGMENTS\n` +
724
+ Object.entries(bySeg)
725
+ .sort((a, b) => b[1].size - a[1].size)
726
+ .map(([k, v]) => ` ${k}: ${v.size}`)
727
+ .join("\n") +
728
+ (thinSeg.length
729
+ ? `\n Thin: ${thinSeg.map(([k, v]) => `${k} (${v.size})`).join(", ")}. A finding about these rests on very few people.`
730
+ : `\n No segment below 5 participants.`) +
731
+ `\n\nREGIONS\n` +
732
+ (Object.keys(byRegion).length
733
+ ? Object.entries(byRegion)
734
+ .map(([k, v]) => ` ${k}: ${v.size}`)
735
+ .join("\n")
736
+ : ` No region recorded on any session, so nothing can be said about geographic coverage.`) +
737
+ `\n\nTHEMES RESTING ON 1 OR 2 PARTICIPANTS\n` +
738
+ (fragile.length
739
+ ? fragile.map(([k, v]) => ` ${k}: ${v.size}`).join("\n") +
740
+ `\n These are leads, not findings. Do not put them in a readout as patterns.`
741
+ : ` Every theme has 3 or more participants behind it.`) +
742
+ `\n\nWHAT IS MISSING ENTIRELY\n` +
743
+ ` This tool can only report on people who were recruited. Anyone your recruitment never reached is invisible here, ` +
744
+ `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.`);
745
+ }
353
746
  return out(`Unknown tool: ${name}`);
354
747
  });
355
748
  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.3.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",