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:
parent
364f25f23e
commit
2cb32cfb00
|
|
@ -38,6 +38,7 @@
|
|||
"allowImportNames": [
|
||||
"loadFont",
|
||||
"listFamilies",
|
||||
"listFonts",
|
||||
"preloadFonts"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue