pattern-mcp 0.1.1 → 0.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.
package/dist/index.js CHANGED
@@ -3,13 +3,13 @@
3
3
  * Pattern
4
4
  *
5
5
  * MCP server exposing two tools. `recommend_component` judges whether a UI
6
- * component need should be met with an existing shadcn/ui or 21st.dev
7
- * component, or requires a custom build guided by a real-app reference
8
- * from Mobbin. `record_component_decision` appends a confirmed decision to
9
- * local per-project memory (see MEMORY_PATH below), which recommend_component
10
- * can optionally read back (via project_id) as consistency context for a
11
- * future call -- never as a cached verdict; coverage is still scored fresh
12
- * every time.
6
+ * component need should be met with an existing shadcn/ui, 21st.dev, or
7
+ * ReUI (reui.io) component, or requires a custom build guided by a
8
+ * real-app reference from Mobbin. `record_component_decision` appends a
9
+ * confirmed decision to local per-project memory (see MEMORY_PATH below),
10
+ * which recommend_component can optionally read back (via project_id) as
11
+ * consistency context for a future call -- never as a cached verdict;
12
+ * coverage is still scored fresh every time.
13
13
  *
14
14
  * The judgment logic (extract requirements -> search -> score real code ->
15
15
  * threshold into a verdict) is delegated to a single Anthropic API call
@@ -22,19 +22,20 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
22
22
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
24
  import { dirname, join } from "node:path";
25
- const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
25
+ export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
26
26
  // Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
27
27
  // Defaults to Sonnet 5. Try MODEL=claude-haiku-4-5-20251001 to test the
28
28
  // cheaper tier -- re-run the 5 validated test cases from the product brief
29
29
  // (price breakdown, cancellation policy, earnings dashboard, gallery,
30
30
  // messaging) and diff verdicts before trusting it in production.
31
- const MODEL = process.env.PATTERN_MODEL ?? "claude-sonnet-5";
32
- // Search budget for candidate discovery. Defaults to 2, matching the
33
- // process the system prompt was originally validated against. Set to
34
- // "unlimited" to remove the cap entirely (enforced server-side via the
35
- // web_search tool's max_uses -- not just prompt instruction, since models
36
- // don't reliably self-limit against a purely textual budget).
37
- const SEARCH_BUDGET_RAW = process.env.PATTERN_SEARCH_BUDGET ?? "2";
31
+ export const MODEL = process.env.PATTERN_MODEL ?? "claude-sonnet-5";
32
+ // Search budget for candidate discovery. Defaults to 3 -- one search per
33
+ // source (shadcn/ui, 21st.dev, ReUI), fired together in the same turn per
34
+ // the system prompt's step 3. Set to "unlimited" to remove the cap
35
+ // entirely (enforced server-side via the web_search tool's max_uses --
36
+ // not just prompt instruction, since models don't reliably self-limit
37
+ // against a purely textual budget).
38
+ const SEARCH_BUDGET_RAW = process.env.PATTERN_SEARCH_BUDGET ?? "3";
38
39
  const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
39
40
  ? null
