Skip to content

Commit a145bc7

Browse files
committed
added tests
1 parent 08768db commit a145bc7

21 files changed

Lines changed: 931 additions & 172 deletions

packages/mcp/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,20 @@
1818
"author": "HashiCorp Design Systems <design-systems@hashicorp.com>",
1919
"type": "module",
2020
"scripts": {
21-
"typecheck": "pnpm tsc --noEmit",
21+
"typecheck": "pnpm tsc",
2222
"lint": "pnpm eslint --quiet .",
2323
"test": "pnpm vitest run --dir tests",
2424
"test:watch": "pnpm vitest --dir tests",
25-
"build": "pnpm tsc",
26-
"build:watch": "pnpm tsc --watch --preserveWatchOutput",
25+
"build": "pnpm tsc --project tsconfig.build.json",
26+
"build:watch": "pnpm tsc --project tsconfig.build.json --watch --preserveWatchOutput",
2727
"serve": "node ./dist/index.js",
2828
"serve:watch": "node --watch --watch-path=./dist --watch-preserve-output ./dist/index.js",
2929
"start": "pnpm build && concurrently --kill-others-on-fail --names build,server \"pnpm build:watch\" \"pnpm serve:watch\"",
3030
"inspect": "npx -y @modelcontextprotocol/inspector node --watch --watch-path=./dist --watch-preserve-output ./dist/index.js",
3131
"start:dev": "pnpm build && concurrently --kill-others-on-fail --names build,inspector \"pnpm build:watch\" \"pnpm inspect\""
3232
},
3333
"dependencies": {
34+
"@hashicorp/design-system-tokens": "workspace:^5.1.0",
3435
"@modelcontextprotocol/sdk": "^1.29.0",
3536
"zod": "^4.4.3"
3637
},
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const TOKENS_URI = "hds://tokens";
2+
export const TOKEN_BY_KEY_URI_TEMPLATE = `${TOKENS_URI}/{tokenKey}`;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { getOrLoadTokenStore } from "./store/index.js";
3+
import { toJsonResourceResponse } from "../utils.js";
4+
import { TOKENS_URI, TOKEN_BY_KEY_URI_TEMPLATE } from "./constants.js";
5+
import { toSerializableTokenSummary } from "./utils.js";
6+
7+
import type { McpResource } from "../types.js";
8+
import type { TokenCatalogStore } from "./store/index.js";
9+
import type { TokenSummary } from "./store/lookup.js";
10+
11+
const getTokenByKeyUri = (tokenKey: string): string => {
12+
return `${TOKENS_URI}/${encodeURIComponent(tokenKey)}`;
13+
};
14+
15+
const decodeTokenKey = (tokenKey: string): string => {
16+
try {
17+
return decodeURIComponent(tokenKey);
18+
} catch {
19+
return tokenKey;
20+
}
21+
};
22+
23+
export const completeTokenKeys = (
24+
tokens: TokenSummary[],
25+
value: string,
26+
limit = 100,
27+
): string[] => {
28+
const query = value.trim().toLowerCase();
29+
const matches: string[] = [];
30+
31+
for (const token of tokens) {
32+
const aliases = [token.key, token.name, token.path.join(".")];
33+
const isMatch =
34+
query.length === 0 ||
35+
aliases.some((alias) => alias.toLowerCase().includes(query));
36+
37+
if (isMatch) {
38+
matches.push(token.key);
39+
}
40+
41+
if (matches.length >= limit) {
42+
break;
43+
}
44+
}
45+
46+
return matches;
47+
};
48+
49+
export const readTokenByKeyResource = (
50+
store: TokenCatalogStore,
51+
tokenKey: string,
52+
) => {
53+
const token = store.getTokenByKey(tokenKey);
54+
55+
if (token === null) {
56+
return toJsonResourceResponse(getTokenByKeyUri(tokenKey), {
57+
found: false,
58+
requestedTokenKey: tokenKey,
59+
message: "Token not found for provided tokenKey.",
60+
});
61+
}
62+
63+
return toJsonResourceResponse(getTokenByKeyUri(tokenKey), {
64+
found: true,
65+
requestedTokenKey: tokenKey,
66+
token: {
67+
...toSerializableTokenSummary(token),
68+
...(token.original === undefined ? {} : { original: token.original }),
69+
},
70+
});
71+
};
72+
73+
export const createGetTokenByKeyResource = (
74+
getStore: () => TokenCatalogStore,
75+
): McpResource => {
76+
return {
77+
name: "get_hds_token",
78+
template: new ResourceTemplate(TOKEN_BY_KEY_URI_TEMPLATE, {
79+
list: undefined,
80+
complete: {
81+
tokenKey: (value) => completeTokenKeys(getStore().listTokens(), value),
82+
},
83+
}),
84+
config: {
85+
title: "HDS token detail",
86+
description: "Detailed token record for a specific token key",
87+
mimeType: "application/json",
88+
},
89+
readCallback: async (
90+
uri: URL,
91+
variables: Record<string, string | string[]>,
92+
) => {
93+
const tokenKey = variables.tokenKey;
94+
95+
if (typeof tokenKey !== "string" || tokenKey.trim().length === 0) {
96+
return toJsonResourceResponse(uri.toString(), {
97+
found: false,
98+
message: "Missing tokenKey variable.",
99+
});
100+
}
101+
102+
return readTokenByKeyResource(getStore(), decodeTokenKey(tokenKey));
103+
},
104+
};
105+
};
106+
107+
const getTokenByKeyResource = createGetTokenByKeyResource(getOrLoadTokenStore);
108+
109+
export default getTokenByKeyResource;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { toJsonResourceResponse } from "../utils.js";
2+
import { getOrLoadTokenStore } from "./store/index.js";
3+
import { toSerializableTokenSummary } from "./utils.js";
4+
import { TOKENS_URI } from "./constants.js";
5+
6+
import type { McpResource } from "../types.js";
7+
import type { TokenCatalogStore } from "./store/index.js";
8+
9+
export const readTokensResource = (store: TokenCatalogStore) => {
10+
const payload = {
11+
totalTokenCount: store.getMeta().totalTokenCount,
12+
tokens: store
13+
.listTokens()
14+
.map((token) => toSerializableTokenSummary(token)),
15+
};
16+
17+
return toJsonResourceResponse(TOKENS_URI, payload);
18+
};
19+
20+
export const createGetTokensResource = (
21+
getStore: () => TokenCatalogStore,
22+
): McpResource => {
23+
return {
24+
name: "get_hds_tokens",
25+
uri: TOKENS_URI,
26+
config: {
27+
title: "HDS token catalog index",
28+
description: "Canonical list of tokens with summary metadata",
29+
mimeType: "application/json",
30+
},
31+
readCallback: async () => readTokensResource(getStore()),
32+
};
33+
};
34+
35+
export const getTokensResource = createGetTokensResource(getOrLoadTokenStore);
36+
37+
export default getTokensResource;

