From 2cb32cfb00cc4863755bb6dce3550c2d369267c9 Mon Sep 17 00:00:00 2001 From: Chris Gliddon Date: Wed, 15 Apr 2026 23:56:33 -0700 Subject: [PATCH] feat(mcp): add list_available_fonts tool for font discovery (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): add list_available_fonts tool for font discovery The existing list_fonts tool reports only fonts currently used in the current page (it walks every TEXT node and groups by fontFamily). Agents authoring new text have no way to discover what fonts the host can render — they have to guess and hope set_font succeeds. This adds list_available_fonts, which returns the set of font families the host advertises. On desktop this is the system fonts font-kit enumerates plus any bundled fonts the host registers; in non-desktop runtimes that don't expose enumeration, it returns an empty list with an explanatory note rather than throwing. The optional `family` filter mirrors list_fonts for a substring search, so agents can do `list_available_fonts({family: "mono"})` to narrow before set_font. Wired through: - packages/core/src/figma-api/index.ts: adds optional listAvailableFontFamilies hook on FigmaAPI (same pattern as exportImage). Optional so non-desktop hosts aren't forced to implement it. - packages/core/src/tools/read.ts: defines the tool. - packages/core/src/tools/registry.ts: registers it in CORE_TOOLS so the MCP server advertises it. - src/automation/figma-factory.ts: provides the desktop implementation by delegating to engine/fonts.ts#listFamilies which already wraps the Tauri list_system_fonts sidecar. * refactor(mcp): reshape to listAvailableFontsAsync matching Figma API Per review feedback on #210: FigmaAPI mirrors the Figma Plugin API, and Figma already exposes figma.listAvailableFontsAsync(): Promise where Font = { fontName: { family, style } }. Replacing the custom listAvailableFontFamilies hook with the Figma-shaped method means eval'd plugin code and any future plugin-compat layer get font enumeration for free. Follows the same pattern as loadFontAsync on FigmaAPI. - packages/core/src/figma-api/proxy.ts: export FigmaFont type ({ fontName: FigmaFontName }) alongside FigmaFontName. - packages/core/src/figma-api/index.ts: drop optional listAvailableFontFamilies; add listAvailableFontsAsync() as a real method (non-optional) with a safe [] default, mirroring loadFontAsync. - packages/core/src/tools/read.ts: list_available_fonts execute() now consumes Font[] and dedupes families via a Set. Drops the host-capability fallback note — the new method always returns an array, so a non-desktop host just yields {count: 0, fonts: []}. - src/engine/fonts.ts: add listFonts() returning TauriFontFamily[] ({family, styles[]}) so the factory can emit one Font per (family, style) pair. - src/automation/figma-factory.ts: override listAvailableFontsAsync, flattening {family, styles[]}[] to Font[]. - oxlint.json: add listFonts to the engine/fonts allowlist. Tool public shape unchanged (still returns {count, fonts: string[]}) so agents calling list_available_fonts see no change. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- oxlint.json | 1 + packages/core/src/figma-api/index.ts | 17 +++++++++++++++-- packages/core/src/figma-api/proxy.ts | 1 + packages/core/src/tools/read.ts | 21 +++++++++++++++++++++ packages/core/src/tools/registry.ts | 2 ++ src/automation/figma-factory.ts | 7 +++++++ src/engine/fonts.ts | 7 +++++++ 7 files changed, 54 insertions(+), 2 deletions(-) diff --git a/oxlint.json b/oxlint.json index 4840cbb50..18e397162 100644 --- a/oxlint.json +++ b/oxlint.json @@ -38,6 +38,7 @@ "allowImportNames": [ "loadFont", "listFamilies", + "listFonts", "preloadFonts" ] }, diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index 600578d1e..5978d2b3a 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -1,7 +1,14 @@ import { IS_BROWSER } from '../constants' import { computeBounds } from '../geometry' import { copyFills, copyStrokes, copyEffects } from '../scene-graph/copy' -import { FigmaNodeProxy, INTERNAL_ID, MIXED, type FigmaFontName, type NodeProxyHost } from './proxy' +import { + FigmaNodeProxy, + INTERNAL_ID, + MIXED, + type FigmaFont, + type FigmaFontName, + type NodeProxyHost +} from './proxy' import type { RasterExportFormat } from '../io/formats/raster' import type { @@ -15,7 +22,7 @@ import type { import type { Rect, Vector } from '../types' export { FigmaNodeProxy } from './proxy' -export type { FigmaFontName } from './proxy' +export type { FigmaFont, FigmaFontName } from './proxy' export function computeImageHash(data: Uint8Array): string { let h1 = 0x811c9dc5 >>> 0 @@ -370,6 +377,12 @@ export class FigmaAPI implements NodeProxyHost { // No-op: we don't gate text editing on font loading } + async listAvailableFontsAsync(): Promise { + // Default: pure browser / test contexts have no enumeration surface. + // Desktop hosts override this to return system + bundled fonts. + return [] + } + notify(message: string): { cancel: () => void } { if (typeof console !== 'undefined') console.log(`[figma.notify] ${message}`) // eslint-disable-next-line no-empty-function diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 9d0f7c891..c8012cb2c 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -19,6 +19,7 @@ import type { Rect } from '../types' const MIXED = Symbol('mixed') export type FigmaFontName = { family: string; style: string } +export type FigmaFont = { fontName: FigmaFontName } export function weightToStyleName(weight: number, italic: boolean): string { const base = FONT_WEIGHT_NAMES[weight] ?? 'Regular' diff --git a/packages/core/src/tools/read.ts b/packages/core/src/tools/read.ts index cd2ae67d7..bc561d28c 100644 --- a/packages/core/src/tools/read.ts +++ b/packages/core/src/tools/read.ts @@ -227,6 +227,27 @@ export const listFonts = defineTool({ } }) +export const listAvailableFonts = defineTool({ + name: 'list_available_fonts', + description: + 'List font families the host can render (system fonts on desktop plus any bundled fonts). ' + + 'Use this to discover what fonts are available to set on a text node — distinct from list_fonts ' + + 'which only reports families currently used in the page.', + params: { + family: { type: 'string', description: 'Filter by family name (substring, case-insensitive)' } + }, + execute: async (figma, args) => { + const fonts = await figma.listAvailableFontsAsync() + let families = Array.from(new Set(fonts.map((f) => f.fontName.family))) + if (args.family) { + const q = args.family.toLowerCase() + families = families.filter((f) => f.toLowerCase().includes(q)) + } + families.sort((a, b) => a.localeCompare(b)) + return { count: families.length, fonts: families } + } +}) + export const queryNodes = defineTool({ name: 'query_nodes', description: `Query nodes using XPath selectors. Node types are element names (FRAME, TEXT, RECTANGLE, ELLIPSE, etc.). Attributes: name, width, height, x, y, visible, opacity, cornerRadius, fontSize, fontFamily, fontWeight, layoutMode, itemSpacing, paddingTop/Right/Bottom/Left, strokeWeight, rotation, locked, blendMode, text, lineHeight, letterSpacing. diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 89389d2f4..3bf3ddcc8 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -58,6 +58,7 @@ import { pageBounds, selectNodes, listFonts, + listAvailableFonts, getJsx, diffJsx } from './read' @@ -164,6 +165,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [ switchPage, pageBounds, listFonts, + listAvailableFonts, diffJsx, // Create (advanced) createShape, diff --git a/src/automation/figma-factory.ts b/src/automation/figma-factory.ts index b08f5063f..9854e4038 100644 --- a/src/automation/figma-factory.ts +++ b/src/automation/figma-factory.ts @@ -1,5 +1,6 @@ import { FigmaAPI } from '@open-pencil/core' +import { listFonts } from '@/engine/fonts' import type { EditorStore } from '@/stores/editor' export function makeFigmaFromStore(store: EditorStore): FigmaAPI { @@ -17,5 +18,11 @@ export function makeFigmaFromStore(store: EditorStore): FigmaAPI { } api.exportImage = (nodeIds, opts) => store.renderExportImage(nodeIds, opts.scale ?? 1, opts.format ?? 'PNG') + api.listAvailableFontsAsync = async () => { + const fonts = await listFonts() + return fonts.flatMap(({ family, styles }) => + styles.map((style) => ({ fontName: { family, style } })) + ) + } return api } diff --git a/src/engine/fonts.ts b/src/engine/fonts.ts index 328d92cea..2ebdb8599 100644 --- a/src/engine/fonts.ts +++ b/src/engine/fonts.ts @@ -52,6 +52,13 @@ export async function listFamilies(): Promise { return coreList() } +export async function listFonts(): Promise { + if (IS_TAURI) { + return getTauriFonts() + } + return [] +} + export async function loadFont(family: string, style = 'Regular'): Promise { if (IS_TAURI) { try {