40
41
  : (() => {
@@ -47,7 +48,7 @@ const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
47
48
  // Static skip-list: single-purpose primitives with no meaningful internal
48
49
  // structure to score coverage against. Decided in the product brief as a
49
50
  // starting point -- revisit once real usage data exists (see README).
50
- const SKIP_LIST = [
51
+ export const SKIP_LIST = [
51
52
  "button",
52
53
  "input",
53
54
  "checkbox",
@@ -59,7 +60,7 @@ const SKIP_LIST = [
59
60
  "avatar",
60
61
  "icon",
61
62
  ];
62
- function isSkipListMatch(componentNeed) {
63
+ export function isSkipListMatch(componentNeed) {
63
64
  const needLower = componentNeed.toLowerCase().trim();
64
65
  return SKIP_LIST.some((item) => needLower === item || needLower === `a ${item}` || needLower === `an ${item}`);
65
66
  }
@@ -99,8 +100,244 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
99
100
  // of what's in this file (see README's "no verdict caching" rule).
100
101
  const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
101
102
  const MAX_DECISIONS_PER_PROJECT = 50;
103
+ // $/1M tokens, checked against the Anthropic pricing page rather than
104
+ // recalled from training data (rates drift). Both current and legacy
105
+ // Haiku 4.5 model-id spellings are listed since PATTERN_MODEL is
106
+ // user-configurable and either form may be in use. Falls back to Sonnet 5
107
+ // rates (with a diagnostic) for any model not listed here -- an estimate
108
+ // clearly logged as such beats silently returning $0.
109
+ const PRICING = {
110
+ "claude-sonnet-5": { inputPerMTok: 2.0, outputPerMTok: 10.0 },
111
+ "claude-opus-5": { inputPerMTok: 5.0, outputPerMTok: 25.0 },
112
+ "claude-haiku-4-5": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
113
+ "claude-haiku-4-5-20251001": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
114
+ };
115
+ // Anthropic's standard prompt-caching multipliers, applied on top of a
116
+ // model's base input rate -- cache writes cost ~1.25x, cache reads ~0.1x.
117
+ // These ratios are documented as consistent across models, unlike the
118
+ // base per-model rates above.
119
+ const CACHE_WRITE_MULTIPLIER = 1.25;
120
+ const CACHE_READ_MULTIPLIER = 0.1;
121
+ // Estimate only -- see PRICING's comment above. Rounded to 4 decimal
122
+ // places since a single call is well under a cent in many cases.
123
+ export function estimateCostUsd(usage, model) {
124
+ const pricing = PRICING[model];
125
+ if (!pricing) {
126
+ console.error(JSON.stringify({
127
+ diagnostic: "pricing_fallback",
128
+ reason: `no pricing entry for model "${model}" -- estimated_cost_usd uses Sonnet 5 rates as a stand-in`,
129
+ model,
130
+ }));
131
+ }
132
+ const { inputPerMTok, outputPerMTok } = pricing ?? PRICING["claude-sonnet-5"];
133
+ const input = usage.input_tokens ?? 0;
134
+ const output = usage.output_tokens ?? 0;
135
+ const cacheWrite = usage.cache_creation_input_tokens ?? 0;
136
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
137
+ const cost = (input * inputPerMTok +
138
+ output * outputPerMTok +
139
+ cacheWrite * inputPerMTok * CACHE_WRITE_MULTIPLIER +
140
+ cacheRead * inputPerMTok * CACHE_READ_MULTIPLIER) /
141
+ 1_000_000;
142
+ return Math.round(cost * 10000) / 10000;
143
+ }
144
+ // This bundled call runs extraction, search, and scoring inside ONE model
145
+ // turn via server-executed tools (web_search/web_fetch run on Anthropic's
146
+ // servers, not as separate round-trips this code makes) -- so there's no
147
+ // natural place to put three separate stopwatches. Streaming the response
148
+ // and timing content-block boundaries is the only way to get a real
149
+ // per-phase split without adding a second API call (which would change
150
+ // cost/behavior -- out of scope here).
151
+ //
152
+ // Validated against 8 real streamed traces before shipping (4 custom_build,
153
+ // 4 use_existing, covering both branches of step 6) rather than assumed:
154
+ // every trace showed the same shape --
155
+ // [thinking] -> [search tool_use x2 -> search tool_result x2] -> [thinking/text...]
156
+ // with the first tool_use block starting at the exact millisecond the
157
+ // opening `thinking` block stopped (0-16ms of jitter across all 8 runs),
158
+ // and the discovery-search wave (always exactly the 2 calls step 3 asks
159
+ // the model to fire together) always followed immediately by a `thinking`
160
+ // or `text` block -- never by a third tool call with no reasoning in
161
+ // between. That gives two clean, consistently-observed cut points:
162
+ // first-tool-block-start (end of extract) and end-of-the-first-contiguous
163
+ // tool-block-run (end of search).
164
+ //
165
+ // For custom_build cases specifically, step 6's reference search
166
+ // (Mobbin/Figma) and its web_fetch deep-link check happen in a SECOND
167
+ // tool-block run, separated from the first by a `thinking` block that
168
+ // contains the actual coverage-scoring/verdict reasoning -- i.e. search
169
+ // and score are not simply sequential there, scoring happens in the
170
+ // middle. Using "last tool result in the whole response" as the search/
171
+ // score boundary (an earlier draft of this) would have wrongly folded that
172
+ // interstitial scoring reasoning, plus all of step 6, into "search". The
173
+ // boundary below avoids that: "search" is only ever the first contiguous
174
+ // tool-block run. Concretely this means breakdown_ms.score, for a
175
+ // custom_build verdict, also covers step 6's reference-finding and
176
+ // write-up -- not just coverage scoring -- which is disclosed in the
177
+ // README rather than presented as a narrower number than it is.
178
+ function classifyBlockKind(type) {
179
+ return type === "tool_use" ||
180
+ type === "server_tool_use" ||
181
+ type === "web_search_tool_result" ||
182
+ type === "web_fetch_tool_result"
183
+ ? "tool"
184
+ : "other";
185
+ }
186
+ export function computeBreakdownMs(t) {
187
+ return {
188
+ extract: t.extractEndMs - t.requestStartMs,
189
+ search: t.searchEndMs - t.extractEndMs,
190
+ score: t.scoreEndMs - t.searchEndMs,
191
+ };
192
+ }
193
+ function buildMeta(timings, usage) {
194
+ return {
195
+ total_ms: timings.scoreEndMs - timings.requestStartMs,
196
+ breakdown_ms: computeBreakdownMs(timings),
197
+ tokens_used: {
198
+ input: (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0),
199
+ output: usage.output_tokens ?? 0,
200
+ },
201
+ estimated_cost_usd: estimateCostUsd(usage, MODEL),
202
+ };
203
+ }
204
+ // Streams a Messages API request over SSE and reconstructs the same
205
+ // {content, stop_reason, usage} shape the non-streaming endpoint returns,
206
+ // so every downstream consumer (search/fetch-call parsing, JSON
207
+ // extraction, the enforce* functions) is unaffected by this transport
208
+ // change. Also captures the phase timestamps described above. This is
209
+ // hand-rolled SSE parsing rather than the Anthropic SDK to avoid pulling
210
+ // in a new dependency for what's a small, stable, well-documented event
211
+ // shape (message_start/content_block_start/_delta/_stop/message_delta/
212
+ // message_stop).
213
+ async function streamAnthropicMessage(body) {
214
+ const requestStartMs = Date.now();
215
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
216
+ method: "POST",
217
+ headers: {
218
+ "content-type": "application/json",
219
+ "x-api-key": ANTHROPIC_API_KEY,
220
+ "anthropic-version": "2023-06-01",
221
+ },
222
+ body: JSON.stringify({ ...body, stream: true }),
223
+ });
224
+ if (!response.ok) {
225
+ const errText = await response.text();
226
+ throw new Error(`Anthropic API error ${response.status}: ${errText}`);
227
+ }
228
+ if (!response.body) {
229
+ throw new Error("Anthropic API streaming response had no body to read.");
230
+ }
231
+ const blocks = [];
232
+ const partialJson = {};
233
+ let usage = {};
234
+ let stop_reason;
235
+ let firstToolBlockStartMs;
236
+ let lastToolResultStopMs;
237
+ let searchEndMs; // frozen the first time a non-tool block interrupts the run
238
+ let sawAnyToolBlock = false;
239
+ const handleEvent = (payload) => {
240
+ const now = Date.now();
241
+ switch (payload.type) {
242
+ case "message_start":
243
+ usage = { ...usage, ...payload.message?.usage };
244
+ break;
245
+ case "content_block_start": {
246
+ const idx = payload.index;
247
+ blocks[idx] = structuredClone(payload.content_block);
248
+ const kind = classifyBlockKind(blocks[idx].type);
249
+ if (kind === "tool") {
250
+ sawAnyToolBlock = true;
251
+ if (firstToolBlockStartMs === undefined)
252
+ firstToolBlockStartMs = now;
253
+ }
254
+ else if (sawAnyToolBlock && searchEndMs === undefined && lastToolResultStopMs !== undefined) {
255
+ // A thinking/text block has interrupted the first tool-block run --
256
+ // freeze the search/score boundary at the last tool result seen so far.
257
+ searchEndMs = lastToolResultStopMs;
258
+ }
259
+ break;
260
+ }
261
+ case "content_block_delta": {
262
+ const idx = payload.index;
263
+ const delta = payload.delta;
264
+ if (delta?.type === "text_delta") {
265
+ blocks[idx].text = (blocks[idx].text ?? "") + delta.text;
266
+ }
267
+ else if (delta?.type === "input_json_delta") {
268
+ partialJson[idx] = (partialJson[idx] ?? "") + delta.partial_json;
269
+ }
270
+ break;
271
+ }
272
+ case "content_block_stop": {
273
+ const idx = payload.index;
274
+ if (partialJson[idx] !== undefined) {
275
+ try {
276
+ blocks[idx].input = JSON.parse(partialJson[idx] || "{}");
277
+ }
278
+ catch {
279
+ blocks[idx].input = {};
280
+ }
281
+ }
282
+ if (blocks[idx]?.type === "web_search_tool_result" || blocks[idx]?.type === "web_fetch_tool_result") {
283
+ lastToolResultStopMs = now;
284
+ }
285
+ break;
286
+ }
287
+ case "message_delta":
288
+ if (payload.usage)
289
+ usage = { ...usage, ...payload.usage };
290
+ if (payload.delta?.stop_reason)
291
+ stop_reason = payload.delta.stop_reason;
292
+ break;
293
+ default:
294
+ break;
295
+ }
296
+ };
297
+ const reader = response.body.getReader();
298
+ const decoder = new TextDecoder();
299
+ let buf = "";
300
+ let dataLines = [];
301
+ while (true) {
302
+ const { done, value } = await reader.read();
303
+ if (done)
304
+ break;
305
+ buf += decoder.decode(value, { stream: true });
306
+ let idx;
307
+ while ((idx = buf.indexOf("\n")) !== -1) {
308
+ const line = buf.slice(0, idx).replace(/\r$/, "");
309
+ buf = buf.slice(idx + 1);
310
+ if (line === "") {
311
+ if (dataLines.length > 0) {
312
+ try {
313
+ handleEvent(JSON.parse(dataLines.join("\n")));
314
+ }
315
+ catch {
316
+ // Malformed/partial SSE frame -- skip it rather than crash the call.
317
+ }
318
+ }
319
+ dataLines = [];
320
+ continue;
321
+ }
322
+ if (line.startsWith("data:"))
323
+ dataLines.push(line.slice(5).trim());
324
+ // "event:" lines are ignored -- payload.type inside `data:` is
325
+ // sufficient to dispatch on, and is what the code above already uses.
326
+ }
327
+ }
328
+ const scoreEndMs = Date.now();
329
+ const extractEndMs = firstToolBlockStartMs ?? scoreEndMs;
330
+ const resolvedSearchEndMs = searchEndMs ?? lastToolResultStopMs ?? extractEndMs;
331
+ return {
332
+ content: blocks,
333
+ stop_reason,
334
+ usage,
335
+ timings: { requestStartMs, extractEndMs, searchEndMs: resolvedSearchEndMs, scoreEndMs },
336
+ };
337
+ }
102
338
  const TOOL_NAME = "recommend_component";
103
339
  const RECORD_DECISION_TOOL_NAME = "record_component_decision";
340
+ const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
104
341
  const INPUT_SCHEMA = {
105
342
  type: "object",
106
343
  properties: {
@@ -133,9 +370,34 @@ const INPUT_SCHEMA = {
133
370
  "better match found in this search still wins). Omit to skip memory " +
134
371
  "lookup entirely; this never falls back to a shared/global bucket.",
135
372
  },
373
+ checklist: {
374
+ type: "array",
375
+ items: { type: "string" },
376
+ description: "Optional. A hand-provided (or extract_requirements-provided) requirement " +
377
+ "checklist to score against directly, skipping this call's own internal " +
378
+ "requirement extraction. Use this to inspect or correct the checklist " +
379
+ "before spending the search+score budget -- call extract_requirements " +
380
+ "first, review or edit its checklist, then pass it here. Omit to keep " +
381
+ "today's default behavior: recommend_component extracts its own " +
382
+ "checklist internally, unchanged.",
383
+ },
136
384
  },
137
385
  required: ["component_need", "domain", "framework"],
138
386
  };
387
+ const EXTRACT_REQUIREMENTS_INPUT_SCHEMA = {
388
+ type: "object",
389
+ properties: {
390
+ component_need: {
391
+ type: "string",
392
+ description: "Same field as recommend_component's input -- a specific description of the UI component needed, not a category.",
393
+ },
394
+ domain: {
395
+ type: "string",
396
+ description: "Same field as recommend_component's input -- the product type/domain. Extraction is grounded in this, not the component name alone.",
397
+ },
398
+ },
399
+ required: ["component_need", "domain"],
400
+ };
139
401
  const RECORD_DECISION_INPUT_SCHEMA = {
140
402
  type: "object",
141
403
  properties: {
@@ -160,7 +422,7 @@ const RECORD_DECISION_INPUT_SCHEMA = {
160
422
  },
161
423
  source: {
162
424
  type: "string",
163
- description: "Where it came from, e.g. 'shadcn', '21st.dev', or 'custom' for a custom build.",
425
+ description: "Where it came from, e.g. 'shadcn', '21st.dev', 'reui', or 'custom' for a custom build.",
164
426
  },
165
427
  timestamp: {
166
428
  type: "string",
@@ -169,11 +431,25 @@ const RECORD_DECISION_INPUT_SCHEMA = {
169
431
  },
170
432
  required: ["project_id", "component_need", "action", "source"],
171
433
  };
172
- function buildSystemPrompt(searchBudget) {
434
+ // Shared between buildSystemPrompt's own step 2 and
435
+ // buildExtractionSystemPrompt (the extract_requirements tool's standalone
436
+ // prompt) -- the extraction *instructions* are one piece of text reused
437
+ // by both, even though the two tools issue physically separate API calls
438
+ // (recommend_component's step 2 runs inside the same server-tool-use
439
+ // turn as search+score; extract_requirements is a standalone call with no
440
+ // tools at all). This is what "factor it out into a shared function" means
441
+ // here: the wording, not a shared HTTP call.
442
+ const EXTRACTION_INSTRUCTIONS = "Turn the component need + domain into a concrete checklist of elements the component must contain -- specific enough to check against real code, not a vibe. Ground it in the stated domain, not the component name alone. Extract exactly 8 checklist items, ranked by importance to the component's core function (most important first) -- a fixed count, not a range, so coverage = met/total isn't itself a moving target across runs.";
443
+ function buildSystemPrompt(searchBudget, opts) {
173
444
  const budgetLine = searchBudget === null
174
445
  ? "Budget: no fixed limit on search calls for candidate discovery -- search as much as genuinely helps you find and verify real candidates, but don't search redundantly once you have enough to score confidently."
175
446
  : `Budget: at most ${searchBudget} search call${searchBudget === 1 ? "" : "s"} for candidate discovery. This is separate from, and does not include, the Mobbin and Figma Community lookups in step 6 -- two extra search calls (one per source) are reserved for those and will not work if you spend them here.`;
176
- return `You are a UI component judgment layer. Given a component need, you decide whether it should be met with an existing shadcn/ui or 21st.dev component, or requires a custom build guided by a real-app reference. You have access to a web_search tool -- use it.
447
+ const step2 = opts?.checklistProvided
448
+ ? `2. USE THE PROVIDED CHECKLIST
449
+ The user message includes a "Provided checklist" section -- a requirement checklist already prepared for you (either hand-written by the calling agent, or produced by a prior extract_requirements call). Do not extract your own checklist, and do not add, remove, reorder, or reword any item. Treat it as fixed input and score coverage against exactly these items in step 4 below.`
450
+ : `2. EXTRACT REQUIREMENTS
451
+ ${EXTRACTION_INSTRUCTIONS}`;
452
+ return `You are a UI component judgment layer. Given a component need, you decide whether it should be met with an existing shadcn/ui, 21st.dev, or ReUI (reui.io) component, or requires a custom build guided by a real-app reference. You have access to a web_search tool -- use it.
177
453
 
178
454
  If the user message includes a "Past confirmed decisions in this project" section, treat it only as a signal, not a rule: if a highly similar past decision exists, consider consistency with it while scoring and recommending, but don't let it override a genuinely better match found in this search, and don't skip or shortcut your own search and scoring because a past decision exists. You decide relevance yourself -- nothing upstream has already matched these past decisions to the current need for you. Step 8 below tells you exactly how to report what you did with it.
179
455
 
@@ -182,11 +458,10 @@ Follow this process exactly:
182
458
  1. SKIP-LIST CHECK
183
459
  If the component need is a trivial, single-purpose primitive with no meaningful internal structure (button, input, checkbox, label, badge, spinner, loader, tooltip, avatar, icon), skip the rest of this process and return verdict "use_existing" with reason "skip_list", confidence "high", and a note that this is a commodity primitive not worth scoring.
184
460
 
185
- 2. EXTRACT REQUIREMENTS
186
- Turn the component need + domain into a concrete checklist of elements the component must contain -- specific enough to check against real code, not a vibe. Ground it in the stated domain, not the component name alone. Extract exactly 8 checklist items, ranked by importance to the component's core function (most important first) -- a fixed count, not a range, so coverage = met/total isn't itself a moving target across runs.
461
+ ${step2}
187
462
 
188
463
  3. SEARCH FOR CANDIDATES
189
- Search shadcn/ui and 21st.dev for components matching the need, filtered to the stated framework. Fire the shadcn and 21st.dev searches together in the same turn (they're independent lookups) rather than one at a time -- this avoids re-sending the growing conversation on extra round-trips. ${budgetLine} If those don't surface enough to score, proceed with what you have rather than continuing to search -- a "low confidence, here's why" verdict is more useful than an unbounded search loop.
464
+ Search shadcn/ui, 21st.dev, and ReUI (reui.io) for components matching the need, filtered to the stated framework. Fire the shadcn, 21st.dev, and ReUI searches together in the same turn (they're independent lookups) rather than one at a time -- this avoids re-sending the growing conversation on extra round-trips. ${budgetLine} If those don't surface enough to score, proceed with what you have rather than continuing to search -- a "low confidence, here's why" verdict is more useful than an unbounded search loop.
190
465
 
191
466
  If search returns zero real candidates -- not just weak matches, but nothing relevant at all (e.g. only vendor policy pages, unrelated components) -- stop here and return verdict "custom_build" with reason "no_candidates_found". Do not fabricate a coverage score in this case; omit requirements_checked and coverage entirely.
192
467
 
@@ -244,14 +519,48 @@ Respond with ONLY a single JSON object, no prose before or after, no markdown co
244
519
  "past_decision_signal": { "considered": true|false, "note": "string" } | omit this field entirely if step 8 doesn't apply
245
520
  }`;
246
521
  }
522
+ // Standalone prompt for the extract_requirements tool -- shares
523
+ // EXTRACTION_INSTRUCTIONS with buildSystemPrompt's own step 2 (see that
524
+ // constant's comment) but is otherwise a much smaller prompt: no tools, no
525
+ // search/score steps, just the extraction reasoning. This is what makes
526
+ // extract_requirements fast and cheap relative to recommend_component.
527
+ function buildExtractionSystemPrompt() {
528
+ return `You are the requirement-extraction step of a UI component judgment tool. Given a component need and a product domain, produce a checklist of concrete elements the component must contain.
529
+
530
+ ${EXTRACTION_INSTRUCTIONS}
531
+
532
+ Respond with ONLY a single JSON object, no prose before or after, no markdown code fences, matching this exact shape:
533
+
534
+ {
535
+ "checklist": ["string", "string", "..."]
536
+ }`;
537
+ }
538
+ // Placeholder heuristic, not a validated confidence signal -- see the
539
+ // extract_requirements section of README.md for why (a known gap to
540
+ // revisit with real usage data, not fabricated precision). A longer,
541
+ // more specific component_need gives the extraction step more to ground
542
+ // the checklist in; a one- or two-word need is exactly the "too vague"
543
+ // case the README already warns produces misleading matches elsewhere in
544
+ // this tool, so it's flagged "low" here too.
545
+ export function estimateExtractionConfidence(componentNeed) {
546
+ const wordCount = componentNeed.trim().split(/\s+/).filter(Boolean).length;
547
+ if (wordCount <= 2)
548
+ return "low";
549
+ if (wordCount <= 5)
550
+ return "medium";
551
+ return "high";
552
+ }
247
553
  async function runSinglePass(input) {
248
554
  if (!ANTHROPIC_API_KEY) {
249
555
  throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
250
556
  }
557
+ const passStartMs = Date.now();
558
+ const checklistSource = input.checklist && input.checklist.length > 0 ? "provided" : "extracted";
251
559
  // Fast path: skip-list check happens locally too, so trivial primitives
252
560
  // never spend a real API call. The system prompt also enforces this, but
253
561
  // checking here avoids the round-trip entirely for the common case.
254
562
  if (isSkipListMatch(input.component_need)) {
563
+ const skipListElapsedMs = Math.max(1, Date.now() - passStartMs);
255
564
  return {
256
565
  ok: true,
257
566
  result: {
@@ -262,11 +571,23 @@ async function runSinglePass(input) {
262
571
  requirements_checked: null,
263
572
  coverage: null,
264
573
  recommendation: {
265
- source: "shadcn/ui or 21st.dev (commodity primitive)",
574
+ source: "shadcn/ui, 21st.dev, or ReUI (commodity primitive)",
266
575
  install_command: null,
267
576
  component_description: null,
268
577
  reference: null,
269
578
  },
579
+ checklist_source: checklistSource,
580
+ // No API call happens on this path -- tokens/cost are genuinely
581
+ // zero, not omitted. total_ms is clamped to at least 1 so the
582
+ // field is never zero even though this branch is sub-millisecond;
583
+ // all of that trivial time is attributed to "extract" since it's
584
+ // the local skip-list check, not a search or scoring step.
585
+ _meta: {
586
+ total_ms: skipListElapsedMs,
587
+ breakdown_ms: { extract: skipListElapsedMs, search: 0, score: 0 },
588
+ tokens_used: { input: 0, output: 0 },
589
+ estimated_cost_usd: 0,
590
+ },
270
591
  },
271
592
  };
272
593
  }
@@ -285,10 +606,15 @@ async function runSinglePass(input) {
285
606
  return `- ${verb} for "${d.component_need}"${domainPart}, source: ${d.source}, confirmed ${d.timestamp}`;
286
607
  })
287
608
  .join("\n")}`;
609
+ const checklistBlock = input.checklist && input.checklist.length > 0
610
+ ? `\n\nProvided checklist (use exactly these items, do not re-extract):\n${input.checklist
611
+ .map((item, i) => `${i + 1}. ${item}`)
612
+ .join("\n")}`
613
+ : "";
288
614
  const userMessage = `component_need: ${input.component_need}
289
615
  domain: ${input.domain}
290
616
  framework: ${input.framework}
291
- existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock}`;
617
+ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}`;
292
618
  // Diagnostic only, same pattern as the other stderr diagnostics in this
293
619
  // file -- proves the memory lookup actually reached the prompt sent to
294
620
  // the model, not just that it was read from disk successfully.
@@ -300,75 +626,62 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
300
626
  included_in_prompt: pastDecisionsBlock || null,
301
627
  }));
302
628
  }
303
- const response = await fetch("https://api.anthropic.com/v1/messages", {
304
- method: "POST",
305
- headers: {
306
- "content-type": "application/json",
307
- "x-api-key": ANTHROPIC_API_KEY,
308
- "anthropic-version": "2023-06-01",
309
- },
310
- body: JSON.stringify({
311
- model: MODEL,
312
- // Raised from 4096: higher search budgets produce more candidates
313
- // and more per-requirement evidence text, and 4096 was observed
314
- // truncating mid-response (stop_reason "max_tokens"), which corrupts
315
- // the JSON extractJson() pulls out below.
316
- max_tokens: 8192,
317
- // System prompt is identical on every call, so mark it cacheable --
318
- // cache reads cost roughly a tenth of fresh input tokens. This is
319
- // the single biggest cost lever here: the same ~800-token prompt is
320
- // otherwise re-sent in full on every turn of the search loop, and on
321
- // every separate tool call besides.
322
- system: [
323
- {
324
- type: "text",
325
- text: buildSystemPrompt(SEARCH_BUDGET),
326
- cache_control: { type: "ephemeral" },
327
- },
328
- ],
329
- messages: [{ role: "user", content: userMessage }],
330
- tools: [
331
- {
332
- type: "web_search_20250305",
333
- name: "web_search",
334
- // Server-enforced cap, not just prompt instruction -- omitted
335
- // entirely when SEARCH_BUDGET is null (unlimited). +2 reserves
336
- // one slot each for the step-6 Mobbin and Figma Community
337
- // lookups so neither has to compete with discovery for the same
338
- // budget: without a reservation like this, discovery searches
339
- // (fired first) consumed the whole cap and the Mobbin search was
340
- // silently blocked (max_uses_exceeded) every time a custom_build
341
- // verdict was reached, and the model backfilled a plausible-
342
- // looking but ungrounded reference URL instead of reporting that
343
- // it never actually searched -- confirmed via a direct rerun
344
- // where 0 Mobbin queries were attempted but a specific Mobbin
345
- // URL was still returned. Figma Community gets the same
346
- // treatment now that it's a second reference source.
347
- ...(SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
348
- },
349
- {
350
- type: "web_fetch_20250910",
351
- name: "web_fetch",
352
- // Exactly one fetch per reference source (Mobbin, Figma
353
- // Community) -- step 6 fetches the search result page to look
354
- // for a deep link to the specific screen/flow already
355
- // identified, never more than once per source. Not reserved
356
- // from the web_search budget above; this is a separate tool
357
- // with its own separate cap.
358
- max_uses: 2,
359
- // Category/browse pages can be large, and all we need from them
360
- // is a permalink, not the full page -- caps token cost of a
361
- // fetch that turns out not to have a deep link after all.
362
- max_content_tokens: 15000,
363
- },
364
- ],
365
- }),
629
+ const data = await streamAnthropicMessage({
630
+ model: MODEL,
631
+ // Raised from 4096: higher search budgets produce more candidates
632
+ // and more per-requirement evidence text, and 4096 was observed
633
+ // truncating mid-response (stop_reason "max_tokens"), which corrupts
634
+ // the JSON extractJson() pulls out below.
635
+ max_tokens: 8192,
636
+ // System prompt is identical on every call, so mark it cacheable --
637
+ // cache reads cost roughly a tenth of fresh input tokens. This is
638
+ // the single biggest cost lever here: the same ~800-token prompt is
639
+ // otherwise re-sent in full on every turn of the search loop, and on
640
+ // every separate tool call besides.
641
+ system: [
642
+ {
643
+ type: "text",
644
+ text: buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
645
+ cache_control: { type: "ephemeral" },
646
+ },
647
+ ],
648
+ messages: [{ role: "user", content: userMessage }],
649
+ tools: [
650
+ {
651
+ type: "web_search_20250305",
652
+ name: "web_search",
653
+ // Server-enforced cap, not just prompt instruction -- omitted
654
+ // entirely when SEARCH_BUDGET is null (unlimited). +2 reserves
655
+ // one slot each for the step-6 Mobbin and Figma Community
656
+ // lookups so neither has to compete with discovery for the same
657
+ // budget: without a reservation like this, discovery searches
658
+ // (fired first) consumed the whole cap and the Mobbin search was
659
+ // silently blocked (max_uses_exceeded) every time a custom_build
660
+ // verdict was reached, and the model backfilled a plausible-
661
+ // looking but ungrounded reference URL instead of reporting that
662
+ // it never actually searched -- confirmed via a direct rerun
663
+ // where 0 Mobbin queries were attempted but a specific Mobbin
664
+ // URL was still returned. Figma Community gets the same
665
+ // treatment now that it's a second reference source.
666
+ ...(SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
667
+ },
668
+ {
669
+ type: "web_fetch_20250910",
670
+ name: "web_fetch",
671
+ // Exactly one fetch per reference source (Mobbin, Figma
672
+ // Community) -- step 6 fetches the search result page to look
673
+ // for a deep link to the specific screen/flow already
674
+ // identified, never more than once per source. Not reserved
675
+ // from the web_search budget above; this is a separate tool
676
+ // with its own separate cap.
677
+ max_uses: 2,
678
+ // Category/browse pages can be large, and all we need from them
679
+ // is a permalink, not the full page -- caps token cost of a
680
+ // fetch that turns out not to have a deep link after all.
681
+ max_content_tokens: 15000,
682
+ },
683
+ ],
366
684
  });
367
- if (!response.ok) {
368
- const errText = await response.text();
369
- throw new Error(`Anthropic API error ${response.status}: ${errText}`);
370
- }
371
- const data = (await response.json());
372
685
  // Diagnostic only -- logged to stderr (stdout is the MCP JSON-RPC
373
686
  // channel) so callers can measure actual vs. attempted search-call
374
687
  // counts against the configured budget without it leaking into the
@@ -485,6 +798,12 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
485
798
  enforceCoverageRecount(parsed);
486
799
  enforceVerdictThreshold(parsed);
487
800
  enforceRecommendationConsistency(parsed);
801
+ // Set server-side rather than trusted from the model -- deterministic
802
+ // from whether input.checklist was actually supplied, same "never trust
803
+ // the model where the server already knows the truth" policy as the
804
+ // other enforce* functions above.
805
+ parsed.checklist_source = checklistSource;
806
+ parsed._meta = buildMeta(data.timings, data.usage);
488
807
  // Same "server-side, not just prompt instruction" policy as the rest of
489
808
  // this file: a past_decision_signal is only trusted when this call
490
809
  // actually had past-decision context to consider. Strips a fabricated
@@ -501,6 +820,77 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
501
820
  }
502
821
  return { ok: true, result: parsed };
503
822
  }
823
+ // Backs the extract_requirements tool. Deliberately a separate, much
824
+ // smaller call than runSinglePass above: no tools declared (extraction is
825
+ // pure reasoning over component_need + domain, no search needed), so this
826
+ // is fast and cheap relative to recommend_component's full pipeline. Also
827
+ // applies the same local skip-list short-circuit as recommend_component,
828
+ // for the same reason (trivial primitives shouldn't cost an API call here
829
+ // either).
830
+ async function runExtraction(input) {
831
+ if (!ANTHROPIC_API_KEY) {
832
+ throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
833
+ }
834
+ const startMs = Date.now();
835
+ if (isSkipListMatch(input.component_need)) {
836
+ const elapsedMs = Math.max(1, Date.now() - startMs);
837
+ return {
838
+ ok: true,
839
+ result: {
840
+ checklist: [],
841
+ extraction_confidence: "high",
842
+ _meta: {
843
+ total_ms: elapsedMs,
844
+ breakdown_ms: { extract: elapsedMs, search: 0, score: 0 },
845
+ tokens_used: { input: 0, output: 0 },
846
+ estimated_cost_usd: 0,
847
+ },
848
+ },
849
+ };
850
+ }
851
+ const userMessage = `component_need: ${input.component_need}\ndomain: ${input.domain}`;
852
+ const data = await streamAnthropicMessage({
853
+ model: MODEL,
854
+ max_tokens: 1024,
855
+ system: [
856
+ {
857
+ type: "text",
858
+ text: buildExtractionSystemPrompt(),
859
+ cache_control: { type: "ephemeral" },
860
+ },
861
+ ],
862
+ messages: [{ role: "user", content: userMessage }],
863
+ });
864
+ if (data.stop_reason === "max_tokens") {
865
+ throw new Error("Anthropic response was truncated (stop_reason: max_tokens) before finishing its JSON output.");
866
+ }
867
+ const finalText = data.content
868
+ .filter((block) => block.type === "text")
869
+ .map((block) => block.text ?? "")
870
+ .join("\n")
871
+ .trim();
872
+ if (!finalText) {
873
+ throw new Error(`Anthropic response contained no text content to extract JSON from (stop_reason: ${data.stop_reason ?? "unknown"}).`);
874
+ }
875
+ const extracted = extractJson(finalText);
876
+ let parsed;
877
+ try {
878
+ parsed = JSON.parse(extracted);
879
+ }
880
+ catch {
881
+ console.error(JSON.stringify({ diagnostic: "postprocess_skipped", reason: "extract_requirements output did not parse as JSON" }));
882
+ return { ok: false, raw: extracted };
883
+ }
884
+ const checklist = Array.isArray(parsed.checklist) ? parsed.checklist.filter((item) => typeof item === "string") : [];
885
+ return {
886
+ ok: true,
887
+ result: {
888
+ checklist,
889
+ extraction_confidence: estimateExtractionConfidence(input.component_need),
890
+ _meta: buildMeta(data.timings, data.usage),
891
+ },
892
+ };
893
+ }
504
894
  // Coverage can only land on one of 9 discrete values when exactly 8
505
895
  // checklist items are extracted (0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5,
506
896
  // 100%). The 40% verdict threshold sits between met=3 (37.5%) and met=4
@@ -519,8 +909,8 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
519
909
  // both point the same direction for that case. It was pure extra cost
520
910
  // with no observed stability benefit; revisit if a future case shows
521
911
  // otherwise.
522
- const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
523
- function isBoundaryRisk(result) {
912
+ export const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
913
+ export function isBoundaryRisk(result) {
524
914
  if (result.reason !== "scored")
525
915
  return false;
526
916
  const items = result.requirements_checked;
@@ -570,6 +960,9 @@ function logCall(input, result) {
570
960
  if (result.verdict === "custom_build") {
571
961
  entry.reference_sources_grounded = groundedReferenceSources(result.recommendation);
572
962
  }
963
+ entry.checklist_source = result.checklist_source ?? null;
964
+ entry.total_ms = result._meta?.total_ms ?? null;
965
+ entry.estimated_cost_usd = result._meta?.estimated_cost_usd ?? null;
573
966
  }
574
967
  appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n", "utf8");
575
968
  }
@@ -625,7 +1018,7 @@ function recordDecision(input) {
625
1018
  // provided. Never called with no project_id -- callers skip memory
626
1019
  // entirely in that case (see runSinglePass) rather than falling back to
627
1020
  // some shared bucket that would mix unrelated projects' decisions.
628
- function getPastDecisions(projectId) {
1021
+ export function getPastDecisions(projectId) {
629
1022
  const memory = readMemory();
630
1023
  return memory[projectId] ?? [];
631
1024
  }
@@ -634,6 +1027,32 @@ function getPastDecisions(projectId) {
634
1027
  // verdict threshold that a single item's judgment swinging could flip
635
1028
  // the answer. Cases far from any boundary return the fast single-run
636
1029
  // path unchanged, at no extra cost.
1030
+ // Sums _meta across every pass that actually ran, for the ensemble case --
1031
+ // "total" here means cumulative internal compute/cost across all reruns,
1032
+ // not perceived wall-clock latency (the second and third passes run
1033
+ // concurrently via Promise.all, so wall-clock is closer to ~2x one pass,
1034
+ // not ~3x). Cost and token spend are genuinely additive across reruns, so
1035
+ // that's what total_ms/tokens_used/estimated_cost_usd report here; this is
1036
+ // called out in the README so a 3x-looking total_ms isn't mistaken for
1037
+ // request latency.
1038
+ function aggregateMeta(passes) {
1039
+ const metas = passes.map((p) => p.result._meta).filter((m) => !!m);
1040
+ if (metas.length === 0)
1041
+ return undefined;
1042
+ return {
1043
+ total_ms: metas.reduce((sum, m) => sum + m.total_ms, 0),
1044
+ breakdown_ms: {
1045
+ extract: metas.reduce((sum, m) => sum + m.breakdown_ms.extract, 0),
1046
+ search: metas.reduce((sum, m) => sum + m.breakdown_ms.search, 0),
1047
+ score: metas.reduce((sum, m) => sum + m.breakdown_ms.score, 0),
1048
+ },
1049
+ tokens_used: {
1050
+ input: metas.reduce((sum, m) => sum + m.tokens_used.input, 0),
1051
+ output: metas.reduce((sum, m) => sum + m.tokens_used.output, 0),
1052
+ },
1053
+ estimated_cost_usd: Math.round(metas.reduce((sum, m) => sum + m.estimated_cost_usd, 0) * 10000) / 10000,
1054
+ };
1055
+ }
637
1056
  async function judgeComponent(input) {
638
1057
  // Session cap and local logging both apply only to calls that actually
639
1058
  // reach the API -- skip-list hits never do, so both are excluded here
@@ -692,6 +1111,7 @@ async function judgeComponent(input) {
692
1111
  if (majorityCount < passes.length)
693
1112
  base.confidence = "low";
694
1113
  base.ensemble = { triggered: true, runs: verdicts, agreement };
1114
+ base._meta = aggregateMeta(passes) ?? base._meta;
695
1115
  console.error(JSON.stringify({
696
1116
  diagnostic: "ensemble_decision",
697
1117
  runs: verdicts,
@@ -715,7 +1135,7 @@ async function judgeComponent(input) {
715
1135
  // a plain enumerable list, not arithmetic the model has to get right --
716
1136
  // and overwrite `coverage` with the true tally before anything else reads
717
1137
  // it.
718
- function enforceCoverageRecount(parsed) {
1138
+ export function enforceCoverageRecount(parsed) {
719
1139
  if (parsed.reason !== "scored")
720
1140
  return;
721
1141
  const items = parsed.requirements_checked;
@@ -742,7 +1162,7 @@ function enforceCoverageRecount(parsed) {
742
1162
  // thresholds (>=80 high, 40-79 low, <40 custom_build) call for
743
1163
  // "use_existing" at low confidence. Recompute deterministically instead of
744
1164
  // trusting the model's arithmetic.
745
- function parseCoveragePercent(coverage) {
1165
+ export function parseCoveragePercent(coverage) {
746
1166
  if (!coverage)
747
1167
  return null;
748
1168
  const parenMatch = coverage.match(/\((\d+(?:\.\d+)?)%\)/);
@@ -757,7 +1177,7 @@ function parseCoveragePercent(coverage) {
757
1177
  }
758
1178
  return null;
759
1179
  }
760
- function enforceVerdictThreshold(parsed) {
1180
+ export function enforceVerdictThreshold(parsed) {
761
1181
  if (parsed.reason !== "scored")
762
1182
  return;
763
1183
  const pct = parseCoveragePercent(parsed.coverage);
@@ -811,7 +1231,7 @@ function enforceVerdictThreshold(parsed) {
811
1231
  // after every other correction, on every single pass, so each pass
812
1232
  // entering the ensemble is already self-consistent before any
813
1233
  // cross-pass selection happens.
814
- function enforceRecommendationConsistency(parsed) {
1234
+ export function enforceRecommendationConsistency(parsed) {
815
1235
  const rec = parsed.recommendation;
816
1236
  if (!rec)
817
1237
  return;
@@ -843,7 +1263,7 @@ function enforceRecommendationConsistency(parsed) {
843
1263
  // still valid when only one source grounded) or an array of up to 2 --
844
1264
  // normalize, filter per-entry, then collapse back down: 0 survivors ->
845
1265
  // null, 1 -> bare object (never a one-element array), 2 -> array.
846
- function referenceSourceKeyword(source) {
1266
+ export function referenceSourceKeyword(source) {
847
1267
  const normalized = (source ?? "").toLowerCase();
848
1268
  if (normalized.includes("mobbin"))
849
1269
  return "mobbin";
@@ -851,7 +1271,7 @@ function referenceSourceKeyword(source) {
851
1271
  return "figma";
852
1272
  return null; // unrecognized source -- can't verify, treated as ungrounded below
853
1273
  }
854
- const DOMAIN_FOR_SOURCE_KEYWORD = {
1274
+ export const DOMAIN_FOR_SOURCE_KEYWORD = {
855
1275
  mobbin: "mobbin.com",
856
1276
  figma: "figma.com",
857
1277
  };
@@ -865,13 +1285,13 @@ const DOMAIN_FOR_SOURCE_KEYWORD = {
865
1285
  // only ever fail -- treating an already-specific file URL as grounded
866
1286
  // without a fetch avoids wasting the reserved fetch budget on a check that
867
1287
  // cannot succeed and isn't needed anyway.
868
- const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
1288
+ export const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
869
1289
  // Pulls literal http(s) URLs out of arbitrary tool-result content (search
870
1290
  // results, fetched page text) without needing to know that content's
871
1291
  // exact shape -- used only to find real candidate URLs, never to
872
1292
  // construct one, so a shape we didn't anticipate just yields fewer
873
1293
  // matches rather than a wrong parse.
874
- function extractUrlsForDomain(content, domain) {
1294
+ export function extractUrlsForDomain(content, domain) {
875
1295
  if (!content)
876
1296
  return [];
877
1297
  const text = typeof content === "string" ? content : JSON.stringify(content);
@@ -888,7 +1308,7 @@ function extractUrlsForDomain(content, domain) {
888
1308
  // "entry_point" and the URL is swapped for one a real search/fetch call
889
1309
  // actually returned -- the model's own unconfirmed claim is never kept,
890
1310
  // same policy already enforced for search-only grounding above.
891
- function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
1311
+ export function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
892
1312
  const domain = DOMAIN_FOR_SOURCE_KEYWORD[keyword];
893
1313
  const claimedUrl = (entry.url ?? "").trim();
894
1314
  if (keyword === "figma" && FIGMA_FILE_URL_PATTERN.test(claimedUrl)) {
@@ -926,7 +1346,7 @@ function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetch
926
1346
  entry.reference_description = `${entry.reference_description} (${caveat})`;
927
1347
  }
928
1348
  }
929
- function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
1349
+ export function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
930
1350
  const rawReference = parsed.recommendation?.reference;
931
1351
  if (!rawReference)
932
1352
  return;
@@ -970,7 +1390,7 @@ function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsBy
970
1390
  // output directly (the README's whole contract is structured JSON, not
971
1391
  // prose), so pull out the {...} substring rather than trust verbatim
972
1392
  // compliance.
973
- function extractJson(text) {
1393
+ export function extractJson(text) {
974
1394
  const start = text.indexOf("{");
975
1395
  const end = text.lastIndexOf("}");
976
1396
  if (start === -1 || end === -1 || end < start)
@@ -983,9 +1403,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
983
1403
  {
984
1404
  name: TOOL_NAME,
985
1405
  description: "Judges whether a UI component need should be met with an existing " +
986
- "shadcn/ui or 21st.dev component, or requires a custom build guided " +
987
- "by a real-app reference from Mobbin. Returns a structured verdict " +
988
- "(use_existing | custom_build), not a list of search results. Call " +
1406
+ "shadcn/ui, 21st.dev, or ReUI (reui.io) component, or requires a " +
1407
+ "custom build guided by a real-app reference from Mobbin. Returns " +
1408
+ "a structured verdict (use_existing | custom_build), not a list " +
1409
+ "of search results. Call " +
989
1410
  "this whenever you are about to scaffold a new, non-trivial UI " +
990
1411
  "component from scratch, when you're unsure your own default output " +
991
1412
  "will look production-quality, or when the user references a " +
@@ -1010,9 +1431,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1010
1431
  "own past confirmed decisions (recorded via " +
1011
1432
  "record_component_decision) as a consistency signal -- coverage " +
1012
1433
  "is still scored fresh every call regardless; this never returns " +
1013
- "a cached verdict.",
1434
+ "a cached verdict. Pass checklist (optional, string array) to skip " +
1435
+ "this call's own internal requirement extraction and score " +
1436
+ "directly against a checklist you already have -- e.g. from a " +
1437
+ "prior extract_requirements call you inspected or edited first. " +
1438
+ "Omit it to keep today's default behavior unchanged. The response " +
1439
+ "always includes checklist_source ('extracted' | 'provided') and " +
1440
+ "an internal _meta block (timing/token/cost accounting) -- neither " +
1441
+ "affects the verdict itself.",
1014
1442
  inputSchema: INPUT_SCHEMA,
1015
1443
  },
1444
+ {
1445
+ name: EXTRACT_REQUIREMENTS_TOOL_NAME,
1446
+ description: "Runs only the requirement-extraction step recommend_component " +
1447
+ "normally does internally, and returns the checklist on its own -- " +
1448
+ "no search, no scoring, no verdict. Use this when you want to " +
1449
+ "inspect (and optionally hand-edit) the checklist BEFORE " +
1450
+ "recommend_component spends its search+score budget, e.g. to catch " +
1451
+ "a misread requirement early. Pass the resulting (or your edited) " +
1452
+ "checklist back into recommend_component's optional checklist " +
1453
+ "param to score against it directly. extraction_confidence is a " +
1454
+ "heuristic based on how specific component_need is, not a " +
1455
+ "calibrated signal -- treat 'low' as a hint to reread the input, " +
1456
+ "not a hard error. Cheaper and faster than recommend_component " +
1457
+ "since it makes no search calls at all.",
1458
+ inputSchema: EXTRACT_REQUIREMENTS_INPUT_SCHEMA,
1459
+ },
1016
1460
  {
1017
1461
  name: RECORD_DECISION_TOOL_NAME,
1018
1462
  description: "Records a UI component decision you have actually acted on -- call " +
@@ -1045,6 +1489,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1045
1489
  };
1046
1490
  }
1047
1491
  }
1492
+ if (request.params.name === EXTRACT_REQUIREMENTS_TOOL_NAME) {
1493
+ const args = request.params.arguments;
1494
+ // Same session-cap protection as recommend_component, extended to
1495
+ // this tool since it's a real API call too (skip-list hits excluded,
1496
+ // same exclusion recommend_component applies).
1497
+ const reachesApi = !isSkipListMatch(args.component_need);
1498
+ try {
1499
+ if (reachesApi) {
1500
+ if (sessionCallCount >= SESSION_CALL_CAP) {
1501
+ throw new Error(`Session call cap (${SESSION_CALL_CAP}) reached. This protects against runaway costs on your API key. Restart the MCP server to reset the counter, or set PATTERN_SESSION_CAP to raise the limit.`);
1502
+ }
1503
+ sessionCallCount++;
1504
+ console.error(JSON.stringify({ diagnostic: "session_call_count", count: sessionCallCount, cap: SESSION_CALL_CAP }));
1505
+ }
1506
+ const outcome = await runExtraction(args);
1507
+ const resultText = outcome.ok ? JSON.stringify(outcome.result) : outcome.raw;
1508
+ return {
1509
+ content: [{ type: "text", text: resultText }],
1510
+ };
1511
+ }
1512
+ catch (err) {
1513
+ const message = err instanceof Error ? err.message : String(err);
1514
+ return {
1515
+ content: [{ type: "text", text: `Error: ${message}` }],
1516
+ isError: true,
1517
+ };
1518
+ }
1519
+ }
1048
1520
  if (request.params.name === RECORD_DECISION_TOOL_NAME) {
1049
1521
  const args = request.params.arguments;
1050
1522
  try {