packages/mcp/src/resources/tokens/index.ts

Lines changed: 3 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -3,160 +3,13 @@
33
* SPDX-License-Identifier: MPL-2.0
44
*/
55

6-
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
7-
import { loadTokenCatalog } from "./store/index.js";
8-
import { toJsonResourceResponse } from "../utils.js";
6+
import getTokensResource from "./get-tokens.js";
7+
import getTokenByKeyResource from "./get-token-by-key.js";
98

109
import type { McpResource } from "../types.js";
11-
import type { TokenCatalogStore } from "./store/index.js";
12-
import type { JsonObject } from "../../types.js";
13-
import type { TokenRecord, TokenSummary } from "./store/lookup.js";
14-
15-
export const TOKENS_URI = "hds://tokens";
16-
export const TOKEN_BY_KEY_URI_TEMPLATE = `${TOKENS_URI}/{tokenKey}`;
17-
18-
const getTokenByKeyUri = (tokenKey: string): string => {
19-
return `${TOKENS_URI}/${encodeURIComponent(tokenKey)}`;
20-
};
21-
22-
const decodeTokenKey = (tokenKey: string): string => {
23-
try {
24-
return decodeURIComponent(tokenKey);
25-
} catch {
26-
return tokenKey;
27-
}
28-
};
29-
30-
const toSerializableTokenSummary = (token: TokenSummary): JsonObject => {
31-
return {
32-
key: token.key,
33-
name: token.name,
34-
type: token.type,
35-
value: token.value,
36-
cssVar: token.cssVar,
37-
category: token.category,
38-
path: token.path,
39-
};
40-
};
41-
42-
const toSerializableTokenRecord = (token: TokenRecord): JsonObject => {
43-
return {
44-
...toSerializableTokenSummary(token),
45-
...(token.original === undefined ? {} : { original: token.original }),
46-
};
47-
};
48-
49-
export const readTokensResource = (store: TokenCatalogStore) => {
50-
const payload = {
51-
totalTokenCount: store.getMeta().totalTokenCount,
52-
tokens: store
53-
.listTokens()
54-
.map((token) => toSerializableTokenSummary(token)),
55-
};
56-
57-
return toJsonResourceResponse(TOKENS_URI, payload);
58-
};
59-
60-
export const readTokenByKeyResource = (
61-
store: TokenCatalogStore,
62-
tokenKey: string,
63-
) => {
64-
const token = store.getTokenByKey(tokenKey);
65-
66-
if (token === null) {
67-
return toJsonResourceResponse(getTokenByKeyUri(tokenKey), {
68-
found: false,
69-
requestedTokenKey: tokenKey,
70-
message: "Token not found for provided tokenKey.",
71-
});
72-
}
73-
74-
return toJsonResourceResponse(getTokenByKeyUri(tokenKey), {
75-
found: true,
76-
requestedTokenKey: tokenKey,
77-
token: toSerializableTokenRecord(token),
78-
});
79-
};
80-
81-
let tokenStore: TokenCatalogStore | null = null;
82-
83-
const getOrLoadTokenStore = (): TokenCatalogStore => {
84-
if (tokenStore === null) {
85-
tokenStore = loadTokenCatalog();
86-
}
87-
88-
return tokenStore;
89-
};
90-
91-
const completeTokenKey = (value: string): string[] => {
92-
const query = value.trim().toLowerCase();
93-
const matches: string[] = [];
94-
95-
for (const token of getOrLoadTokenStore().listTokens()) {
96-
const aliases = [token.key, token.name, token.path.join(".")];
97-
const isMatch =
98-
query.length === 0 ||
99-
aliases.some((alias) => alias.toLowerCase().includes(query));
100-
101-
if (isMatch) {
102-
matches.push(token.key);
103-
}
104-
105-
if (matches.length >= 100) {
106-
break;
107-
}
108-
}
109-
110-
return matches;
111-
};
112-
113-
const getTokensResource: McpResource = {
114-
name: "get_hds_tokens",
115-
uri: TOKENS_URI,
116-
config: {
117-
title: "HDS token catalog index",
118-
description: "Canonical list of tokens with summary metadata",
119-
mimeType: "application/json",
120-
},
121-
readCallback: async () => {
122-
return readTokensResource(getOrLoadTokenStore());
123-
},
124-
};
125-
126-
const getTokenByKeyResource: McpResource = {
127-
name: "get_hds_token",
128-
template: new ResourceTemplate(TOKEN_BY_KEY_URI_TEMPLATE, {
129-
list: undefined,
130-
complete: {
131-
tokenKey: completeTokenKey,
132-
},
133-
}),
134-
config: {
135-
title: "HDS token detail",
136-
description: "Detailed token record for a specific token key",
137-
mimeType: "application/json",
138-
},
139-
readCallback: async (
140-
uri: URL,
141-
variables: Record<string, string | string[]>,
142-
) => {
143-
const tokenKey = variables.tokenKey;
144-
145-
if (typeof tokenKey !== "string" || tokenKey.trim().length === 0) {
146-
return toJsonResourceResponse(uri.toString(), {
147-
found: false,
148-
message: "Missing tokenKey variable.",
149-
});
150-
}
151-
152-
return readTokenByKeyResource(
153-
getOrLoadTokenStore(),
154-
decodeTokenKey(tokenKey),
155-
);
156-
},
157-
};
15810

