pattern-mcp 0.1.1 → 0.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.
@@ -0,0 +1,110 @@
1
+ // Thin, shared Anthropic Messages API caller for the staged pipeline's
2
+ // four independent calls. Deliberately separate from src/index.ts's own
3
+ // fetch logic (which is tuned for the single bundled call) rather than
4
+ // reused, since each stage here has a different tool/budget shape.
5
+ import { ANTHROPIC_API_KEY, MODEL, extractJson, extractUrlsForDomain } from "../index.js";
6
+ export async function callAnthropic({ systemPrompt, userMessage, tools, maxTokens = 4096 }) {
7
+ if (!ANTHROPIC_API_KEY) {
8
+ throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this pipeline.");
9
+ }
10
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
11
+ method: "POST",
12
+ headers: {
13
+ "content-type": "application/json",
14
+ "x-api-key": ANTHROPIC_API_KEY,
15
+ "anthropic-version": "2023-06-01",
16
+ },
17
+ body: JSON.stringify({
18
+ model: MODEL,
19
+ max_tokens: maxTokens,
20
+ system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
21
+ messages: [{ role: "user", content: userMessage }],
22
+ ...(tools ? { tools } : {}),
23
+ }),
24
+ });
25
+ if (!response.ok) {
26
+ const errText = await response.text();
27
+ throw new Error(`Anthropic API error ${response.status}: ${errText}`);
28
+ }
29
+ return (await response.json());
30
+ }
31
+ export function textFromResult(data) {
32
+ return data.content
33
+ .filter((b) => b.type === "text")
34
+ .map((b) => b.text ?? "")
35
+ .join("\n")
36
+ .trim();
37
+ }
38
+ export function parseJsonResponse(data) {
39
+ const text = textFromResult(data);
40
+ if (!text)
41
+ return { ok: false, raw: "" };
42
+ const extracted = extractJson(text);
43
+ try {
44
+ return { ok: true, value: JSON.parse(extracted) };
45
+ }
46
+ catch {
47
+ return { ok: false, raw: extracted };
48
+ }
49
+ }
50
+ export function searchCallDiagnostics(data) {
51
+ const searchCalls = data.content.filter((b) => b.type === "server_tool_use" && b.name === "web_search");
52
+ const resultsById = new Map(data.content.filter((b) => b.type === "web_search_tool_result").map((b) => [b.tool_use_id, b.content]));
53
+ return searchCalls.map((call) => {
54
+ const result = resultsById.get(call.id);
55
+ const isError = typeof result === "object" && result !== null && !Array.isArray(result) && "error_code" in result;
56
+ return {
57
+ query: call.input,
58
+ succeeded: !isError,
59
+ error_code: isError ? result.error_code : undefined,
60
+ };
61
+ });
62
+ }
63
+ /**
64
+ * Reconstructs the fallback-URL map enforceReferenceGrounding needs:
65
+ * for each reference-source keyword (e.g. "mobbin", "figma"), every URL
66
+ * found in that keyword's own successful search results. Mirrors the
67
+ * inline logic in src/index.ts's runSinglePass exactly, since
68
+ * enforceReferenceGrounding's fallback behavior depends on it.
69
+ */
70
+ export function buildSearchResultUrlsByKeyword(data, domainForKeyword) {
71
+ const searchCalls = data.content.filter((b) => b.type === "server_tool_use" && b.name === "web_search");
72
+ const resultsById = new Map(data.content.filter((b) => b.type === "web_search_tool_result").map((b) => [b.tool_use_id, b.content]));
73
+ const map = new Map();
74
+ for (const call of searchCalls) {
75
+ const q = typeof call.input === "object" && call.input !== null ? JSON.stringify(call.input) : String(call.input ?? "");
76
+ const qLower = q.toLowerCase();
77
+ const keyword = Object.keys(domainForKeyword).find((k) => qLower.includes(k)) ?? null;
78
+ if (!keyword)
79
+ continue;
80
+ const result = resultsById.get(call.id);
81
+ const isError = typeof result === "object" && result !== null && !Array.isArray(result) && "error_code" in result;
82
+ if (isError)
83
+ continue;
84
+ const urls = extractUrlsForDomain(result, domainForKeyword[keyword]);
85
+ map.set(keyword, (map.get(keyword) ?? []).concat(urls));
86
+ }
87
+ return map;
88
+ }
89
+ export function fetchCallDiagnostics(data) {
90
+ const fetchCalls = data.content.filter((b) => b.type === "server_tool_use" && b.name === "web_fetch");
91
+ const resultsById = new Map(data.content.filter((b) => b.type === "web_fetch_tool_result").map((b) => [b.tool_use_id, b.content]));
92
+ return fetchCalls.map((call) => {
93
+ const result = resultsById.get(call.id);
94
+ const isError = typeof result === "object" && result !== null && !Array.isArray(result) && "error_code" in result;
95
+ const input = call.input;
96
+ let fetchedText = null;
97
+ if (!isError && typeof result === "object" && result !== null) {
98
+ const r = result;
99
+ if (r.content?.source?.type === "text" && typeof r.content.source.data === "string") {
100
+ fetchedText = r.content.source.data;
101
+ }
102
+ }
103
+ return {
104
+ url: input?.url,
105
+ succeeded: !isError,
106
+ error_code: isError ? result.error_code : undefined,
107
+ fetchedText,
108
+ };
109
+ });
110
+ }
@@ -0,0 +1,22 @@
1
+ // Stage 1: requirement extraction, in isolation. No tools, no search --
2
+ // same wording as step 2 of the bundled system prompt in src/index.ts,
3
+ // and the same prompt used in scripts/phase1-extraction-only.mjs, so
4
+ // Phase 3's comparison is testing an architecture difference, not a
5
+ // reworded prompt.
6
+ import { callAnthropic, parseJsonResponse } from "./anthropic.js";
7
+ const EXTRACTION_SYSTEM_PROMPT = `You are the requirement-extraction step of a UI component judgment pipeline. Given a component need and domain, turn it 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.
8
+
9
+ Respond with ONLY a JSON object, no prose before or after, no markdown code fences, matching this exact shape:
10
+ { "requirements": ["string", "string", "string", "string", "string", "string", "string", "string"] }`;
11
+ export async function extractRequirements(input) {
12
+ const userMessage = `component_need: ${input.component_need}\ndomain: ${input.domain}\nframework: ${input.framework}`;
13
+ const data = await callAnthropic({ systemPrompt: EXTRACTION_SYSTEM_PROMPT, userMessage, maxTokens: 1024 });
14
+ const parsed = parseJsonResponse(data);
15
+ if (!parsed.ok) {
16
+ throw new Error(`Extraction stage did not return valid JSON: ${parsed.raw.slice(0, 200)}`);
17
+ }
18
+ if (!Array.isArray(parsed.value.requirements) || parsed.value.requirements.length === 0) {
19
+ throw new Error("Extraction stage returned no requirements array");
20
+ }
21
+ return { requirements: parsed.value.requirements, diagnostics: { searchCalls: 0, note: "extraction makes no tool calls" } };
22
+ }
@@ -0,0 +1,103 @@
1
+ // Phase 2 orchestrator: runs extract -> search -> score -> (reference, if
2
+ // custom_build) as four independent calls instead of src/index.ts's one
3
+ // bundled call, applying the same server-side threshold/recount/ensemble
4
+ // logic (reused, not reimplemented) so Phase 3 is comparing architecture,
5
+ // not comparing whose enforcement code is stricter.
6
+ //
7
+ // This is experimental and NOT wired into the shipped MCP server -- see
8
+ // scripts/phase2-staged-smoke-test.mjs for how to invoke it standalone.
9
+ import { enforceCoverageRecount, enforceRecommendationConsistency, enforceVerdictThreshold, isBoundaryRisk, isSkipListMatch, } from "../index.js";
10
+ import { extractRequirements } from "./extract.js";
11
+ import { searchCandidates } from "./search.js";
12
+ import { scoreCandidates } from "./score.js";
13
+ import { searchReference } from "./reference.js";
14
+ function nowIso() {
15
+ return new Date().toISOString();
16
+ }
17
+ async function logged(log, stage, input, fn) {
18
+ const timestamp = nowIso();
19
+ try {
20
+ const output = await fn();
21
+ log.push({ stage, timestamp, input, output, ok: true });
22
+ return output;
23
+ }
24
+ catch (err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ log.push({ stage, timestamp, input, output: null, ok: false, error: message });
27
+ throw err;
28
+ }
29
+ }
30
+ /** One full extract -> search -> score -> (reference) pass. No ensemble here -- runSingleStagedPass wraps this 3x itself when boundary risk is detected, mirroring judgeComponent in src/index.ts. */
31
+ async function runSingleStagedPass(input, log) {
32
+ const extractInput = { component_need: input.component_need, domain: input.domain, framework: input.framework };
33
+ const extraction = await logged(log, "extract", extractInput, () => extractRequirements(input));
34
+ const searchInput = { ...extractInput, requirements: extraction.requirements };
35
+ const search = await logged(log, "search", searchInput, () => searchCandidates(input, extraction.requirements));
36
+ const scoreInput = { requirements: extraction.requirements, candidates: search.candidates };
37
+ const score = await logged(log, "score", scoreInput, () => scoreCandidates(input, extraction.requirements, search.candidates));
38
+ const result = {
39
+ verdict: score.reason === "no_candidates_found" ? "custom_build" : "use_existing", // placeholder; enforceVerdictThreshold corrects the "scored" case from coverage
40
+ confidence: score.reason === "no_candidates_found" ? "high" : "medium",
41
+ reason: score.reason,
42
+ computed_at: nowIso().slice(0, 10),
43
+ requirements_checked: score.requirements_checked,
44
+ coverage: score.coverage,
45
+ recommendation: {
46
+ source: score.recommendation.source,
47
+ install_command: score.recommendation.install_command,
48
+ component_description: score.recommendation.component_description,
49
+ reference: null,
50
+ },
51
+ };
52
+ enforceCoverageRecount(result);
53
+ enforceVerdictThreshold(result);
54
+ if (result.verdict === "custom_build") {
55
+ const referenceInput = { component_need: input.component_need, domain: input.domain };
56
+ const referenceResult = await logged(log, "reference", referenceInput, () => searchReference(input));
57
+ if (result.recommendation)
58
+ result.recommendation.reference = referenceResult.reference;
59
+ }
60
+ enforceRecommendationConsistency(result);
61
+ return result;
62
+ }
63
+ export async function runStagedPipeline(input) {
64
+ const log = [];
65
+ if (isSkipListMatch(input.component_need)) {
66
+ const result = {
67
+ verdict: "use_existing",
68
+ confidence: "high",
69
+ reason: "skip_list",
70
+ computed_at: nowIso().slice(0, 10),
71
+ requirements_checked: null,
72
+ coverage: null,
73
+ recommendation: {
74
+ source: "shadcn/ui or 21st.dev (commodity primitive)",
75
+ install_command: null,
76
+ component_description: null,
77
+ reference: null,
78
+ },
79
+ ensemble: { triggered: false },
80
+ };
81
+ return { result, log };
82
+ }
83
+ const first = await runSingleStagedPass(input, log);
84
+ if (!isBoundaryRisk(first)) {
85
+ first.ensemble = { triggered: false };
86
+ return { result: first, log };
87
+ }
88
+ const [second, third] = await Promise.all([runSingleStagedPass(input, log), runSingleStagedPass(input, log)]);
89
+ const passes = [first, second, third];
90
+ const verdicts = passes.map((p) => p.verdict);
91
+ const counts = new Map();
92
+ for (const v of verdicts)
93
+ counts.set(v, (counts.get(v) ?? 0) + 1);
94
+ const [majorityVerdict, majorityCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
95
+ const agreement = `${majorityCount}/${passes.length}`;
96
+ const winningPass = passes.find((p) => p.verdict === majorityVerdict) ?? first;
97
+ const base = winningPass;
98
+ base.verdict = majorityVerdict;
99
+ if (majorityCount < passes.length)
100
+ base.confidence = "low";
101
+ base.ensemble = { triggered: true, runs: verdicts, agreement };
102
+ return { result: base, log };
103
+ }
@@ -0,0 +1,67 @@
1
+ // Stage 3b: reference search, run only when scoring resolves to
2
+ // custom_build. Kept as its own isolated call rather than a 4th named
3
+ // pipeline stage, since the plan calls for extract/search/score and this
4
+ // is causally downstream of score's verdict, not an independent stage --
5
+ // but it's still logged separately, same as the other three.
6
+ //
7
+ // Reuses enforceReferenceGrounding and applyDeepLinkGrounding from
8
+ // src/index.ts verbatim rather than reimplementing them, so the
9
+ // no-fabrication and deep-link-verification guarantees are identical to
10
+ // the bundled pipeline's -- this stage's whole job is finding candidate
11
+ // URLs; the trust decision about them stays centralized in one place.
12
+ import { DOMAIN_FOR_SOURCE_KEYWORD, enforceReferenceGrounding } from "../index.js";
13
+ import { buildSearchResultUrlsByKeyword, callAnthropic, fetchCallDiagnostics, parseJsonResponse, searchCallDiagnostics } from "./anthropic.js";
14
+ const REFERENCE_SYSTEM_PROMPT = `You are the reference-search step of a UI component judgment pipeline, run only when an earlier step decided no existing component covers a UI need well enough. Search TWO reference sources, one search call each:
15
+ - Mobbin (site:mobbin.com) for the closest real-app screen matching the stated domain (e.g. real Airbnb screens for an Airbnb-style app).
16
+ - Figma Community (site:figma.com/community) for a relevant real component or template file matching the stated domain and component need. Plain web search only -- there is no Figma API token available, don't attempt to use one.
17
+
18
+ A search result URL is very often a category/browse page (e.g. mobbin.com/explore/mobile/screens/notifications), not a direct link to the specific screen or flow you actually identified. Figma Community results are different: a URL containing "/community/file/" is already file-specific by Figma's own URL structure -- leave it as-is and do not spend a fetch on it. Only a non-"/community/file/" Figma result has the same category-vs-specific gap Mobbin has.
19
+
20
+ For each Mobbin result, and for any Figma Community result that isn't already a "/community/file/" URL: fetch that result's URL with the web_fetch tool and look in the fetched page content for a more specific permalink pointing at that same specific screen or flow you identified. Use that permalink as the reference "url" ONLY if you can actually see it written in the fetched content -- never construct, guess, or pattern-match a deep-link URL that isn't literally present on the page. Figma's robots.txt blocks automated fetching site-wide, so a Figma category-page fetch will very likely fail outright -- that's expected. Each source gets at most ONE fetch attempt; do not retry by guessing a different URL variant. If a fetch fails or doesn't expose a more specific link, keep the category/search URL as "url" and say so plainly in "reference_description".
21
+
22
+ Include a reference for each source that actually returned a real, relevant result from a search you actually ran -- never name a plausible-sounding URL from memory for either source. If out of budget, or a search found nothing relevant, that source is simply not included.
23
+
24
+ Shape the "reference" field based on how many sources actually grounded:
25
+ - Both grounded: an array of both reference objects.
26
+ - Only one grounded: a single reference object (not a one-element array).
27
+ - Neither grounded: reference is null.
28
+
29
+ Each reference object has: "source" ("Mobbin" or "Figma Community"), "url", and either "flow_name" (Mobbin) or "file_name" (Figma Community). Each also gets its own "reference_description": 1-2 sentences of plain-language description of what that specific screen or file actually shows, grounded only in what you saw in that source's own search result.
30
+
31
+ Respond with ONLY a JSON object, no prose before or after, no markdown code fences, matching this exact shape:
32
+ { "recommendation": { "reference": { "source": "Mobbin" | "Figma Community", "url": "string", "flow_name": "string (Mobbin only)", "file_name": "string (Figma Community only)", "reference_description": "string" } | [ /* same shape, up to 2 entries */ ] | null } }`;
33
+ export async function searchReference(input) {
34
+ const userMessage = `component_need: ${input.component_need}\ndomain: ${input.domain}`;
35
+ const data = await callAnthropic({
36
+ systemPrompt: REFERENCE_SYSTEM_PROMPT,
37
+ userMessage,
38
+ maxTokens: 8192, // matches score.ts's reasoning
39
+ tools: [
40
+ { type: "web_search_20250305", name: "web_search", max_uses: 2 },
41
+ { type: "web_fetch_20250910", name: "web_fetch", max_uses: 2, max_content_tokens: 15000 },
42
+ ],
43
+ });
44
+ const searchCallDetails = searchCallDiagnostics(data);
45
+ const fetchCallDetails = fetchCallDiagnostics(data);
46
+ const searchResultUrlsByKeyword = buildSearchResultUrlsByKeyword(data, DOMAIN_FOR_SOURCE_KEYWORD);
47
+ if (data.stop_reason === "max_tokens") {
48
+ throw new Error("Reference stage response was truncated (max_tokens) before finishing its JSON output.");
49
+ }
50
+ const parsed = parseJsonResponse(data);
51
+ if (!parsed.ok) {
52
+ throw new Error(`Reference stage did not return valid JSON: ${parsed.raw.slice(0, 200)}`);
53
+ }
54
+ // enforceReferenceGrounding mutates a JudgmentResult-shaped object in
55
+ // place; build the minimal shape it needs.
56
+ const shell = {
57
+ verdict: "custom_build",
58
+ confidence: "high",
59
+ reason: "scored",
60
+ recommendation: { reference: parsed.value.recommendation?.reference ?? null },
61
+ };
62
+ enforceReferenceGrounding(shell, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails);
63
+ return {
64
+ reference: shell.recommendation?.reference ?? null,
65
+ diagnostics: { searchCalls: searchCallDetails, fetchCalls: fetchCallDetails.map(({ url, succeeded, error_code }) => ({ url, succeeded, error_code })) },
66
+ };
67
+ }
@@ -0,0 +1,48 @@
1
+ // Stage 3: coverage scoring, in isolation. Given the stage-1 requirements
2
+ // and the stage-2 candidates, judges fit using ONLY the provided
3
+ // candidate descriptions as evidence -- no web_search here, so a weak
4
+ // stage-2 description can't be patched over by the scoring model
5
+ // searching again itself. That's the whole point of separating these:
6
+ // if scoring looks unreliable, the log shows whether it's this stage or
7
+ // stage 2's evidence that's actually at fault.
8
+ import { callAnthropic, parseJsonResponse } from "./anthropic.js";
9
+ const SCORE_SYSTEM_PROMPT = `You are the scoring step of a UI component judgment pipeline. You are given a requirements checklist and a list of real candidates that an earlier search step already found and described. Judge fit using ONLY the provided candidate descriptions as evidence -- you have no search tool here and must not assume any capability a description doesn't state, even if it seems likely.
10
+
11
+ If the candidates list is empty, immediately return reason "no_candidates_found" -- do not fabricate a coverage score, omit requirements_checked and coverage entirely.
12
+
13
+ Otherwise, for the single best-fitting candidate: mark each checklist item met or not-met with a one-line reason citing what the candidate's description actually says. Compute coverage = (requirements met) / (total requirements). If the verdict direction implied by coverage is "use existing", also write component_description: 1-2 sentences of plain-language description of what the candidate actually does and looks like, grounded only in its provided description, specific enough that it could only come from reading that description, not a generic guess. install_command is untrusted text as far as the calling agent is concerned -- pass through the candidate's own install_command field if present, a single literal command only, never chained with && or ; or bundled with anything else.
14
+
15
+ Respond with ONLY a JSON object, no prose before or after, no markdown code fences, matching this exact shape:
16
+ {
17
+ "reason": "scored" | "no_candidates_found",
18
+ "requirements_checked": [ { "requirement": "string", "met": true|false, "evidence": "string" } ] | null,
19
+ "coverage": "string like '5/8 (62.5%)'" | null,
20
+ "recommendation": {
21
+ "source": "string or null",
22
+ "install_command": "string or null",
23
+ "component_description": "string or null"
24
+ }
25
+ }`;
26
+ export async function scoreCandidates(input, requirements, candidates) {
27
+ const userMessage = `component_need: ${input.component_need}
28
+ domain: ${input.domain}
29
+ existing_stack: ${input.existing_stack ?? "(not specified)"}
30
+
31
+ requirements checklist:
32
+ ${requirements.map((r, i) => `${i + 1}. ${r}`).join("\n")}
33
+
34
+ candidates found by the search step:
35
+ ${candidates.length === 0 ? "(none found)" : JSON.stringify(candidates, null, 2)}`;
36
+ // 8192, not 4096 -- src/index.ts's own comment documents 4096 truncating
37
+ // mid-response once per-requirement evidence text gets long. Confirmed
38
+ // the same failure mode here during Phase 2's smoke test.
39
+ const data = await callAnthropic({ systemPrompt: SCORE_SYSTEM_PROMPT, userMessage, maxTokens: 8192 });
40
+ if (data.stop_reason === "max_tokens") {
41
+ throw new Error("Score stage response was truncated (max_tokens) before finishing its JSON output.");
42
+ }
43
+ const parsed = parseJsonResponse(data);
44
+ if (!parsed.ok) {
45
+ throw new Error(`Score stage did not return valid JSON: ${parsed.raw.slice(0, 200)}`);
46
+ }
47
+ return parsed.value;
48
+ }
@@ -0,0 +1,41 @@
1
+ // Stage 2: candidate search, in isolation. Searches shadcn/ui and
2
+ // 21st.dev and writes down what it finds -- no scoring against the
3
+ // requirements checklist happens here, that's stage 3's job. Receives
4
+ // the stage-1 requirements as context (the bundled pipeline has them
5
+ // available by search time too, so this keeps the comparison fair)
6
+ // but must not pre-judge fit.
7
+ import { callAnthropic, parseJsonResponse, searchCallDiagnostics } from "./anthropic.js";
8
+ const SEARCH_BUDGET = 2;
9
+ const SEARCH_SYSTEM_PROMPT = `You are the candidate-search step of a UI component judgment pipeline. You are given a component need, domain, framework, and a requirements checklist that a later step will score against -- your job is only to find and describe real candidates, not to judge whether they fit.
10
+
11
+ Search shadcn/ui and 21st.dev for components matching the stated need, filtered to the stated framework. Fire both searches together in the same turn rather than one at a time. Budget: at most ${SEARCH_BUDGET} search calls total.
12
+
13
+ For each real candidate you find, write a grounded description of what you actually found: its real described props/structure/functionality, not just marketing copy, since a later step will score requirements against this description alone and cannot re-search. Be specific enough that someone reading only your description (never seeing the original page) could judge whether each checklist item is met.
14
+
15
+ If search returns zero real candidates -- not just weak matches, but nothing relevant at all -- return an empty candidates array. Do not fabricate a candidate to avoid an empty result.
16
+
17
+ Respond with ONLY a JSON object, no prose before or after, no markdown code fences, matching this exact shape:
18
+ { "candidates": [ { "source": "shadcn/ui | 21st.dev", "name": "string", "url": "string or null", "description": "string, grounded in what you actually found", "install_command": "string or null" } ] }`;
19
+ export async function searchCandidates(input, requirements) {
20
+ const userMessage = `component_need: ${input.component_need}
21
+ domain: ${input.domain}
22
+ framework: ${input.framework}
23
+ existing_stack: ${input.existing_stack ?? "(not specified)"}
24
+ requirements checklist (for search targeting only, do not score against it here):
25
+ ${requirements.map((r, i) => `${i + 1}. ${r}`).join("\n")}`;
26
+ const data = await callAnthropic({
27
+ systemPrompt: SEARCH_SYSTEM_PROMPT,
28
+ userMessage,
29
+ maxTokens: 8192, // matches score.ts's reasoning: candidate write-ups can run long
30
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: SEARCH_BUDGET }],
31
+ });
32
+ const searchCalls = searchCallDiagnostics(data);
33
+ if (data.stop_reason === "max_tokens") {
34
+ throw new Error("Search stage response was truncated (max_tokens) before finishing its JSON output.");
35
+ }
36
+ const parsed = parseJsonResponse(data);
37
+ if (!parsed.ok) {
38
+ throw new Error(`Search stage did not return valid JSON: ${parsed.raw.slice(0, 200)}`);
39
+ }
40
+ return { candidates: Array.isArray(parsed.value.candidates) ? parsed.value.candidates : [], diagnostics: { searchCalls } };
41
+ }
@@ -0,0 +1,7 @@
1
+ // Shared types for the staged (extract / search / score) pipeline.
2
+ //
3
+ // This is Phase 2 of validation-plan-staged-pipeline.md: an experimental,
4
+ // standalone alternative to the bundled single-call pipeline in
5
+ // src/index.ts, built to be compared against it in Phase 3 -- not (yet)
6
+ // the production path. Nothing here is wired into the shipped MCP server.
7
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.1.1",
4
- "description": "MCP tool that judges whether a UI component need should be met with an existing shadcn/21st.dev component or requires a custom build, using field/requirement coverage scored against real component code.",
3
+ "version": "0.3.0",
4
+ "description": "MCP tool that judges whether a UI component need should be met with an existing shadcn/ui, 21st.dev, or ReUI component or requires a custom build, using field/requirement coverage scored against real component code.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {