research-lens-mcp 2.2.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.
- package/README.md +9 -2
- package/build/index.js +62 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -139,7 +139,14 @@ Node 18 or newer. Nothing to clone, nothing to build.
|
|
|
139
139
|
|
|
140
140
|
## Your data
|
|
141
141
|
|
|
142
|
-
`RESEARCH_LENS_DATA` points at a folder
|
|
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.
|
|
143
150
|
|
|
144
151
|
```json
|
|
145
152
|
{
|
|
@@ -172,7 +179,7 @@ Only `id`, `round`, `segment` and the excerpt fields are required. Everything el
|
|
|
172
179
|
**From a spreadsheet.** Fill in [`template.csv`](template.csv), one row per quote, then:
|
|
173
180
|
|
|
174
181
|
```bash
|
|
175
|
-
npx research-lens-mcp-import my-study.csv "Study name"
|
|
182
|
+
npx -p research-lens-mcp research-lens-import my-study.csv "Study name"
|
|
176
183
|
```
|
|
177
184
|
|
|
178
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
|
@@ -59,19 +59,20 @@ function studyFiles() {
|
|
|
59
59
|
* reads better than whatever the file happens to be called; the filename is the
|
|
60
60
|
* fallback. Collisions get a numeric suffix so two studies never share a handle.
|
|
61
61
|
*/
|
|
62
|
-
function keyFor(parsed, file
|
|
62
|
+
function keyFor(parsed, file) {
|
|
63
63
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
64
|
-
|
|
65
|
-
if (!taken.some((t) => t.key === base))
|
|
66
|
-
return base;
|
|
67
|
-
let n = 2;
|
|
68
|
-
while (taken.some((t) => t.key === `${base}-${n}`))
|
|
69
|
-
n++;
|
|
70
|
-
return `${base}-${n}`;
|
|
64
|
+
return slug(String(parsed.study?.name ?? "")) || slug(basename(file, ".json")) || "study";
|
|
71
65
|
}
|
|
72
|
-
/**
|
|
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
|
+
*/
|
|
73
74
|
function loadAll() {
|
|
74
|
-
const
|
|
75
|
+
const byKey = new Map();
|
|
75
76
|
for (const file of studyFiles()) {
|
|
76
77
|
let parsed;
|
|
77
78
|
try {
|
|
@@ -83,9 +84,29 @@ function loadAll() {
|
|
|
83
84
|
if (!Array.isArray(parsed?.sessions)) {
|
|
84
85
|
throw new DataError(`"${basename(file)}" has no "sessions" array.`);
|
|
85
86
|
}
|
|
86
|
-
|
|
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 };
|
|
87
108
|
}
|
|
88
|
-
return
|
|
109
|
+
return [...byKey.values()];
|
|
89
110
|
}
|
|
90
111
|
/**
|
|
91
112
|
* Pick the studies a call applies to. With no name given, every study is used,
|
|
@@ -114,6 +135,9 @@ function allExcerpts(studies) {
|
|
|
114
135
|
task: s.task,
|
|
115
136
|
study: st.key,
|
|
116
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 ?? "" : ""),
|
|
117
141
|
}))));
|
|
118
142
|
}
|
|
119
143
|
const sessionsOf = (studies) => studies.flatMap((s) => active(s.sessions));
|
|
@@ -178,8 +202,11 @@ function applySegment(rows, segment) {
|
|
|
178
202
|
return { rows: loose, label: `, segment "${segment}"` };
|
|
179
203
|
}
|
|
180
204
|
/** Prefix a citation with the study only when more than one is in scope. */
|
|
181
|
-
const cite = (h, multi) => `${multi ? h.study + " | " : ""}${h.participant}
|
|
182
|
-
|
|
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: {} } });
|
|
183
210
|
const STUDY_ARG = {
|
|
184
211
|
study: {
|
|
185
212
|
type: "string",
|
|
@@ -307,16 +334,22 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
307
334
|
const rows = allExcerpts(studies);
|
|
308
335
|
const sessions = sessionsOf(studies);
|
|
309
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;
|
|
310
339
|
const scope = args.study ? ` in "${args.study}"` : multi ? ` across ${studies.length} studies` : "";
|
|
311
340
|
if (name === "list_studies") {
|
|
312
341
|
const lines = all.map((st) => {
|
|
313
342
|
const people = new Set(st.sessions.map((s) => s.id));
|
|
314
|
-
const rounds = [...new Set(st.sessions.map((s) => s.round))].sort();
|
|
343
|
+
const rounds = [...new Set(st.sessions.map((s) => s.round ?? 1))].sort();
|
|
315
344
|
const segments = [...new Set(st.sessions.map((s) => s.segment))];
|
|
316
345
|
const themes = [...new Set(st.sessions.flatMap((s) => s.excerpts.map((e) => e.theme)))];
|
|
317
346
|
const quotes = st.sessions.reduce((a, s) => a + s.excerpts.length, 0);
|
|
318
347
|
return (`${st.key}\n` +
|
|
319
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
|
+
: "") +
|
|
320
353
|
` participants: ${people.size} sessions: ${st.sessions.length} quotes: ${quotes}\n` +
|
|
321
354
|
` rounds: ${rounds.join(", ") || "none"}\n` +
|
|
322
355
|
` segments: ${segments.join(", ") || "none"}\n` +
|
|
@@ -336,14 +369,14 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
336
369
|
return out(`No excerpts match "${args.query}"${scope}${seg.label}.`);
|
|
337
370
|
return out(`${hits.length} excerpt(s) matching "${args.query}"${scope}${seg.label}:\n\n` +
|
|
338
371
|
hits
|
|
339
|
-
.map((h) => `[${cite(h, multi)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n ${quoteOrWithheld(h)}`)
|
|
372
|
+
.map((h) => `[${cite(h, multi, showRound)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n ${quoteOrWithheld(h)}`)
|
|
340
373
|
.join("\n\n"));
|
|
341
374
|
}
|
|
342
375
|
if (name === "quotes_by_theme") {
|
|
343
376
|
const seg = applySegment(rows, args.segment);
|
|
344
377
|
let hits = seg.rows.filter((r) => themeMatches(r.theme, args.theme));
|
|
345
378
|
if (typeof args.round === "number")
|
|
346
|
-
hits = hits.filter((r) => r.round === args.round);
|
|
379
|
+
hits = hits.filter((r) => (r.round ?? 1) === args.round);
|
|
347
380
|
if (!hits.length) {
|
|
348
381
|
// Distinguish "that theme does not exist" from "it exists, just not in
|
|
349
382
|
// that segment". Conflating them reads as an absence of evidence.
|
|
@@ -363,7 +396,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
363
396
|
const body = Object.entries(groups)
|
|
364
397
|
.filter(([, v]) => v.length)
|
|
365
398
|
.map(([k, v]) => `${k.toUpperCase()} (${v.length})\n` +
|
|
366
|
-
v.map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`).join("\n"))
|
|
399
|
+
v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n"))
|
|
367
400
|
.join("\n\n");
|
|
368
401
|
return out(`Theme "${args.theme}"${scope}${seg.label} — ${hits.length} excerpt(s)\n\n${body}`);
|
|
369
402
|
}
|
|
@@ -413,13 +446,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
413
446
|
return out(`Rounds can only be compared within one study. Name one of: ${all.map((s) => s.key).join(", ")}.`);
|
|
414
447
|
}
|
|
415
448
|
const theme = String(args.theme);
|
|
416
|
-
|
|
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);
|
|
417
451
|
if (rounds.length < 2) {
|
|
418
|
-
return out(
|
|
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.`);
|
|
419
456
|
}
|
|
420
457
|
const summarise = (round) => {
|
|
421
|
-
const hits = rows.filter((r) => r.theme === theme && r.round === round);
|
|
422
|
-
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);
|
|
423
460
|
const scored = ss.filter((s) => typeof s.sus === "number" && s.sus > 0);
|
|
424
461
|
return {
|
|
425
462
|
pos: hits.filter((h) => h.sentiment === "positive").length,
|
|
@@ -474,7 +511,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
474
511
|
const mix = hits.filter((h) => h.sentiment === "mixed");
|
|
475
512
|
const block = (label, v) => v.length
|
|
476
513
|
? `${label} (${v.length}):\n` +
|
|
477
|
-
v.map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`).join("\n") +
|
|
514
|
+
v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n") +
|
|
478
515
|
"\n"
|
|
479
516
|
: "";
|
|
480
517
|
return out(`DRAFT FINDING — ${theme}${scope}\n\n` +
|
|
@@ -522,7 +559,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
522
559
|
const segs = [...new Set(hits.map((h) => h.segment))];
|
|
523
560
|
const quotes = hits
|
|
524
561
|
.slice(0, 3)
|
|
525
|
-
.map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`)
|
|
562
|
+
.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`)
|
|
526
563
|
.join("\n");
|
|
527
564
|
return {
|
|
528
565
|
n: who.size,
|
|
@@ -607,7 +644,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
607
644
|
.map((x) => `${studyLabel(x.st)}\n` +
|
|
608
645
|
` ${x.people.size} participant(s), ${x.rws.length} excerpt(s)\n` +
|
|
609
646
|
` 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])}`)
|
|
647
|
+
` e.g. [${cite({ ...x.rws[0], study: x.st.key }, true, showRound)}] ${quoteOrWithheld(x.rws[0])}`)
|
|
611
648
|
.join("\n\n") +
|
|
612
649
|
`\n\nRead these before commissioning new research on the topic.`);
|
|
613
650
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "research-lens-mcp",
|
|
3
|
-
"version": "2.
|
|
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": {
|