15911
const TOKENS_RESOURCES: McpResource[] = [
12+
// TOKENS
16013
getTokensResource,
16114
getTokenByKeyResource,
16215
];

packages/mcp/src/resources/tokens/store/index.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { tokenCatalogSchema } from "./schema.js";
1515

1616
import type { TokenRecord, TokenSummary } from "./lookup.js";
17-
import type { TokenType } from "./schema.js";
17+
import type { TokenCatalogRow, TokenType } from "./schema.js";
1818

1919
type SearchTokensInput = {
2020
query: string;
@@ -46,16 +46,22 @@ const toSearchBlob = (token: TokenSummary): string => {
4646
return [token.key, token.name, path, category, value].join(" ").toLowerCase();
4747
};
4848

49-
export const loadTokenCatalog = (): TokenCatalogStore => {
50-
const tokensPath = getTokensPath();
51-
const rawTokens = readFileSync(tokensPath, "utf8");
52-
const parsedTokens = JSON.parse(rawTokens) as unknown;
53-
const rows = tokenCatalogSchema.parse(parsedTokens);
49+
export const parseTokenCatalog = (value: unknown): TokenCatalogRow[] => {
50+
return tokenCatalogSchema.parse(value);
51+
};
52+
53+
export const createTokenCatalogStore = (
54+
rows: TokenCatalogRow[],
55+
): TokenCatalogStore => {
5456
const tokenRecords = rows.map((row) => toTokenRecord(row));
5557
const tokenLookup = new Map<string, TokenRecord>();
5658

57-
for (const row of rows) {
58-
const token = toTokenRecord(row);
59+
for (const [index, row] of rows.entries()) {
60+
const token = tokenRecords[index];
61+
62+
if (token === undefined) {
63+
continue;
64+
}
5965

6066
for (const key of getTokenLookupKeys(row)) {
6167
tokenLookup.set(key, token);
@@ -100,3 +106,21 @@ export const loadTokenCatalog = (): TokenCatalogStore => {
100106
},
101107
};
102108
};
109+
110+
export const loadTokenCatalog = (): TokenCatalogStore => {
111+
const tokensPath = getTokensPath();
112+
const rawTokens = readFileSync(tokensPath, "utf8");
113+
const parsedTokens = JSON.parse(rawTokens) as unknown;
114+
115+
return createTokenCatalogStore(parseTokenCatalog(parsedTokens));
116+
};
117+
118+
let tokenStore: TokenCatalogStore | null = null;
119+
120+
export const getOrLoadTokenStore = (): TokenCatalogStore => {
121+
if (tokenStore === null) {
122+
tokenStore = loadTokenCatalog();
123+
}
124+
125+
return tokenStore;
126+
};

0 commit comments

Comments
 (0)