@aphrody/m3-mcp 0.0.0-stage → 3.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # @aphrody/m3-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The design/UI MCP server of aphrody-m3 (stdio): packages, M3 tokens, docs, research links,
4
+ templates, Material Symbols and desktop icons, served from this repository's files.
5
+
6
+ ```sh
7
+ bun run start # bun src/server.ts (stdio transport)
8
+ ```
9
+
10
+ | Tool | Does |
11
+ | --- | --- |
12
+ | `m3_list_packages` | workspace packages with versions |
13
+ | `m3_token_lookup`, `m3_token_validate` | look up and validate M3 tokens |
14
+ | `m3_os_themes_list`, `m3_os_themes_generate` | OS theme targets of `@aphrody/m3-os-themes` (dry run) |
15
+ | `m3_docs_search`, `m3_research_links` | search `docs/`, list research links |
16
+ | `m3_list_templates` | `templates/` |
17
+ | `m3_symbol_svg`, `m3_symbol_search` | Material Symbols SVG and catalogue search (`@aphrody/material-design-icons`) |
18
+ | `m3_svg_to_ico` | SVG to Tauri desktop icons |
19
+
20
+ Sources: `src/server.ts` (tool registration), `src/catalog.ts` (file-backed lookups). Tests:
21
+ `bun test`; typecheck: `bun run typecheck`. Registered in the aphrody-labs plugins marketplace as
22
+ the `aphrody-m3` plugin.
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@aphrody/m3-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.3.4",
4
+ "description": "Single design/UI MCP server of aphrody-m3: packages, M3 tokens (lookup, validate), docs search, research links, templates, Material Symbols SVG, SVG to Tauri icons.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/server.ts"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "scripts": {
13
+ "start": "bun src/server.ts",
14
+ "test": "bun test",
15
+ "typecheck": "tsc --noEmit -p tsconfig.json"
16
+ },
17
+ "dependencies": {
18
+ "@aphrody/m3-os-themes": "3.3.4",
19
+ "@aphrody/material-design-icons": "1.0.0",
20
+ "@modelcontextprotocol/sdk": "^1.31.0",
21
+ "zod": "^4.6.5"
22
+ },
23
+ "engines": {
24
+ "bun": ">=1.4.2"
25
+ }
26
+ }
@@ -0,0 +1,151 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { loadTokens, lookupTokens, validateTokens } from "./catalog.ts";
7
+
8
+ const scratch = mkdtempSync(join(tmpdir(), "m3-token-catalog-"));
9
+ let fixtures = 0;
10
+ afterAll(() => rmSync(scratch, { recursive: true, force: true }));
11
+
12
+ function fixture(exports: Record<string, unknown>, files: Record<string, string>): string {
13
+ const root = join(scratch, String(fixtures++));
14
+ const pkg = join(root, "packages/m3-tokens");
15
+ mkdirSync(pkg, { recursive: true });
16
+ writeFileSync(join(pkg, "package.json"), JSON.stringify({ exports }));
17
+ for (const [path, text] of Object.entries(files)) {
18
+ const file = join(pkg, path);
19
+ mkdirSync(dirname(file), { recursive: true });
20
+ writeFileSync(file, text);
21
+ }
22
+ return root;
23
+ }
24
+
25
+ describe("the repository token catalogue", () => {
26
+ test("Expressive typography, motion and directional shapes can be looked up and validated", () => {
27
+ const names = [
28
+ "--md-sys-typescale-display-large-emphasized-font",
29
+ "--md-sys-motion-spring-expressive-fast-spatial-damping",
30
+ "--md-sys-shape-corner-large-start",
31
+ ];
32
+ for (const name of names) {
33
+ const tokens = lookupTokens(name);
34
+ expect(tokens).toHaveLength(1);
35
+ expect(tokens[0]?.source).toBe("packages/m3-tokens/src/expressive.css");
36
+ }
37
+ expect(lookupTokens(names[1]!)[0]?.value).toBe("0.6");
38
+ const input = names.map((name) => `var(${name})`).join(" ");
39
+ expect(validateTokens(input)).toEqual({ checked: names, unknown: [] });
40
+ expect(
41
+ validateTokens(`${input} var(--md-sys-motion-spring-expressive-fast-spatial-dampng)`).unknown,
42
+ ).toEqual(["--md-sys-motion-spring-expressive-fast-spatial-dampng"]);
43
+ });
44
+
45
+ test("base tokens, breakpoints and dynamic colour roles remain available without duplicates", () => {
46
+ expect(lookupTokens("corner-full")[0]?.value).toBe("9999px");
47
+ expect(lookupTokens("--md-breakpoint-expanded")[0]?.value).toBe("840px");
48
+ expect(lookupTokens("--md-sys-color-on-primary-container")[0]?.source).toBe(
49
+ "packages/m3-tokens/src/dynamic-color.ts",
50
+ );
51
+ const tokens = loadTokens();
52
+ expect(new Set(tokens.map((token) => token.name)).size).toBe(tokens.length);
53
+ });
54
+ });
55
+
56
+ describe("exported token sources", () => {
57
+ test("reads exported CSS rather than unrelated files and retains the first declaration's source", () => {
58
+ const root = fixture(
59
+ {
60
+ "./base.css": { default: "./src/base.css" },
61
+ "./alias.css": "./src/base.css",
62
+ "./extended.css": ["./src/extended.css"],
63
+ "./helpers": "./src/helpers.ts",
64
+ },
65
+ {
66
+ "src/base.css": `/* --md-commented-out: nope; */
67
+ :root { --md-sys-shape-corner-large: 16px; --md-sys-color-primary: #6750a4; }
68
+ .dark { --md-sys-shape-corner-large: 20px; }`,
69
+ "src/extended.css":
70
+ ":root { --md-sys-shape-corner-large: 32px; --md-sys-shape-corner-large-start: 16px 0 0 16px; }",
71
+ "src/unexported.css": ":root { --md-unexported: hidden; }",
72
+ "src/helpers.ts": "// --md-not-css: hidden;",
73
+ "src/dynamic-color.ts": `export const ROLES = ["primary", 'surfaceBright', "primary", /* "hiddenRole" */];`,
74
+ },
75
+ );
76
+ const tokens = loadTokens(root);
77
+ expect(tokens).toHaveLength(4);
78
+ expect(lookupTokens("--md-sys-shape-corner-large", 1, root)).toEqual([
79
+ {
80
+ name: "--md-sys-shape-corner-large",
81
+ value: "16px",
82
+ source: "packages/m3-tokens/src/base.css",
83
+ },
84
+ ]);
85
+ expect(lookupTokens("--md-sys-color-primary", 50, root)).toEqual([
86
+ {
87
+ name: "--md-sys-color-primary",
88
+ value: "#6750a4",
89
+ source: "packages/m3-tokens/src/base.css",
90
+ },
91
+ ]);
92
+ expect(lookupTokens("surface-bright", 50, root)).toEqual([
93
+ {
94
+ name: "--md-sys-color-surface-bright",
95
+ value: "(dynamic colour role)",
96
+ source: "packages/m3-tokens/src/dynamic-color.ts",
97
+ },
98
+ ]);
99
+ expect(
100
+ validateTokens(
101
+ "var(--md-sys-shape-corner-large-start) var(--md-sys-color-surface-bright)",
102
+ root,
103
+ ).unknown,
104
+ ).toEqual([]);
105
+ expect(
106
+ validateTokens(
107
+ "--md-unexported --md-commented-out --md-not-css --md-sys-color-hidden-role",
108
+ root,
109
+ ).unknown,
110
+ ).toEqual([
111
+ "--md-unexported",
112
+ "--md-commented-out",
113
+ "--md-not-css",
114
+ "--md-sys-color-hidden-role",
115
+ ]);
116
+ });
117
+
118
+ test("missing exported sheets do not hide present sheets or dynamic roles", () => {
119
+ const root = fixture(
120
+ {
121
+ "./missing.css": "./src/missing.css",
122
+ "./present.css": "./src/present.css",
123
+ "./outside.css": "../outside.css",
124
+ },
125
+ {
126
+ "src/present.css": ":root { --md-present: 1px; }",
127
+ "src/dynamic-color.ts": 'export const ROLES = ["onPrimaryContainer"];',
128
+ },
129
+ );
130
+ expect(loadTokens(root).map((token) => token.name)).toEqual([
131
+ "--md-present",
132
+ "--md-sys-color-on-primary-container",
133
+ ]);
134
+ expect(
135
+ validateTokens("--md-present --md-sys-color-on-primary-container --md-missing", root),
136
+ ).toEqual({
137
+ checked: ["--md-present", "--md-sys-color-on-primary-container", "--md-missing"],
138
+ unknown: ["--md-missing"],
139
+ });
140
+ });
141
+
142
+ test("an absent token checkout gives an empty catalogue and reports unknown references", () => {
143
+ const root = join(scratch, "absent");
144
+ expect(loadTokens(root)).toEqual([]);
145
+ expect(lookupTokens("primary", 50, root)).toEqual([]);
146
+ expect(validateTokens("var(--md-sys-color-primary)", root)).toEqual({
147
+ checked: ["--md-sys-color-primary"],
148
+ unknown: ["--md-sys-color-primary"],
149
+ });
150
+ });
151
+ });
package/src/catalog.ts ADDED
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ export const ROOT = join(import.meta.dir, "../../..");
6
+
7
+ const read = (p: string) => (existsSync(p) ? readFileSync(p, "utf8") : "");
8
+
9
+ export function listPackages(root = ROOT): string[] {
10
+ const dir = join(root, "packages");
11
+ return readdirSync(dir).flatMap((d) => {
12
+ const p = join(dir, d, "package.json");
13
+ if (!existsSync(p)) return [];
14
+ const j = JSON.parse(readFileSync(p, "utf8"));
15
+ return [`${j.name}@${j.version ?? "0.0.0"} packages/${d}`];
16
+ });
17
+ }
18
+
19
+ export interface Token {
20
+ name: string;
21
+ value: string;
22
+ source: string;
23
+ }
24
+
25
+ const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
26
+
27
+ /** Local CSS targets of the token package's exports, including conditional exports and aliases. */
28
+ function exportedSheets(exports: unknown): string[] {
29
+ if (typeof exports === "string")
30
+ return exports.startsWith("./") && exports.endsWith(".css") && !exports.includes("*")
31
+ ? [exports]
32
+ : [];
33
+ if (Array.isArray(exports)) return exports.flatMap(exportedSheets);
34
+ if (exports && typeof exports === "object") return Object.values(exports).flatMap(exportedSheets);
35
+ return [];
36
+ }
37
+
38
+ /** Every exported `--md-*` CSS property and dynamic colour role, once per name. */
39
+ export function loadTokens(root = ROOT): Token[] {
40
+ const pkg = join(root, "packages/m3-tokens");
41
+ const manifest = read(join(pkg, "package.json"));
42
+ const exports: unknown = manifest
43
+ ? (JSON.parse(manifest) as { exports?: unknown }).exports
44
+ : undefined;
45
+ const out = new Map<string, Token>();
46
+ const add = (token: Token) => {
47
+ // The first declaration is representative; theme/direction variants do not create new names.
48
+ if (!out.has(token.name)) out.set(token.name, token);
49
+ };
50
+ for (const target of new Set(exportedSheets(exports))) {
51
+ const file = resolve(pkg, target);
52
+ const path = relative(pkg, file).replaceAll("\\", "/");
53
+ if (
54
+ isAbsolute(path) ||
55
+ path === ".." ||
56
+ path.startsWith("../") ||
57
+ path.split("/").includes("node_modules")
58
+ )
59
+ continue;
60
+ const css = read(file).replace(/\/\*[\s\S]*?\*\//g, "");
61
+ for (const m of css.matchAll(/(--md-[\w-]+)\s*:\s*([^;]+);/g)) {
62
+ add({ name: m[1]!, value: m[2]!.trim(), source: `packages/m3-tokens/${path}` });
63
+ }
64
+ }
65
+ const dyn = read(join(pkg, "src/dynamic-color.ts"));
66
+ const roles = (/export const ROLES = \[([\s\S]*?)\]/.exec(dyn)?.[1] ?? "").replace(
67
+ /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,
68
+ "",
69
+ );
70
+ for (const m of roles.matchAll(/["'](\w+)["']/g)) {
71
+ add({
72
+ name: `--md-sys-color-${kebab(m[1]!)}`,
73
+ value: "(dynamic colour role)",
74
+ source: "packages/m3-tokens/src/dynamic-color.ts",
75
+ });
76
+ }
77
+ return [...out.values()];
78
+ }
79
+
80
+ export function lookupTokens(query: string, limit = 50, root = ROOT): Token[] {
81
+ const q = query.toLowerCase();
82
+ return loadTokens(root)
83
+ .filter((t) => t.name.includes(q) || t.value.toLowerCase().includes(q))
84
+ .slice(0, limit);
85
+ }
86
+
87
+ /** Validate token names or every `var(--md-*)` / `--md-*` reference found in a CSS snippet. */
88
+ export function validateTokens(
89
+ input: string,
90
+ root = ROOT,
91
+ ): { checked: string[]; unknown: string[] } {
92
+ const known = new Set(loadTokens(root).map((t) => t.name));
93
+ const checked = [...new Set([...input.matchAll(/--md-[\w-]+/g)].map((m) => m[0]))];
94
+ return { checked, unknown: checked.filter((n) => !known.has(n)) };
95
+ }
96
+
97
+ function* walk(dir: string): Generator<string> {
98
+ for (const e of readdirSync(dir)) {
99
+ if (e === "node_modules" || e.startsWith(".git")) continue;
100
+ const p = join(dir, e);
101
+ if (statSync(p).isDirectory()) yield* walk(p);
102
+ else if (/\.(md|mdx)$/.test(e)) yield p;
103
+ }
104
+ }
105
+
106
+ export function searchDocs(query: string, limit = 20, root = ROOT): string[] {
107
+ const q = query.toLowerCase();
108
+ const hits: string[] = [];
109
+ const docs = join(root, "docs");
110
+ if (!existsSync(docs)) return hits;
111
+ for (const f of walk(docs)) {
112
+ const lines = read(f).split("\n");
113
+ for (let i = 0; i < lines.length && hits.length < limit; i++) {
114
+ if (lines[i]!.toLowerCase().includes(q))
115
+ hits.push(`${relative(root, f)}:${i + 1}: ${lines[i]!.trim().slice(0, 200)}`);
116
+ }
117
+ if (hits.length >= limit) break;
118
+ }
119
+ return hits;
120
+ }
121
+
122
+ export function researchLinks(root = ROOT): { files: string[]; links: string[] } {
123
+ const dir = join(root, "docs/research");
124
+ const files = existsSync(dir) ? [...walk(dir)].map((f) => relative(root, f)) : [];
125
+ const links = [
126
+ ...read(join(dir, "GOOGLE-LINKS.md")).matchAll(/^\|\s*([^|]+?)\s*\|\s*(https?:\/\/\S+)\s*\|/gm),
127
+ ].map((m) => `${m[1]} ${m[2]}`);
128
+ return { files, links };
129
+ }
130
+
131
+ export function listTemplates(root = ROOT): string[] {
132
+ const rows: string[] = [];
133
+ for (const base of ["templates", "examples", "examples/tauri"]) {
134
+ const dir = join(root, base);
135
+ if (!existsSync(dir)) continue;
136
+ for (const e of readdirSync(dir)) {
137
+ if (statSync(join(dir, e)).isDirectory() && !(base === "examples" && e === "tauri"))
138
+ rows.push(`${base}/${e}`);
139
+ }
140
+ }
141
+ return rows;
142
+ }
package/src/server.ts ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { z } from "zod";
7
+ import type { Target } from "@aphrody/m3-os-themes";
8
+ import {
9
+ ROOT,
10
+ listPackages,
11
+ listTemplates,
12
+ lookupTokens,
13
+ researchLinks,
14
+ searchDocs,
15
+ validateTokens,
16
+ } from "./catalog";
17
+
18
+ // The theme generators (16 ms with m3-tokens) and the symbol catalogue are loaded by the tools that use them, not at
19
+ // start-up: the handshake with the client no longer waits for them.
20
+ const osThemes = () => import("@aphrody/m3-os-themes");
21
+ const symbols = () => import("@aphrody/material-design-icons");
22
+
23
+ const text = (t: string) => ({ content: [{ type: "text" as const, text: t || "(no result)" }] });
24
+
25
+ /** Construct the design server without connecting or taking ownership of stdin. */
26
+ export function createM3McpServer(): McpServer {
27
+ const server = new McpServer({ name: "aphrody-m3", version: "1.1.0" });
28
+
29
+ server.tool(
30
+ "m3_list_packages",
31
+ "List the aphrody-m3 workspace packages with versions.",
32
+ {},
33
+ async () => text(listPackages().join("\n")),
34
+ );
35
+
36
+ server.tool(
37
+ "m3_token_lookup",
38
+ "Look up Material 3 design tokens (--md-*) by name or value fragment in m3-tokens.",
39
+ { query: z.string(), limit: z.number().int().min(1).max(500).default(50) },
40
+ async ({ query, limit }) =>
41
+ text(
42
+ lookupTokens(query, limit)
43
+ .map((t) => `${t.name}: ${t.value} [${t.source}]`)
44
+ .join("\n"),
45
+ ),
46
+ );
47
+
48
+ server.tool(
49
+ "m3_token_validate",
50
+ "Validate that every --md-* custom property in a CSS snippet or name list exists in m3-tokens.",
51
+ { css: z.string() },
52
+ async ({ css }) => {
53
+ const r = validateTokens(css);
54
+ return text(
55
+ `checked ${r.checked.length}, unknown ${r.unknown.length}${r.unknown.length ? `\n${r.unknown.join("\n")}` : ""}`,
56
+ );
57
+ },
58
+ );
59
+
60
+ const themeInput = {
61
+ seed: z.string().describe("Seed colour #rrggbb"),
62
+ target: z.enum(["windows", "gnome", "cosmic", "chromium", "all"]).default("all"),
63
+ name: z.string().default("Aphrody"),
64
+ };
65
+
66
+ server.tool(
67
+ "m3_os_themes_list",
68
+ "List the files m3-os-themes would generate for a seed colour and target (nothing is written).",
69
+ themeInput,
70
+ async ({ seed, target, name }) => {
71
+ const { TARGETS, generate } = await osThemes();
72
+ const targets: readonly Target[] = target === "all" ? TARGETS : [target];
73
+ return text(
74
+ generate({ name, seed, mode: "auto" }, targets)
75
+ .map((f) => f.path)
76
+ .join("\n"),
77
+ );
78
+ },
79
+ );
80
+
81
+ server.tool(
82
+ "m3_os_themes_generate",
83
+ "Return the text content of one generated OS theme file (path from m3_os_themes_list). Never writes to disk or the system.",
84
+ { ...themeInput, path: z.string() },
85
+ async ({ seed, target, name, path }) => {
86
+ const { TARGETS, generate } = await osThemes();
87
+ const targets: readonly Target[] = target === "all" ? TARGETS : [target];
88
+ const file = generate({ name, seed, mode: "auto" }, targets).find((f) => f.path === path);
89
+ if (!file) return text(`unknown path ${path}`);
90
+ return text(
91
+ typeof file.content === "string" ? file.content : `(binary, ${file.content.length} bytes)`,
92
+ );
93
+ },
94
+ );
95
+
96
+ server.tool(
97
+ "m3_docs_search",
98
+ "Case-insensitive line search over aphrody-m3 docs/ (md, mdx); returns path:line hits.",
99
+ { query: z.string(), limit: z.number().int().min(1).max(500).default(20) },
100
+ async ({ query, limit }) => text(searchDocs(query, limit).join("\n")),
101
+ );
102
+
103
+ server.tool(
104
+ "m3_research_links",
105
+ "List docs/research files and the curated dated Google design/research link index.",
106
+ {},
107
+ async () => {
108
+ const r = researchLinks();
109
+ return text(`${r.files.join("\n")}\n\n${r.links.join("\n")}`);
110
+ },
111
+ );
112
+
113
+ server.tool(
114
+ "m3_list_templates",
115
+ "List UI templates, examples and Tauri example apps.",
116
+ {},
117
+ async () => text(listTemplates().join("\n")),
118
+ );
119
+
120
+ server.tool(
121
+ "m3_symbol_svg",
122
+ "Return path and SVG source of a Material Symbol from the forked catalogue.",
123
+ {
124
+ name: z.string().regex(/^[a-z0-9_]+$/),
125
+ style: z.enum(["outlined", "rounded", "sharp"]).default("outlined"),
126
+ weight: z
127
+ .union([
128
+ z.literal(100),
129
+ z.literal(200),
130
+ z.literal(300),
131
+ z.literal(400),
132
+ z.literal(500),
133
+ z.literal(600),
134
+ z.literal(700),
135
+ ])
136
+ .default(400),
137
+ filled: z.boolean().default(false),
138
+ },
139
+ async ({ name, style, weight, filled }) => {
140
+ const { symbolSvgPath } = await symbols();
141
+ const p = symbolSvgPath(name, style, weight, filled);
142
+ return existsSync(p) ? text(`${p}\n${readFileSync(p, "utf8")}`) : text(`not found: ${p}`);
143
+ },
144
+ );
145
+
146
+ server.tool(
147
+ "m3_symbol_search",
148
+ "Search the full Material Symbols catalogue (4150 names) by substring; returns matching symbol names.",
149
+ { query: z.string(), limit: z.number().int().min(1).max(500).default(50) },
150
+ async ({ query, limit }) => {
151
+ const { searchSymbols, symbolNames } = await symbols();
152
+ const hits = searchSymbols(query, limit);
153
+ return text(
154
+ hits.length > 0 ? hits.join("\n") : `no match among ${symbolNames().length} symbols`,
155
+ );
156
+ },
157
+ );
158
+
159
+ server.tool(
160
+ "m3_svg_to_ico",
161
+ "Generate Tauri desktop icons (ico, icns, png) from a symbol name or SVG file.",
162
+ {
163
+ input: z.string(),
164
+ out: z.string().default("src-tauri/icons"),
165
+ style: z.enum(["outlined", "rounded", "sharp"]).default("outlined"),
166
+ fill: z.string().optional(),
167
+ },
168
+ async ({ input, out, style, fill }, { signal }) => {
169
+ const args = [
170
+ "bun",
171
+ join(ROOT, "packages/material-design-icons/scripts/svg-to-ico.ts"),
172
+ input,
173
+ "--out",
174
+ out,
175
+ "--style",
176
+ style,
177
+ ];
178
+ if (fill) args.push("--fill", fill);
179
+ const child = Bun.spawn({
180
+ cmd: args,
181
+ stdout: "pipe",
182
+ stderr: "pipe",
183
+ signal,
184
+ timeout: 30_000,
185
+ killSignal: "SIGKILL",
186
+ maxBuffer: 1024 * 1024,
187
+ });
188
+ const [code, stdout, stderr] = await Promise.all([
189
+ child.exited,
190
+ new Response(child.stdout).text(),
191
+ new Response(child.stderr).text(),
192
+ ]);
193
+ return { ...text(`${code}\n${stdout}${stderr}`), ...(code !== 0 ? { isError: true } : {}) };
194
+ },
195
+ );
196
+
197
+ return server;
198
+ }
199
+
200
+ if (import.meta.main) {
201
+ void createM3McpServer()
202
+ .connect(new StdioServerTransport())
203
+ .catch((error: unknown) => {
204
+ console.error(error);
205
+ process.exitCode = 1;
206
+ });
207
+ }
@@ -0,0 +1,26 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ listPackages,
4
+ listTemplates,
5
+ lookupTokens,
6
+ researchLinks,
7
+ searchDocs,
8
+ validateTokens,
9
+ } from "../src/catalog";
10
+
11
+ test("packages include m3-mcp", () => {
12
+ const p = listPackages().join("\n");
13
+ expect(p).toContain("@aphrody/m3-mcp");
14
+ });
15
+ test("token lookup and validation", () => {
16
+ expect(lookupTokens("corner-full")[0]?.value).toBe("9999px");
17
+ expect(lookupTokens("on-primary-container").length).toBeGreaterThan(0);
18
+ expect(validateTokens("a{color:var(--md-sys-color-primary);b:var(--md-nope)}").unknown).toEqual([
19
+ "--md-nope",
20
+ ]);
21
+ });
22
+ test("docs, research and templates", () => {
23
+ expect(searchDocs("Material", 3).length).toBeGreaterThan(0);
24
+ expect(researchLinks().links.length).toBeGreaterThan(20);
25
+ expect(listTemplates()).toContain("templates/react");
26
+ });
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { expect, test } from "bun:test";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import { createM3McpServer } from "../src/server.ts";
7
+
8
+ test("the design server supports imported composition and a real MCP tool round trip", async () => {
9
+ const server = createM3McpServer();
10
+ const client = new Client({ name: "m3-test", version: "1.0.0" });
11
+ const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
12
+ try {
13
+ await server.connect(serverTransport);
14
+ await client.connect(clientTransport);
15
+ const tools = await client.listTools();
16
+ expect(tools.tools.map((tool) => tool.name)).toContain("m3_token_lookup");
17
+ expect(tools.tools.map((tool) => tool.name)).toContain("m3_os_themes_generate");
18
+ const result = await client.callTool({
19
+ name: "m3_token_lookup",
20
+ arguments: { query: "corner-full", limit: 1 },
21
+ });
22
+ expect(result.isError).not.toBe(true);
23
+ expect(JSON.stringify(result.content)).toContain("9999px");
24
+ const invalid = await client.callTool({
25
+ name: "m3_symbol_svg",
26
+ arguments: { name: "../../private" },
27
+ });
28
+ expect(invalid.isError).toBe(true);
29
+ const oversized = await client.callTool({
30
+ name: "m3_docs_search",
31
+ arguments: { query: "Material", limit: 501 },
32
+ });
33
+ expect(oversized.isError).toBe(true);
34
+ } finally {
35
+ await client.close();
36
+ await server.close();
37
+ }
38
+ });
39
+
40
+ test("the executable entry completes stdio handshake, discovery and tool execution", async () => {
41
+ const client = new Client({ name: "m3-stdio-test", version: "1.0.0" });
42
+ const transport = new StdioClientTransport({
43
+ command: process.execPath,
44
+ args: [new URL("../src/server.ts", import.meta.url).pathname],
45
+ stderr: "pipe",
46
+ });
47
+ try {
48
+ await client.connect(transport);
49
+ expect((await client.listTools()).tools.length).toBeGreaterThan(10);
50
+ const result = await client.callTool({ name: "m3_list_packages", arguments: {} });
51
+ expect(result.isError).not.toBe(true);
52
+ expect(JSON.stringify(result.content)).toContain("@aphrody/m3-mcp");
53
+ } finally {
54
+ await client.close();
55
+ }
56
+ }, 10_000);
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["bun"],
11
+ "allowImportingTsExtensions": true
12
+ },
13
+ "include": ["src/**/*", "test/**/*"]
14
+ }