feat(mcp): add list_available_fonts tool for font discovery (#210)

* 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<Font[]>
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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Gliddon 2026-04-15 23:56:33 -07:00 committed by GitHub
parent 364f25f23e
commit 2cb32cfb00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 54 additions and 2 deletions

View file

@ -38,6 +38,7 @@
"allowImportNames": [
"loadFont",
"listFamilies",
"listFonts",
"preloadFonts"
]
},

View file

@ -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<FigmaFont[]> {
// 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

View file

@ -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'

View file

@ -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.

View file

@ -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,

View file

@ -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
}

View file

@ -52,6 +52,13 @@ export async function listFamilies(): Promise<string[]> {
return coreList()
}
export async function listFonts(): Promise<TauriFontFamily[]> {
if (IS_TAURI) {
return getTauriFonts()
}
return []
}
export async function loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
if (IS_TAURI) {
try {