@hashicorp/design-system-mcp 0.1.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/LICENSE +375 -0
- package/README.md +83 -0
- package/dist/catalog/loader.js +99 -0
- package/dist/catalog/normalize.js +7 -0
- package/dist/catalog/project-root.js +47 -0
- package/dist/index.js +107 -0
- package/dist/prompts/index.js +10 -0
- package/dist/prompts/types.js +5 -0
- package/dist/resources/completions.js +30 -0
- package/dist/resources/components/constants.js +6 -0
- package/dist/resources/components/get-component-by-name.js +54 -0
- package/dist/resources/components/get-components.js +33 -0
- package/dist/resources/components/index.js +11 -0
- package/dist/resources/components/utils.js +39 -0
- package/dist/resources/define-resource.js +35 -0
- package/dist/resources/hds-icons/constants.js +6 -0
- package/dist/resources/hds-icons/get-icon-by-name.js +48 -0
- package/dist/resources/hds-icons/get-icons.js +33 -0
- package/dist/resources/hds-icons/index.js +11 -0
- package/dist/resources/hds-icons/utils.js +26 -0
- package/dist/resources/index.js +23 -0
- package/dist/resources/responses.js +34 -0
- package/dist/resources/tokens/constants.js +2 -0
- package/dist/resources/tokens/get-token-by-key.js +51 -0
- package/dist/resources/tokens/get-tokens.js +33 -0
- package/dist/resources/tokens/index.js +11 -0
- package/dist/resources/tokens/utils.js +15 -0
- package/dist/resources/types.js +5 -0
- package/dist/resources/uri.js +15 -0
- package/dist/stores/components/index.js +114 -0
- package/dist/stores/components/lookup.js +55 -0
- package/dist/stores/components/schema.js +37 -0
- package/dist/stores/docs/constants.js +163 -0
- package/dist/stores/docs/index.js +322 -0
- package/dist/stores/docs/lookup.js +148 -0
- package/dist/stores/docs/schema.js +60 -0
- package/dist/stores/docs/search-index.js +102 -0
- package/dist/stores/docs/tokenize.js +52 -0
- package/dist/stores/hds-icons/index.js +115 -0
- package/dist/stores/hds-icons/lookup.js +43 -0
- package/dist/stores/hds-icons/schema.js +31 -0
- package/dist/stores/rank.js +129 -0
- package/dist/stores/tokens/index.js +105 -0
- package/dist/stores/tokens/lookup.js +65 -0
- package/dist/stores/tokens/schema.js +42 -0
- package/dist/stores/types.js +5 -0
- package/dist/tools/components/constants.js +15 -0
- package/dist/tools/components/get-component.js +98 -0
- package/dist/tools/components/index.js +11 -0
- package/dist/tools/components/search-components.js +75 -0
- package/dist/tools/components/utils.js +42 -0
- package/dist/tools/constants.js +7 -0
- package/dist/tools/define-tool.js +17 -0
- package/dist/tools/docs/constants.js +33 -0
- package/dist/tools/docs/index.js +8 -0
- package/dist/tools/docs/read-doc.js +101 -0
- package/dist/tools/docs/search-docs.js +143 -0
- package/dist/tools/docs/utils.js +82 -0
- package/dist/tools/hds-icons/constants.js +5 -0
- package/dist/tools/hds-icons/index.js +7 -0
- package/dist/tools/hds-icons/search-icons.js +123 -0
- package/dist/tools/index.js +19 -0
- package/dist/tools/output-schema.js +11 -0
- package/dist/tools/responses.js +52 -0
- package/dist/tools/search.js +36 -0
- package/dist/tools/tokens/constants.js +5 -0
- package/dist/tools/tokens/index.js +7 -0
- package/dist/tools/tokens/search-tokens.js +144 -0
- package/dist/tools/types.js +5 -0
- package/dist/types.js +5 -0
- package/docs-catalog.json +39778 -0
- package/package.json +62 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { defineTool } from "../define-tool.js";
|
|
7
|
+
import { toJsonToolResponse, withSafeToolHandler } from "../responses.js";
|
|
8
|
+
import { clampFilterValue, clampSearchLimit, MAX_FILTER_LENGTH, } from "../search.js";
|
|
9
|
+
import { DEFAULT_SEARCH_LIMIT, DOCS_TABS, MAX_QUERY_LENGTH, MAX_SEARCH_LIMIT, SEARCH_DOCS_TOOL_NAME, VERSION_HISTORY_TAB, } from "./constants.js";
|
|
10
|
+
import { getOrLoadDocsStore } from "../../stores/docs/index.js";
|
|
11
|
+
import { toSerializableSearchResult } from "./utils.js";
|
|
12
|
+
export const searchDocsInputShape = {
|
|
13
|
+
query: z
|
|
14
|
+
.string()
|
|
15
|
+
.min(1)
|
|
16
|
+
.describe("Natural language or keyword query, e.g. 'how do I make a button full width' or 'sticky table header'."),
|
|
17
|
+
limit: z
|
|
18
|
+
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(MAX_SEARCH_LIMIT)
|
|
22
|
+
.default(DEFAULT_SEARCH_LIMIT)
|
|
23
|
+
.describe(`Maximum results to return (1-${MAX_SEARCH_LIMIT}). Defaults to ${DEFAULT_SEARCH_LIMIT}.`),
|
|
24
|
+
section: z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(MAX_FILTER_LENGTH)
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Restrict to one top-level docs section: about, components, content, foundations, getting-started, icons, layouts, overrides, patterns, utilities, whats-new."),
|
|
30
|
+
tab: z
|
|
31
|
+
.string()
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(MAX_FILTER_LENGTH)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe(`Restrict to one page tab. The corpus uses exactly these: ${DOCS_TABS.join(", ")}. Version and changelog questions must pass tab: '${VERSION_HISTORY_TAB}', which is otherwise down-ranked.`),
|
|
36
|
+
docsPath: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.max(MAX_FILTER_LENGTH)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Restrict to one docs route or route prefix, e.g. 'components/button' or 'components/form'. This is the same value the hds://components resource reports as a component's docsPath."),
|
|
42
|
+
};
|
|
43
|
+
const searchDocsResultShape = z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
score: z.number(),
|
|
46
|
+
relScore: z.number(),
|
|
47
|
+
title: z.string(),
|
|
48
|
+
pageDescription: z.string().optional(),
|
|
49
|
+
route: z.string(),
|
|
50
|
+
docsPath: z.string(),
|
|
51
|
+
section: z.string(),
|
|
52
|
+
tab: z.string().optional(),
|
|
53
|
+
headingPath: z.array(z.string()),
|
|
54
|
+
url: z.string(),
|
|
55
|
+
pageAnchored: z.boolean(),
|
|
56
|
+
pageMatchedTerms: z.array(z.string()),
|
|
57
|
+
snippet: z.string(),
|
|
58
|
+
});
|
|
59
|
+
export const searchDocsOutputShape = {
|
|
60
|
+
query: z.string(),
|
|
61
|
+
bundledAt: z.string(),
|
|
62
|
+
siteBaseUrl: z.string(),
|
|
63
|
+
totalMatches: z.number().int(),
|
|
64
|
+
returnedMatches: z.number().int(),
|
|
65
|
+
truncated: z.boolean(),
|
|
66
|
+
matchedTerms: z.array(z.string()),
|
|
67
|
+
unmatchedTerms: z.array(z.string()),
|
|
68
|
+
filters: z.object({
|
|
69
|
+
section: z.string().optional(),
|
|
70
|
+
tab: z.string().optional(),
|
|
71
|
+
docsPath: z.string().optional(),
|
|
72
|
+
}),
|
|
73
|
+
unknownFilters: z.array(z.string()),
|
|
74
|
+
availableSections: z.array(z.string()).optional(),
|
|
75
|
+
availableTabs: z.array(z.string()).optional(),
|
|
76
|
+
results: z.array(searchDocsResultShape),
|
|
77
|
+
provenance: z.string(),
|
|
78
|
+
};
|
|
79
|
+
export const searchDocsOutputSchema = z.object(searchDocsOutputShape);
|
|
80
|
+
const DESCRIPTION = [
|
|
81
|
+
"Search the bundled Helios Design System documentation (helios.hashicorp.design) and return ranked passages with snippets.",
|
|
82
|
+
"The corpus is a snapshot committed into this server: search runs entirely locally and never makes a network request.",
|
|
83
|
+
"Every result carries the canonical URL of the passage, and the response carries the bundledAt timestamp, so a client that needs the live page can fetch it itself.",
|
|
84
|
+
"Follow up with read_hds_docs, passing a result's `id`, to read the full passage including its code examples.",
|
|
85
|
+
"Results are ranked, not filtered for truth: a non-empty result set is not evidence that Helios has the thing you asked about. Check `pageAnchored` and `unmatchedTerms`, and read the snippets.",
|
|
86
|
+
].join(" ");
|
|
87
|
+
export const searchDocs = (store, input) => {
|
|
88
|
+
const meta = store.getMeta();
|
|
89
|
+
const limit = clampSearchLimit(input.limit, MAX_SEARCH_LIMIT);
|
|
90
|
+
const query = input.query.slice(0, MAX_QUERY_LENGTH);
|
|
91
|
+
const outcome = store.search({
|
|
92
|
+
query,
|
|
93
|
+
limit,
|
|
94
|
+
...(input.section === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { section: clampFilterValue(input.section) }),
|
|
97
|
+
...(input.tab === undefined ? {} : { tab: clampFilterValue(input.tab) }),
|
|
98
|
+
...(input.docsPath === undefined
|
|
99
|
+
? {}
|
|
100
|
+
: { docsPath: clampFilterValue(input.docsPath) }),
|
|
101
|
+
});
|
|
102
|
+
const filterHelp = outcome.unknownFilters.length === 0
|
|
103
|
+
? {}
|
|
104
|
+
: {
|
|
105
|
+
availableSections: store.listSections(),
|
|
106
|
+
availableTabs: store.listTabs(),
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
query,
|
|
110
|
+
bundledAt: meta.bundledAt,
|
|
111
|
+
siteBaseUrl: meta.siteBaseUrl,
|
|
112
|
+
totalMatches: outcome.totalMatches,
|
|
113
|
+
returnedMatches: outcome.hits.length,
|
|
114
|
+
truncated: outcome.totalMatches > outcome.hits.length,
|
|
115
|
+
matchedTerms: outcome.matchedTerms,
|
|
116
|
+
unmatchedTerms: outcome.unmatchedTerms,
|
|
117
|
+
filters: outcome.filters,
|
|
118
|
+
unknownFilters: outcome.unknownFilters,
|
|
119
|
+
...filterHelp,
|
|
120
|
+
results: outcome.hits.map((hit) => toSerializableSearchResult(hit)),
|
|
121
|
+
provenance: `Bundled snapshot of ${meta.siteBaseUrl} taken at ${meta.bundledAt}; ${meta.totalPageCount} pages, ${meta.totalChunkCount} passages. Fetch a result's url for the live page.`,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
export const createSearchDocsTool = (getStore) => {
|
|
125
|
+
const executeCallback = withSafeToolHandler(SEARCH_DOCS_TOOL_NAME, (input) => toJsonToolResponse(searchDocs(getStore(), input)));
|
|
126
|
+
return defineTool({
|
|
127
|
+
name: SEARCH_DOCS_TOOL_NAME,
|
|
128
|
+
config: {
|
|
129
|
+
title: "Search HDS documentation",
|
|
130
|
+
description: DESCRIPTION,
|
|
131
|
+
inputSchema: searchDocsInputShape,
|
|
132
|
+
outputSchema: searchDocsOutputShape,
|
|
133
|
+
annotations: {
|
|
134
|
+
readOnlyHint: true,
|
|
135
|
+
idempotentHint: true,
|
|
136
|
+
openWorldHint: false,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
executeCallback,
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
const searchDocsTool = createSearchDocsTool(getOrLoadDocsStore);
|
|
143
|
+
export default searchDocsTool;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { getHeadingPath } from "../../stores/docs/lookup.js";
|
|
6
|
+
const TRUNCATION_NOTE = "\n\n[truncated: raise maxBytes to read the rest]";
|
|
7
|
+
const REPLACEMENT_CHARACTER = "�";
|
|
8
|
+
const CHUNK_SEPARATOR = "\n\n";
|
|
9
|
+
export const toSerializableSearchResult = (hit) => {
|
|
10
|
+
return {
|
|
11
|
+
id: hit.chunk.id,
|
|
12
|
+
score: hit.score,
|
|
13
|
+
relScore: hit.relScore,
|
|
14
|
+
title: hit.page.title,
|
|
15
|
+
...(hit.page.description === undefined
|
|
16
|
+
? {}
|
|
17
|
+
: { pageDescription: hit.page.description }),
|
|
18
|
+
route: hit.chunk.route,
|
|
19
|
+
docsPath: hit.chunk.route,
|
|
20
|
+
section: hit.chunk.section,
|
|
21
|
+
...(hit.chunk.tab === undefined ? {} : { tab: hit.chunk.tab }),
|
|
22
|
+
headingPath: getHeadingPath(hit.chunk),
|
|
23
|
+
url: hit.chunk.url,
|
|
24
|
+
pageAnchored: hit.pageMatchedTerms.length > 0,
|
|
25
|
+
pageMatchedTerms: hit.pageMatchedTerms,
|
|
26
|
+
snippet: hit.snippet,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
// absent rather than empty when a page carries neither link
|
|
30
|
+
export const toSerializablePageLinks = (page) => {
|
|
31
|
+
const links = {
|
|
32
|
+
...(page.links?.github === undefined ? {} : { github: page.links.github }),
|
|
33
|
+
...(page.links?.figma === undefined ? {} : { figma: page.links.figma }),
|
|
34
|
+
};
|
|
35
|
+
return Object.keys(links).length === 0 ? undefined : links;
|
|
36
|
+
};
|
|
37
|
+
export const truncateToBytes = (value, maxBytes) => {
|
|
38
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
|
|
39
|
+
return { value, truncated: false };
|
|
40
|
+
}
|
|
41
|
+
const noteBytes = Buffer.byteLength(TRUNCATION_NOTE, "utf8");
|
|
42
|
+
const budget = Math.max(maxBytes - noteBytes, 0);
|
|
43
|
+
const decoded = new TextDecoder("utf-8").decode(Buffer.from(value, "utf8").subarray(0, budget));
|
|
44
|
+
let truncatedValue = decoded;
|
|
45
|
+
while (truncatedValue.endsWith(REPLACEMENT_CHARACTER)) {
|
|
46
|
+
truncatedValue = truncatedValue.slice(0, -1);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
value: `${truncatedValue.trimEnd()}${TRUNCATION_NOTE}`,
|
|
50
|
+
truncated: true,
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
export const joinChunkContent = (chunks) => {
|
|
54
|
+
return chunks.map((chunk) => chunk.content.trim()).join(CHUNK_SEPARATOR);
|
|
55
|
+
};
|
|
56
|
+
export const selectChunkContent = (chunks, maxBytes) => {
|
|
57
|
+
const { value: content, truncated } = truncateToBytes(joinChunkContent(chunks), maxBytes);
|
|
58
|
+
if (!truncated) {
|
|
59
|
+
return {
|
|
60
|
+
content,
|
|
61
|
+
truncated,
|
|
62
|
+
includedIds: chunks.map((chunk) => chunk.id),
|
|
63
|
+
omittedIds: [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// the note is appended verbatim, so what precedes it is exactly the content that survived
|
|
67
|
+
const keptLength = content.length - TRUNCATION_NOTE.length;
|
|
68
|
+
let offset = 0;
|
|
69
|
+
let keptCount = 0;
|
|
70
|
+
for (const chunk of chunks) {
|
|
71
|
+
if (offset >= keptLength)
|
|
72
|
+
break;
|
|
73
|
+
offset += chunk.content.trim().length + CHUNK_SEPARATOR.length;
|
|
74
|
+
keptCount += 1;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
content,
|
|
78
|
+
truncated,
|
|
79
|
+
includedIds: chunks.slice(0, keptCount).map((chunk) => chunk.id),
|
|
80
|
+
omittedIds: chunks.slice(keptCount).map((chunk) => chunk.id),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CATALOG_TOOL_ANNOTATIONS, defineTool } from "../define-tool.js";
|
|
7
|
+
import catalogSourceOutputSchema from "../output-schema.js";
|
|
8
|
+
import { toJsonToolResponse, withSafeToolHandler, } from "../responses.js";
|
|
9
|
+
import { MAX_FILTER_LENGTH, clampFilterValue, clampSearchLimit, collectUnknownFilters, searchCountsOutputShape, searchLimitSchema, } from "../search.js";
|
|
10
|
+
import { DEFAULT_SEARCH_LIMIT, MAX_QUERY_LENGTH, MAX_SEARCH_LIMIT, } from "../constants.js";
|
|
11
|
+
import { SEARCH_ICONS_TOOL_NAME } from './constants.js';
|
|
12
|
+
import { getOrLoadIconStore } from "../../stores/hds-icons/index.js";
|
|
13
|
+
export const searchIconsInputShape = {
|
|
14
|
+
query: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.describe("Icon name or keyword, e.g. 'alert', 'triangle', 'arrow-right' or 'warning'. Matched as a case-insensitive substring against the icon name, its description keywords, its category, its Figma mapping and its variant file names."),
|
|
18
|
+
limit: searchLimitSchema(MAX_SEARCH_LIMIT, DEFAULT_SEARCH_LIMIT),
|
|
19
|
+
category: z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(MAX_FILTER_LENGTH)
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Restrict to one icon category, e.g. 'Status', 'Arrows', 'Interface' or 'Products'. A value the catalog does not use comes back in unknownFilters alongside the categories that exist."),
|
|
25
|
+
size: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.max(MAX_FILTER_LENGTH)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Restrict to icons available at one size, such as '16' or '24'. A value the catalog does not use comes back in unknownFilters alongside the sizes that exist."),
|
|
31
|
+
hasMapping: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Restrict to icons that do (true) or do not (false) carry a Figma component mapping. Leave unset unless you are reconciling code against the Figma library."),
|
|
35
|
+
};
|
|
36
|
+
const searchIconsResultShape = z.object({
|
|
37
|
+
// the name passed to <Hds::Icon @name= />, without a size suffix
|
|
38
|
+
iconName: z.string(),
|
|
39
|
+
description: z.string(),
|
|
40
|
+
category: z.string(),
|
|
41
|
+
sizes: z.array(z.string()),
|
|
42
|
+
hasMapping: z.boolean(),
|
|
43
|
+
});
|
|
44
|
+
export const searchIconsOutputShape = {
|
|
45
|
+
query: z.string(),
|
|
46
|
+
...searchCountsOutputShape,
|
|
47
|
+
totalIconCount: z.number().int(),
|
|
48
|
+
filters: z.object({
|
|
49
|
+
category: z.string().optional(),
|
|
50
|
+
size: z.string().optional(),
|
|
51
|
+
hasMapping: z.boolean().optional(),
|
|
52
|
+
}),
|
|
53
|
+
unknownFilters: z.array(z.string()),
|
|
54
|
+
availableCategories: z.array(z.string()).optional(),
|
|
55
|
+
availableSizes: z.array(z.string()).optional(),
|
|
56
|
+
results: z.array(searchIconsResultShape),
|
|
57
|
+
source: catalogSourceOutputSchema,
|
|
58
|
+
};
|
|
59
|
+
export const searchIconsOutputSchema = z.object(searchIconsOutputShape);
|
|
60
|
+
const DESCRIPTION = [
|
|
61
|
+
"Find HDS icons — the icon set Helios ships — by name or keyword, from the catalog inside the installed @hashicorp/flight-icons package.",
|
|
62
|
+
"Each icon's searchable text includes its description keywords, so 'warning' reaches alert-triangle even though the two share no characters.",
|
|
63
|
+
"Use it to pick the @name value for <Hds::Icon> or <Hds::Button @icon=>, and to confirm an icon exists before writing a name that would render nothing.",
|
|
64
|
+
"Use the optional size filter when you need an icon available at a particular size; otherwise pick the size at the call site.",
|
|
65
|
+
"The catalog is read from disk and never fetched. Exact and prefix matches on the icon name rank first, so an icon you named by hand comes back at the top; keyword-only matches rank last. If `truncated` is true, narrow the query or filter by category.",
|
|
66
|
+
].join(" ");
|
|
67
|
+
export const searchIcons = (store, input) => {
|
|
68
|
+
const meta = store.getMeta();
|
|
69
|
+
const limit = clampSearchLimit(input.limit, MAX_SEARCH_LIMIT);
|
|
70
|
+
const query = input.query.slice(0, MAX_QUERY_LENGTH);
|
|
71
|
+
const category = input.category === undefined ? undefined : clampFilterValue(input.category);
|
|
72
|
+
const size = input.size === undefined ? undefined : clampFilterValue(input.size);
|
|
73
|
+
const { totalMatches, hits } = store.searchIcons({
|
|
74
|
+
query,
|
|
75
|
+
limit,
|
|
76
|
+
...(category === undefined ? {} : { category }),
|
|
77
|
+
...(size === undefined ? {} : { size }),
|
|
78
|
+
...(input.hasMapping === undefined ? {} : { hasMapping: input.hasMapping }),
|
|
79
|
+
});
|
|
80
|
+
const unknownFilters = collectUnknownFilters([
|
|
81
|
+
{ name: "category", value: category, known: meta.categories },
|
|
82
|
+
{ name: "size", value: size, known: meta.sizes },
|
|
83
|
+
]);
|
|
84
|
+
const hasUnknownCategory = unknownFilters.some((filter) => filter.startsWith("category:"));
|
|
85
|
+
const hasUnknownSize = unknownFilters.some((filter) => filter.startsWith("size:"));
|
|
86
|
+
return {
|
|
87
|
+
query,
|
|
88
|
+
totalMatches,
|
|
89
|
+
returnedMatches: hits.length,
|
|
90
|
+
truncated: totalMatches > hits.length,
|
|
91
|
+
// so `totalMatches` can be read against the size of the catalog it came from
|
|
92
|
+
totalIconCount: meta.totalIconCount,
|
|
93
|
+
filters: {
|
|
94
|
+
...(category === undefined ? {} : { category }),
|
|
95
|
+
...(size === undefined ? {} : { size }),
|
|
96
|
+
...(input.hasMapping === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: { hasMapping: input.hasMapping }),
|
|
99
|
+
},
|
|
100
|
+
unknownFilters,
|
|
101
|
+
// only worth spending tokens on the valid values when the filter did not land
|
|
102
|
+
...(hasUnknownCategory ? { availableCategories: meta.categories } : {}),
|
|
103
|
+
...(hasUnknownSize ? { availableSizes: meta.sizes } : {}),
|
|
104
|
+
results: hits,
|
|
105
|
+
source: meta.source,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
export const createSearchIconsTool = (getStore) => {
|
|
109
|
+
const executeCallback = withSafeToolHandler(SEARCH_ICONS_TOOL_NAME, (input) => toJsonToolResponse(searchIcons(getStore(), input)));
|
|
110
|
+
return defineTool({
|
|
111
|
+
name: SEARCH_ICONS_TOOL_NAME,
|
|
112
|
+
config: {
|
|
113
|
+
title: "Search HDS icons",
|
|
114
|
+
description: DESCRIPTION,
|
|
115
|
+
inputSchema: searchIconsInputShape,
|
|
116
|
+
outputSchema: searchIconsOutputShape,
|
|
117
|
+
annotations: CATALOG_TOOL_ANNOTATIONS,
|
|
118
|
+
},
|
|
119
|
+
executeCallback,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
const searchIconsTool = createSearchIconsTool(getOrLoadIconStore);
|
|
123
|
+
export default searchIconsTool;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import componentsTools from "./components/index.js";
|
|
6
|
+
import docsTools from "./docs/index.js";
|
|
7
|
+
import hdsIconsTools from "./hds-icons/index.js";
|
|
8
|
+
import tokensTools from "./tokens/index.js";
|
|
9
|
+
export const TOOLS = [
|
|
10
|
+
...componentsTools,
|
|
11
|
+
...docsTools,
|
|
12
|
+
...hdsIconsTools,
|
|
13
|
+
...tokensTools,
|
|
14
|
+
];
|
|
15
|
+
export function registerTools(server) {
|
|
16
|
+
for (const tool of TOOLS) {
|
|
17
|
+
tool.register(server);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CATALOG_ANCHORS } from "../catalog/loader.js";
|
|
7
|
+
const catalogSourceOutputSchema = z.object({
|
|
8
|
+
version: z.string().nullable(),
|
|
9
|
+
resolvedVia: z.enum(CATALOG_ANCHORS),
|
|
10
|
+
});
|
|
11
|
+
export default catalogSourceOutputSchema;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
export const toJsonToolResponse = (payload) => {
|
|
6
|
+
return {
|
|
7
|
+
content: [
|
|
8
|
+
{
|
|
9
|
+
type: "text",
|
|
10
|
+
text: JSON.stringify(payload, null, 2),
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
structuredContent: payload,
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export const toDocumentToolResponse = (metadata, document) => {
|
|
17
|
+
return {
|
|
18
|
+
content: [
|
|
19
|
+
{
|
|
20
|
+
type: "text",
|
|
21
|
+
text: JSON.stringify(metadata, null, 2),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: document,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
structuredContent: metadata,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
// this adds the stderr diagnostic and a message naming the tool, so an unreadable
|
|
32
|
+
// catalog degrades to one failed call rather than taking the server down
|
|
33
|
+
export const withSafeToolHandler = (toolName, handler) => {
|
|
34
|
+
return async (...args) => {
|
|
35
|
+
try {
|
|
36
|
+
return await handler(...args);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error(`Tool handler failed (${toolName}):`, error);
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: `${toolName} failed: ${message}`,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { normalizeLookupValue } from "../catalog/normalize.js";
|
|
7
|
+
export const MAX_FILTER_LENGTH = 256;
|
|
8
|
+
export const clampFilterValue = (value) => {
|
|
9
|
+
return value.slice(0, MAX_FILTER_LENGTH);
|
|
10
|
+
};
|
|
11
|
+
export const clampSearchLimit = (value, maxLimit) => {
|
|
12
|
+
return Math.min(Math.max(Math.trunc(value), 1), maxLimit);
|
|
13
|
+
};
|
|
14
|
+
export const searchLimitSchema = (maxLimit, defaultLimit) => {
|
|
15
|
+
return z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(maxLimit)
|
|
20
|
+
.default(defaultLimit)
|
|
21
|
+
.describe(`Maximum results to return (1-${maxLimit}). Defaults to ${defaultLimit}.`);
|
|
22
|
+
};
|
|
23
|
+
export const searchCountsOutputShape = {
|
|
24
|
+
totalMatches: z.number().int(),
|
|
25
|
+
returnedMatches: z.number().int(),
|
|
26
|
+
truncated: z.boolean(),
|
|
27
|
+
};
|
|
28
|
+
export const collectUnknownFilters = (filters) => {
|
|
29
|
+
return filters.flatMap(({ name, value, known }) => {
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
return [];
|
|
32
|
+
const normalized = normalizeLookupValue(value);
|
|
33
|
+
const isKnown = known.some((candidate) => normalizeLookupValue(candidate) === normalized);
|
|
34
|
+
return isKnown ? [] : [`${name}: ${value}`];
|
|
35
|
+
});
|
|
36
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SEARCH_TOKENS_TOOL_NAME } from './constants.js';
|
|
7
|
+
import { CATALOG_TOOL_ANNOTATIONS, defineTool } from "../define-tool.js";
|
|
8
|
+
import catalogSourceOutputSchema from "../output-schema.js";
|
|
9
|
+
import { toJsonToolResponse, withSafeToolHandler, } from "../responses.js";
|
|
10
|
+
import { MAX_FILTER_LENGTH, clampFilterValue, clampSearchLimit, collectUnknownFilters, searchCountsOutputShape, searchLimitSchema, } from "../search.js";
|
|
11
|
+
import { DEFAULT_SEARCH_LIMIT, MAX_QUERY_LENGTH, MAX_SEARCH_LIMIT, } from "../constants.js";
|
|
12
|
+
import { getOrLoadTokenStore } from "../../stores/tokens/index.js";
|
|
13
|
+
import { TOKEN_TYPES } from "../../stores/tokens/schema.js";
|
|
14
|
+
export const searchTokensInputShape = {
|
|
15
|
+
query: z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.describe("Token name fragment or value, e.g. 'foreground-action', 'border radius', 'blue-200' or '#1060ff'. Matched as a case-insensitive substring against the token key, its CSS variable name, its dotted path and its value."),
|
|
19
|
+
limit: searchLimitSchema(MAX_SEARCH_LIMIT, DEFAULT_SEARCH_LIMIT),
|
|
20
|
+
type: z
|
|
21
|
+
.enum(TOKEN_TYPES)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Restrict to one token type. 'color' and 'dimension' are the two that carry most of the catalog."),
|
|
24
|
+
category: z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(MAX_FILTER_LENGTH)
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Restrict to one token category, e.g. 'color', 'border' or 'typography'. A value the catalog does not use comes back in unknownFilters alongside the categories that exist."),
|
|
30
|
+
};
|
|
31
|
+
const searchTokensResultShape = z.object({
|
|
32
|
+
// the catalog key, in braces: {color.foreground.action}
|
|
33
|
+
key: z.string(),
|
|
34
|
+
name: z.string(),
|
|
35
|
+
// the name to use in CSS: var(--token-color-foreground-action)
|
|
36
|
+
cssVar: z.string(),
|
|
37
|
+
type: z.enum(TOKEN_TYPES),
|
|
38
|
+
// a string for most tokens, an array for the font-family ones
|
|
39
|
+
value: z.json(),
|
|
40
|
+
category: z.string(),
|
|
41
|
+
comment: z.string().optional(),
|
|
42
|
+
// the unresolved semantic reference, e.g. {color.palette.blue-200}
|
|
43
|
+
alias: z.string().optional(),
|
|
44
|
+
});
|
|
45
|
+
export const searchTokensOutputShape = {
|
|
46
|
+
query: z.string(),
|
|
47
|
+
...searchCountsOutputShape,
|
|
48
|
+
totalTokenCount: z.number().int(),
|
|
49
|
+
filters: z.object({
|
|
50
|
+
type: z.enum(TOKEN_TYPES).optional(),
|
|
51
|
+
category: z.string().optional(),
|
|
52
|
+
}),
|
|
53
|
+
unknownFilters: z.array(z.string()),
|
|
54
|
+
availableCategories: z.array(z.string()).optional(),
|
|
55
|
+
results: z.array(searchTokensResultShape),
|
|
56
|
+
source: catalogSourceOutputSchema,
|
|
57
|
+
};
|
|
58
|
+
// the payload type is derived from the declared schema, so a drift between the two is a
|
|
59
|
+
// compile error rather than a runtime output-validation failure
|
|
60
|
+
export const searchTokensOutputSchema = z.object(searchTokensOutputShape);
|
|
61
|
+
const DESCRIPTION = [
|
|
62
|
+
"Find Helios design tokens by name or value, from the token catalog inside the installed @hashicorp/design-system-tokens package.",
|
|
63
|
+
"Every result carries the cssVar to write in a stylesheet — var(--token-color-foreground-action) — so use this to replace a hard-coded color, spacing or radius with the token that holds it.",
|
|
64
|
+
"The query matches the token value too, so searching a hex code finds which token already defines it.",
|
|
65
|
+
"The catalog is read from disk and never fetched. Exact and prefix matches on the token key, name or CSS variable rank first, so a token you named by hand comes back at the top; value-only matches rank last. If `truncated` is true, narrow the query or filter by type.",
|
|
66
|
+
].join(" ");
|
|
67
|
+
export const searchTokens = (store, input) => {
|
|
68
|
+
const meta = store.getMeta();
|
|
69
|
+
const limit = clampSearchLimit(input.limit, MAX_SEARCH_LIMIT);
|
|
70
|
+
// both the query and the category are echoed back, so they are bounded before they are
|
|
71
|
+
// repeated rather than only at the declared schema; `type` is closed over TOKEN_TYPES
|
|
72
|
+
const query = input.query.slice(0, MAX_QUERY_LENGTH);
|
|
73
|
+
const category = input.category === undefined ? undefined : clampFilterValue(input.category);
|
|
74
|
+
const { totalMatches, hits } = store.searchTokens({
|
|
75
|
+
query,
|
|
76
|
+
limit,
|
|
77
|
+
...(input.type === undefined ? {} : { type: input.type }),
|
|
78
|
+
...(category === undefined ? {} : { category }),
|
|
79
|
+
});
|
|
80
|
+
const unknownFilters = collectUnknownFilters([
|
|
81
|
+
{ name: "category", value: category, known: meta.categories },
|
|
82
|
+
]);
|
|
83
|
+
return {
|
|
84
|
+
query,
|
|
85
|
+
totalMatches,
|
|
86
|
+
returnedMatches: hits.length,
|
|
87
|
+
truncated: totalMatches > hits.length,
|
|
88
|
+
// so `totalMatches` can be read against the size of the catalog it came from
|
|
89
|
+
totalTokenCount: meta.totalTokenCount,
|
|
90
|
+
filters: {
|
|
91
|
+
...(input.type === undefined ? {} : { type: input.type }),
|
|
92
|
+
...(category === undefined ? {} : { category }),
|
|
93
|
+
},
|
|
94
|
+
unknownFilters,
|
|
95
|
+
// only worth spending tokens on the valid values when the filter did not land
|
|
96
|
+
...(unknownFilters.length === 0
|
|
97
|
+
? {}
|
|
98
|
+
: { availableCategories: meta.categories }),
|
|
99
|
+
/**
|
|
100
|
+
* Two fields of the store summary are dropped rather than forwarded. `attributes` is the
|
|
101
|
+
* raw catalog record's own metadata and repeats `category` verbatim. `path` is `key` with
|
|
102
|
+
* its braces stripped and split on dots, which cost 24% of the payload to say nothing the
|
|
103
|
+
* key does not already say — and a bounded payload is the whole reason these tools exist.
|
|
104
|
+
*/
|
|
105
|
+
results: hits.map((token) => ({
|
|
106
|
+
key: token.key,
|
|
107
|
+
name: token.name,
|
|
108
|
+
cssVar: token.cssVar,
|
|
109
|
+
type: token.type,
|
|
110
|
+
value: toResultValue(token.value),
|
|
111
|
+
category: token.category,
|
|
112
|
+
...(token.comment === undefined ? {} : { comment: token.comment }),
|
|
113
|
+
...(token.alias === undefined ? {} : { alias: token.alias }),
|
|
114
|
+
})),
|
|
115
|
+
source: meta.source,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Thirteen tokens carry an inlined SVG data url, up to 528 characters each, which is enough to
|
|
120
|
+
* make a full result four times the size of the same search over any other category. The head of
|
|
121
|
+
* one still identifies it; nobody reconstructs an icon from a search result.
|
|
122
|
+
*/
|
|
123
|
+
const MAX_VALUE_LENGTH = 120;
|
|
124
|
+
const toResultValue = (value) => {
|
|
125
|
+
if (typeof value !== "string" || value.length <= MAX_VALUE_LENGTH)
|
|
126
|
+
return value;
|
|
127
|
+
return `${value.slice(0, MAX_VALUE_LENGTH)}…`;
|
|
128
|
+
};
|
|
129
|
+
export const createSearchTokensTool = (getStore) => {
|
|
130
|
+
const executeCallback = withSafeToolHandler(SEARCH_TOKENS_TOOL_NAME, (input) => toJsonToolResponse(searchTokens(getStore(), input)));
|
|
131
|
+
return defineTool({
|
|
132
|
+
name: SEARCH_TOKENS_TOOL_NAME,
|
|
133
|
+
config: {
|
|
134
|
+
title: "Search HDS design tokens",
|
|
135
|
+
description: DESCRIPTION,
|
|
136
|
+
inputSchema: searchTokensInputShape,
|
|
137
|
+
outputSchema: searchTokensOutputShape,
|
|
138
|
+
annotations: CATALOG_TOOL_ANNOTATIONS,
|
|
139
|
+
},
|
|
140
|
+
executeCallback,
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
const searchTokensTool = createSearchTokensTool(getOrLoadTokenStore);
|
|
144
|
+
export default searchTokensTool;
|