mirror of
https://github.com/aphrody-labs/aphrody-m3-mcp.git
synced 2026-10-13 19:54:18 +00:00
chore: migrate package sources from Aphrody
This commit is contained in:
1 parent
6fce4748ce
commit
45d0e7090b
9 files changed
+768
-2
No files matched your search
@@ -1,2 +1,24 @@
|
||||
# aphrody-m3-mcp
|
||||
Aphrody package @aphrody/m3-mcp
|
||||
# @aphrody/m3-mcp
|
||||
|
||||
The design/UI tool host of aphrody-m3: packages, M3 tokens, docs, research links, templates,
|
||||
Material Symbols and desktop icons, served from this repository's files. It is not an MCP server:
|
||||
the single `aphrody-mcp` hosts these tools (`aphrody mcp-server`, alias `m3-mcp`).
|
||||
|
||||
```sh
|
||||
bun src/tool-host.ts list # tool definitions (JSON)
|
||||
bun src/tool-host.ts call m3_token_lookup '{"query":"primary"}' # one MCP-shaped result
|
||||
```
|
||||
|
||||
| Tool | Does |
|
||||
| --- | --- |
|
||||
| `m3_list_packages` | workspace packages with versions |
|
||||
| `m3_token_lookup`, `m3_token_validate` | look up and validate M3 tokens |
|
||||
| `m3_os_themes_list`, `m3_os_themes_generate` | OS theme targets of `@aphrody/m3-os-themes` (dry run) |
|
||||
| `m3_docs_search`, `m3_research_links` | search `docs/`, list research links |
|
||||
| `m3_list_templates` | `templates/` |
|
||||
| `m3_symbol_svg`, `m3_symbol_search` | Material Symbols SVG and catalogue search (`@aphrody/material-design-icons`) |
|
||||
| `m3_svg_to_ico` | SVG to Tauri desktop icons |
|
||||
|
||||
Sources: `src/tools.ts` (tool table), `src/tool-host.ts` (`list` / `call`), `src/catalog.ts` (file-backed lookups). Tests:
|
||||
`bun test`; typecheck: `bun run typecheck`. Registered in the aphrody-labs plugins marketplace as
|
||||
the `aphrody-m3` plugin.
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@aphrody/m3-mcp",
|
||||
"version": "1.4.4",
|
||||
"description": "Design/UI tool host of aphrody-m3 (served through the single aphrody-mcp): packages, M3 tokens (lookup, validate), docs search, research links, templates, Material Symbols SVG, SVG to Tauri icons.",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"README.md",
|
||||
"src"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/tools.ts",
|
||||
"./tool-host": "./src/tool-host.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun src/tool-host.ts list",
|
||||
"test": "bun test",
|
||||
"typecheck": "bun check --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aphrody/m3-os-themes": "workspace:*",
|
||||
"@aphrody/material-design-icons": "workspace:*",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { loadTokens, lookupTokens, validateTokens } from "./catalog.ts";
|
||||
|
||||
const scratch = mkdtempSync(join(tmpdir(), "m3-token-catalog-"));
|
||||
let fixtures = 0;
|
||||
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
function fixture(exports: Record<string, unknown>, files: Record<string, string>): string {
|
||||
const root = join(scratch, String(fixtures++));
|
||||
const pkg = join(root, "packages/m3-tokens");
|
||||
mkdirSync(pkg, { recursive: true });
|
||||
writeFileSync(join(pkg, "package.json"), JSON.stringify({ exports }));
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
const file = join(pkg, path);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("the repository token catalogue", () => {
|
||||
test("Expressive typography, motion and directional shapes can be looked up and validated", () => {
|
||||
const names = [
|
||||
"--md-sys-typescale-display-large-emphasized-font",
|
||||
"--md-sys-motion-spring-expressive-fast-spatial-damping",
|
||||
"--md-sys-shape-corner-large-start",
|
||||
];
|
||||
for (const name of names) {
|
||||
const tokens = lookupTokens(name);
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0]?.source).toBe("packages/m3-tokens/src/expressive.css");
|
||||
}
|
||||
expect(lookupTokens(names[1]!)[0]?.value).toBe("0.6");
|
||||
const input = names.map((name) => `var(${name})`).join(" ");
|
||||
expect(validateTokens(input)).toEqual({ checked: names, unknown: [] });
|
||||
expect(
|
||||
validateTokens(`${input} var(--md-sys-motion-spring-expressive-fast-spatial-dampng)`).unknown,
|
||||
).toEqual(["--md-sys-motion-spring-expressive-fast-spatial-dampng"]);
|
||||
});
|
||||
|
||||
test("base tokens, breakpoints and dynamic colour roles remain available without duplicates", () => {
|
||||
expect(lookupTokens("corner-full")[0]?.value).toBe("9999px");
|
||||
expect(lookupTokens("--md-breakpoint-expanded")[0]?.value).toBe("840px");
|
||||
expect(lookupTokens("--md-sys-color-on-primary-container")[0]?.source).toBe(
|
||||
"packages/m3-tokens/src/dynamic-color.ts",
|
||||
);
|
||||
const tokens = loadTokens();
|
||||
expect(new Set(tokens.map((token) => token.name)).size).toBe(tokens.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exported token sources", () => {
|
||||
test("reads exported CSS rather than unrelated files and retains the first declaration's source", () => {
|
||||
const root = fixture(
|
||||
{
|
||||
"./base.css": { default: "./src/base.css" },
|
||||
"./alias.css": "./src/base.css",
|
||||
"./extended.css": ["./src/extended.css"],
|
||||
"./helpers": "./src/helpers.ts",
|
||||
},
|
||||
{
|
||||
"src/base.css": `/* --md-commented-out: nope; */
|
||||
:root { --md-sys-shape-corner-large: 16px; --md-sys-color-primary: #6750a4; }
|
||||
.dark { --md-sys-shape-corner-large: 20px; }`,
|
||||
"src/extended.css":
|
||||
":root { --md-sys-shape-corner-large: 32px; --md-sys-shape-corner-large-start: 16px 0 0 16px; }",
|
||||
"src/unexported.css": ":root { --md-unexported: hidden; }",
|
||||
"src/helpers.ts": "// --md-not-css: hidden;",
|
||||
"src/dynamic-color.ts": `export const ROLES = ["primary", 'surfaceBright', "primary", /* "hiddenRole" */];`,
|
||||
},
|
||||
);
|
||||
const tokens = loadTokens(root);
|
||||
expect(tokens).toHaveLength(4);
|
||||
expect(lookupTokens("--md-sys-shape-corner-large", 1, root)).toEqual([
|
||||
{
|
||||
name: "--md-sys-shape-corner-large",
|
||||
value: "16px",
|
||||
source: "packages/m3-tokens/src/base.css",
|
||||
},
|
||||
]);
|
||||
expect(lookupTokens("--md-sys-color-primary", 50, root)).toEqual([
|
||||
{
|
||||
name: "--md-sys-color-primary",
|
||||
value: "#6750a4",
|
||||
source: "packages/m3-tokens/src/base.css",
|
||||
},
|
||||
]);
|
||||
expect(lookupTokens("surface-bright", 50, root)).toEqual([
|
||||
{
|
||||
name: "--md-sys-color-surface-bright",
|
||||
value: "(dynamic colour role)",
|
||||
source: "packages/m3-tokens/src/dynamic-color.ts",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
validateTokens(
|
||||
"var(--md-sys-shape-corner-large-start) var(--md-sys-color-surface-bright)",
|
||||
root,
|
||||
).unknown,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateTokens(
|
||||
"--md-unexported --md-commented-out --md-not-css --md-sys-color-hidden-role",
|
||||
root,
|
||||
).unknown,
|
||||
).toEqual([
|
||||
"--md-unexported",
|
||||
"--md-commented-out",
|
||||
"--md-not-css",
|
||||
"--md-sys-color-hidden-role",
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing exported sheets do not hide present sheets or dynamic roles", () => {
|
||||
const root = fixture(
|
||||
{
|
||||
"./missing.css": "./src/missing.css",
|
||||
"./present.css": "./src/present.css",
|
||||
"./outside.css": "../outside.css",
|
||||
},
|
||||
{
|
||||
"src/present.css": ":root { --md-present: 1px; }",
|
||||
"src/dynamic-color.ts": 'export const ROLES = ["onPrimaryContainer"];',
|
||||
},
|
||||
);
|
||||
expect(loadTokens(root).map((token) => token.name)).toEqual([
|
||||
"--md-present",
|
||||
"--md-sys-color-on-primary-container",
|
||||
]);
|
||||
expect(
|
||||
validateTokens("--md-present --md-sys-color-on-primary-container --md-missing", root),
|
||||
).toEqual({
|
||||
checked: ["--md-present", "--md-sys-color-on-primary-container", "--md-missing"],
|
||||
unknown: ["--md-missing"],
|
||||
});
|
||||
});
|
||||
|
||||
test("an absent token checkout gives an empty catalogue and reports unknown references", () => {
|
||||
const root = join(scratch, "absent");
|
||||
expect(loadTokens(root)).toEqual([]);
|
||||
expect(lookupTokens("primary", 50, root)).toEqual([]);
|
||||
expect(validateTokens("var(--md-sys-color-primary)", root)).toEqual({
|
||||
checked: ["--md-sys-color-primary"],
|
||||
unknown: ["--md-sys-color-primary"],
|
||||
});
|
||||
});
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export const ROOT = join(import.meta.dir, "../../..");
|
||||
|
||||
const read = (p: string) => (existsSync(p) ? readFileSync(p, "utf8") : "");
|
||||
|
||||
export function listPackages(root = ROOT): string[] {
|
||||
const dir = join(root, "packages");
|
||||
return readdirSync(dir).flatMap((d) => {
|
||||
const p = join(dir, d, "package.json");
|
||||
if (!existsSync(p)) return [];
|
||||
const j = JSON.parse(readFileSync(p, "utf8"));
|
||||
return [`${j.name}@${j.version ?? "0.0.0"} packages/${d}`];
|
||||
});
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
name: string;
|
||||
value: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
||||
|
||||
/** Local CSS targets of the token package's exports, including conditional exports and aliases. */
|
||||
function exportedSheets(exports: unknown): string[] {
|
||||
if (typeof exports === "string")
|
||||
return exports.startsWith("./") && exports.endsWith(".css") && !exports.includes("*")
|
||||
? [exports]
|
||||
: [];
|
||||
if (Array.isArray(exports)) return exports.flatMap(exportedSheets);
|
||||
if (exports && typeof exports === "object") return Object.values(exports).flatMap(exportedSheets);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Every exported `--md-*` CSS property and dynamic colour role, once per name. */
|
||||
export function loadTokens(root = ROOT): Token[] {
|
||||
const pkg = join(root, "packages/m3-tokens");
|
||||
const manifest = read(join(pkg, "package.json"));
|
||||
const exports: unknown = manifest
|
||||
? (JSON.parse(manifest) as { exports?: unknown }).exports
|
||||
: undefined;
|
||||
const out = new Map<string, Token>();
|
||||
const add = (token: Token) => {
|
||||
// The first declaration is representative; theme/direction variants do not create new names.
|
||||
if (!out.has(token.name)) out.set(token.name, token);
|
||||
};
|
||||
for (const target of new Set(exportedSheets(exports))) {
|
||||
const file = resolve(pkg, target);
|
||||
const path = relative(pkg, file).replaceAll("\\", "/");
|
||||
if (
|
||||
isAbsolute(path) ||
|
||||
path === ".." ||
|
||||
path.startsWith("../") ||
|
||||
path.split("/").includes("node_modules")
|
||||
)
|
||||
continue;
|
||||
const css = read(file).replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const m of css.matchAll(/(--md-[\w-]+)\s*:\s*([^;]+);/g)) {
|
||||
add({ name: m[1]!, value: m[2]!.trim(), source: `packages/m3-tokens/${path}` });
|
||||
}
|
||||
}
|
||||
// `dynamic-color.ts` is the public module of the roles; it re-exports the list from `native-theme.ts`.
|
||||
const list = ["src/dynamic-color.ts", "src/native-theme.ts"]
|
||||
.map((file) => /export const ROLES = \[([\s\S]*?)\]/.exec(read(join(pkg, file)))?.[1])
|
||||
.find((roles) => roles !== undefined);
|
||||
const roles = (list ?? "").replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "");
|
||||
for (const m of roles.matchAll(/["'](\w+)["']/g)) {
|
||||
add({
|
||||
name: `--md-sys-color-${kebab(m[1]!)}`,
|
||||
value: "(dynamic colour role)",
|
||||
source: "packages/m3-tokens/src/dynamic-color.ts",
|
||||
});
|
||||
}
|
||||
return [...out.values()];
|
||||
}
|
||||
|
||||
export function lookupTokens(query: string, limit = 50, root = ROOT): Token[] {
|
||||
const q = query.toLowerCase();
|
||||
return loadTokens(root)
|
||||
.filter((t) => t.name.includes(q) || t.value.toLowerCase().includes(q))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Validate token names or every `var(--md-*)` / `--md-*` reference found in a CSS snippet. */
|
||||
export function validateTokens(
|
||||
input: string,
|
||||
root = ROOT,
|
||||
): { checked: string[]; unknown: string[] } {
|
||||
const known = new Set(loadTokens(root).map((t) => t.name));
|
||||
const checked = [...new Set([...input.matchAll(/--md-[\w-]+/g)].map((m) => m[0]))];
|
||||
return { checked, unknown: checked.filter((n) => !known.has(n)) };
|
||||
}
|
||||
|
||||
function* walk(dir: string, includeJson = false): Generator<string> {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true }).toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)) {
|
||||
if (e.name === "node_modules" || e.name.startsWith(".git") || e.isSymbolicLink()) continue;
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) yield* walk(p, includeJson);
|
||||
else if ((includeJson ? /\.(md|mdx|json)$/ : /\.(md|mdx)$/).test(e.name)) yield p;
|
||||
}
|
||||
}
|
||||
|
||||
/** The single native Web acquisition corpus, explicitly configurable for qualified hosts. */
|
||||
export function designCorpusRoot(
|
||||
root = ROOT,
|
||||
configured = process.env.APHRODY_DESIGN_CORPUS,
|
||||
): string | undefined {
|
||||
const corpus = resolve(configured ?? join(root, "../var/mirrors/design-sync"));
|
||||
return existsSync(join(corpus, "AI_READY.md")) && statSync(corpus).isDirectory()
|
||||
? corpus
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function searchDocs(
|
||||
query: string,
|
||||
limit = 20,
|
||||
root = ROOT,
|
||||
corpus = designCorpusRoot(root),
|
||||
): string[] {
|
||||
const q = query.toLowerCase();
|
||||
const hits: string[] = [];
|
||||
const docs = join(root, "docs");
|
||||
const sources = [
|
||||
...(corpus
|
||||
? [...walk(corpus, true)].map((file) => ({
|
||||
file,
|
||||
source: `design-sync/${relative(corpus, file).replaceAll("\\", "/")}`,
|
||||
}))
|
||||
: []),
|
||||
...(existsSync(docs)
|
||||
? [...walk(docs)].map((file) => ({
|
||||
file,
|
||||
source: relative(root, file).replaceAll("\\", "/"),
|
||||
}))
|
||||
: []),
|
||||
];
|
||||
for (const { file, source } of sources) {
|
||||
const lines = read(file).split("\n");
|
||||
for (let i = 0; i < lines.length && hits.length < limit; i++) {
|
||||
if (lines[i]!.toLowerCase().includes(q))
|
||||
hits.push(`${source}:${i + 1}: ${lines[i]!.trim().slice(0, 200)}`);
|
||||
}
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function researchLinks(
|
||||
root = ROOT,
|
||||
corpus = designCorpusRoot(root),
|
||||
): { files: string[]; links: string[] } {
|
||||
if (corpus) {
|
||||
const documents = [...walk(corpus, true)];
|
||||
const links = [
|
||||
...new Set(
|
||||
documents.flatMap((file) =>
|
||||
[...read(file).matchAll(/https?:\/\/[^\s<>"'\])]+/g)].map((match) =>
|
||||
match[0].replace(/[.,;]+$/, ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
].toSorted();
|
||||
return {
|
||||
files: documents.map((file) => `design-sync/${relative(corpus, file).replaceAll("\\", "/")}`),
|
||||
links,
|
||||
};
|
||||
}
|
||||
const dir = join(root, "docs/research");
|
||||
const files = existsSync(dir) ? [...walk(dir)].map((f) => relative(root, f)) : [];
|
||||
// The link index moved with the M3 integrations to the Bun fork checkout next to this repository.
|
||||
const index = [
|
||||
dir,
|
||||
join(root, "../../bun/packages/bun-aphrody-web/integrations/m3/docs/research"),
|
||||
]
|
||||
.map((research) => join(research, "GOOGLE-LINKS.md"))
|
||||
.find((file) => existsSync(file));
|
||||
if (index && !index.startsWith(dir)) files.push(relative(root, index));
|
||||
const links = [
|
||||
...read(index ?? "").matchAll(/^\|\s*([^|]+?)\s*\|\s*(https?:\/\/\S+)\s*\|/gm),
|
||||
].map((m) => `${m[1]} ${m[2]}`);
|
||||
return { files, links };
|
||||
}
|
||||
|
||||
export function listTemplates(root = ROOT): string[] {
|
||||
const rows: string[] = [];
|
||||
for (const base of ["templates", "examples", "examples/tauri"]) {
|
||||
const dir = join(root, base);
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const e of readdirSync(dir)) {
|
||||
if (statSync(join(dir, e)).isDirectory() && !(base === "examples" && e === "tauri"))
|
||||
rows.push(`${base}/${e}`);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bun
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Tool host protocol of the single Aphrody MCP server: `list` prints the tool definitions as JSON,
|
||||
// `call <name> '<json>'` prints one MCP-shaped result (`{content, isError?}`) and exits 0.
|
||||
import { callM3Tool, listM3Tools } from "./tools";
|
||||
|
||||
const [command, name, json] = Bun.argv.slice(2);
|
||||
if (command === "list") {
|
||||
console.log(JSON.stringify(listM3Tools()));
|
||||
} else if (command === "call" && name) {
|
||||
let args: unknown;
|
||||
try {
|
||||
args = JSON.parse(json ?? "{}");
|
||||
} catch {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
content: [{ type: "text", text: "arguments are not JSON" }],
|
||||
isError: true,
|
||||
}),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(JSON.stringify(await callM3Tool(name, args)));
|
||||
} else {
|
||||
console.error("Usage: tool-host.ts list | call <tool-name> '<json-object>'");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// The M3 design tool table. It is hosted by the single Aphrody MCP server (`aphrody-mcp`) through
|
||||
// `tool-host.ts` (`list` / `call`); this package no longer speaks MCP itself.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Target } from "@aphrody/m3-os-themes";
|
||||
import {
|
||||
ROOT,
|
||||
listPackages,
|
||||
listTemplates,
|
||||
lookupTokens,
|
||||
researchLinks,
|
||||
searchDocs,
|
||||
validateTokens,
|
||||
} from "./catalog";
|
||||
|
||||
// The theme generators (16 ms with m3-tokens) and the symbol catalogue are loaded by the tools that use them, not at
|
||||
// start-up: listing the tools never waits for them.
|
||||
const osThemes = () => import("@aphrody/m3-os-themes");
|
||||
const symbols = () => import("@aphrody/material-design-icons");
|
||||
|
||||
export interface ToolResult {
|
||||
content: { type: "text"; text: string }[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface M3Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
shape: z.ZodRawShape;
|
||||
run(args: never, signal?: AbortSignal): Promise<ToolResult> | ToolResult;
|
||||
}
|
||||
|
||||
const text = (t: string): ToolResult => ({ content: [{ type: "text", text: t || "(no result)" }] });
|
||||
|
||||
const themeInput = {
|
||||
seed: z.string().describe("Seed colour #rrggbb"),
|
||||
target: z.enum(["windows", "gnome", "cosmic", "chromium", "all"]).default("all"),
|
||||
name: z.string().default("Aphrody"),
|
||||
};
|
||||
|
||||
const limit = (fallback: number) => z.number().int().min(1).max(500).default(fallback);
|
||||
|
||||
function tool<S extends z.ZodRawShape>(
|
||||
name: string,
|
||||
description: string,
|
||||
shape: S,
|
||||
run: (args: z.infer<z.ZodObject<S>>, signal?: AbortSignal) => Promise<ToolResult> | ToolResult,
|
||||
): M3Tool {
|
||||
return { name, description, shape, run: run as M3Tool["run"] };
|
||||
}
|
||||
|
||||
export const M3_TOOLS: M3Tool[] = [
|
||||
tool("m3_list_packages", "List the aphrody-m3 workspace packages with versions.", {}, () =>
|
||||
text(listPackages().join("\n")),
|
||||
),
|
||||
tool(
|
||||
"m3_token_lookup",
|
||||
"Look up Material 3 design tokens (--md-*) by name or value fragment in m3-tokens.",
|
||||
{ query: z.string(), limit: limit(50) },
|
||||
({ query, limit: maximum }) =>
|
||||
text(
|
||||
lookupTokens(query, maximum)
|
||||
.map((t) => `${t.name}: ${t.value} [${t.source}]`)
|
||||
.join("\n"),
|
||||
),
|
||||
),
|
||||
tool(
|
||||
"m3_token_validate",
|
||||
"Validate that every --md-* custom property in a CSS snippet or name list exists in m3-tokens.",
|
||||
{ css: z.string() },
|
||||
({ css }) => {
|
||||
const r = validateTokens(css);
|
||||
return text(
|
||||
`checked ${r.checked.length}, unknown ${r.unknown.length}${r.unknown.length ? `\n${r.unknown.join("\n")}` : ""}`,
|
||||
);
|
||||
},
|
||||
),
|
||||
tool(
|
||||
"m3_os_themes_list",
|
||||
"List the files m3-os-themes would generate for a seed colour and target (nothing is written).",
|
||||
themeInput,
|
||||
async ({ seed, target, name }) => {
|
||||
const { TARGETS, generate } = await osThemes();
|
||||
const targets: readonly Target[] = target === "all" ? TARGETS : [target];
|
||||
return text(
|
||||
generate({ name, seed, mode: "auto" }, targets)
|
||||
.map((f) => f.path)
|
||||
.join("\n"),
|
||||
);
|
||||
},
|
||||
),
|
||||
tool(
|
||||
"m3_os_themes_generate",
|
||||
"Return the text content of one generated OS theme file (path from m3_os_themes_list). Never writes to disk or the system.",
|
||||
{ ...themeInput, path: z.string() },
|
||||
async ({ seed, target, name, path }) => {
|
||||
const { TARGETS, generate } = await osThemes();
|
||||
const targets: readonly Target[] = target === "all" ? TARGETS : [target];
|
||||
const file = generate({ name, seed, mode: "auto" }, targets).find((f) => f.path === path);
|
||||
if (!file) return text(`unknown path ${path}`);
|
||||
return text(
|
||||
typeof file.content === "string" ? file.content : `(binary, ${file.content.length} bytes)`,
|
||||
);
|
||||
},
|
||||
),
|
||||
tool(
|
||||
"m3_docs_search",
|
||||
"Search the canonical native Web design-sync corpus (AI-ready Markdown, specs and asset JSON), followed by local M3 guides; returns source path:line hits.",
|
||||
{ query: z.string(), limit: limit(20) },
|
||||
({ query, limit: maximum }) => text(searchDocs(query, maximum).join("\n")),
|
||||
),
|
||||
tool(
|
||||
"m3_research_links",
|
||||
"List the canonical native Web design-sync corpus files and source URLs; fall back to the dated local research index when no corpus has been acquired.",
|
||||
{},
|
||||
() => {
|
||||
const r = researchLinks();
|
||||
return text(`${r.files.join("\n")}\n\n${r.links.join("\n")}`);
|
||||
},
|
||||
),
|
||||
tool("m3_list_templates", "List UI templates, examples and Tauri example apps.", {}, () =>
|
||||
text(listTemplates().join("\n")),
|
||||
),
|
||||
tool(
|
||||
"m3_symbol_svg",
|
||||
"Return path and SVG source of a Material Symbol from the forked catalogue.",
|
||||
{
|
||||
name: z.string().regex(/^[a-z0-9_]+$/),
|
||||
style: z.enum(["outlined", "rounded", "sharp"]).default("outlined"),
|
||||
weight: z
|
||||
.union([
|
||||
z.literal(100),
|
||||
z.literal(200),
|
||||
z.literal(300),
|
||||
z.literal(400),
|
||||
z.literal(500),
|
||||
z.literal(600),
|
||||
z.literal(700),
|
||||
])
|
||||
.default(400),
|
||||
filled: z.boolean().default(false),
|
||||
},
|
||||
async ({ name, style, weight, filled }) => {
|
||||
const { symbolSvgPath } = await symbols();
|
||||
const p = symbolSvgPath(name, style, weight, filled);
|
||||
return existsSync(p) ? text(`${p}\n${readFileSync(p, "utf8")}`) : text(`not found: ${p}`);
|
||||
},
|
||||
),
|
||||
tool(
|
||||
"m3_symbol_search",
|
||||
"Search the full Material Symbols catalogue (4150 names) by substring; returns matching symbol names.",
|
||||
{ query: z.string(), limit: limit(50) },
|
||||
async ({ query, limit: maximum }) => {
|
||||
const { searchSymbols, symbolNames } = await symbols();
|
||||
const hits = searchSymbols(query, maximum);
|
||||
return text(
|
||||
hits.length > 0 ? hits.join("\n") : `no match among ${symbolNames().length} symbols`,
|
||||
);
|
||||
},
|
||||
),
|
||||
tool(
|
||||
"m3_svg_to_ico",
|
||||
"Generate Tauri desktop icons (ico, icns, png) from a symbol name or SVG file.",
|
||||
{
|
||||
input: z.string(),
|
||||
out: z.string().default("src-tauri/icons"),
|
||||
style: z.enum(["outlined", "rounded", "sharp"]).default("outlined"),
|
||||
fill: z.string().optional(),
|
||||
},
|
||||
async ({ input, out, style, fill }, signal) => {
|
||||
const args = [
|
||||
"bun",
|
||||
join(ROOT, "packages/material-design-icons/scripts/svg-to-ico.ts"),
|
||||
input,
|
||||
"--out",
|
||||
out,
|
||||
"--style",
|
||||
style,
|
||||
];
|
||||
if (fill) args.push("--fill", fill);
|
||||
const child = Bun.spawn({
|
||||
windowsHide: true,
|
||||
cmd: args,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
signal,
|
||||
timeout: 30_000,
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
]);
|
||||
return { ...text(`${code}\n${stdout}${stderr}`), ...(code !== 0 ? { isError: true } : {}) };
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
/** JSON Schema definitions in the shape every MCP client expects from `tools/list`. */
|
||||
export function listM3Tools(): { name: string; description: string; inputSchema: unknown }[] {
|
||||
return M3_TOOLS.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: z.toJSONSchema(z.object(t.shape)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Validate the arguments with the tool's schema, then run it; failures are `isError` results. */
|
||||
export async function callM3Tool(
|
||||
name: string,
|
||||
args: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolResult> {
|
||||
const found = M3_TOOLS.find((t) => t.name === name);
|
||||
if (!found) return { ...text(`unknown tool ${name}`), isError: true };
|
||||
const parsed = z.object(found.shape).safeParse(args ?? {});
|
||||
if (!parsed.success) return { ...text(parsed.error.message), isError: true };
|
||||
try {
|
||||
return await found.run(parsed.data as never, signal);
|
||||
} catch (error) {
|
||||
return { ...text(error instanceof Error ? error.message : String(error)), isError: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
listPackages,
|
||||
listTemplates,
|
||||
lookupTokens,
|
||||
researchLinks,
|
||||
searchDocs,
|
||||
designCorpusRoot,
|
||||
validateTokens,
|
||||
} from "../src/catalog";
|
||||
|
||||
test("packages include m3-mcp", () => {
|
||||
const p = listPackages().join("\n");
|
||||
expect(p).toContain("@aphrody/m3-mcp");
|
||||
});
|
||||
test("token lookup and validation", () => {
|
||||
expect(lookupTokens("corner-full")[0]?.value).toBe("9999px");
|
||||
expect(lookupTokens("on-primary-container").length).toBeGreaterThan(0);
|
||||
expect(validateTokens("a{color:var(--md-sys-color-primary);b:var(--md-nope)}").unknown).toEqual([
|
||||
"--md-nope",
|
||||
]);
|
||||
});
|
||||
test("docs, research and templates", () => {
|
||||
expect(searchDocs("Material", 3).length).toBeGreaterThan(0);
|
||||
expect(researchLinks().links.length).toBeGreaterThan(0);
|
||||
expect(listTemplates()).toContain("templates/react");
|
||||
});
|
||||
|
||||
test("native design corpus takes priority and exposes Markdown, JSON provenance and URL exports", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "m3-design-corpus-"));
|
||||
try {
|
||||
const framework = join(root, "m3");
|
||||
const corpus = join(root, "var/mirrors/design-sync");
|
||||
mkdirSync(join(framework, "docs"), { recursive: true });
|
||||
mkdirSync(join(corpus, "material-3/documents"), { recursive: true });
|
||||
writeFileSync(join(framework, "docs/old.md"), "Material historical guide");
|
||||
expect(designCorpusRoot(framework)).toBeUndefined();
|
||||
writeFileSync(join(corpus, "AI_READY.md"), "Material primary source https://m3.material.io/\n");
|
||||
writeFileSync(
|
||||
join(corpus, "material-3/documents/buttons.md"),
|
||||
"Material expressive buttons https://m3.material.io/components/buttons/overview\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(corpus, "assets.json"),
|
||||
'{"Material": "https://design.google/assets/pet.svg"}',
|
||||
);
|
||||
expect(designCorpusRoot(framework)).toBe(corpus);
|
||||
expect(searchDocs("Material", 1, framework)).toEqual([
|
||||
"design-sync/AI_READY.md:1: Material primary source https://m3.material.io/",
|
||||
]);
|
||||
expect(searchDocs("pet.svg", 3, framework)[0]).toContain("design-sync/assets.json:1:");
|
||||
expect(researchLinks(framework).links).toContain("https://design.google/assets/pet.svg");
|
||||
expect(researchLinks(framework).files).toContain("design-sync/material-3/documents/buttons.md");
|
||||
expect(searchDocs("Material", 10, framework).at(-1)).toContain("docs/old.md");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { expect, test } from "bun:test";
|
||||
import { callM3Tool, listM3Tools } from "../src/tools.ts";
|
||||
|
||||
test("the table lists the design tools with JSON schemas", () => {
|
||||
const tools = listM3Tools();
|
||||
const names = tools.map((tool) => tool.name);
|
||||
expect(names).toContain("m3_token_lookup");
|
||||
expect(names).toContain("m3_os_themes_generate");
|
||||
expect(tools.length).toBeGreaterThan(10);
|
||||
expect(JSON.stringify(tools[0]?.inputSchema)).toContain("object");
|
||||
});
|
||||
|
||||
test("a call validates its arguments and returns an MCP-shaped result", async () => {
|
||||
const ok = await callM3Tool("m3_token_lookup", { query: "corner-full", limit: 1 });
|
||||
expect(ok.isError).not.toBe(true);
|
||||
expect(JSON.stringify(ok.content)).toContain("9999px");
|
||||
expect((await callM3Tool("m3_symbol_svg", { name: "../../private" })).isError).toBe(true);
|
||||
expect((await callM3Tool("m3_docs_search", { query: "Material", limit: 501 })).isError).toBe(
|
||||
true,
|
||||
);
|
||||
expect((await callM3Tool("nope", {})).isError).toBe(true);
|
||||
});
|
||||
|
||||
test("the executable host prints the list and one call result", () => {
|
||||
const host = Bun.fileURLToPath(new URL("../src/tool-host.ts", import.meta.url));
|
||||
const list = Bun.spawnSync([process.execPath, host, "list"], { windowsHide: true });
|
||||
expect(JSON.parse(list.stdout.toString()).length).toBeGreaterThan(10);
|
||||
const call = Bun.spawnSync([process.execPath, host, "call", "m3_list_packages", "{}"], {
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(JSON.parse(call.stdout.toString()).content[0].type).toBe("text");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
Reference in new issue
Block a user