figura-mcp 0.9.0 → 0.10.0

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
@@ -34,11 +34,12 @@ the server will fall back to `~/.figura/config.json`.
34
34
  | ---------------------- | ------------------------------------------------------------------------------------------------------------- |
35
35
  | `figura_whoami` | `{}` |
36
36
  | `figura_set_brand` | `name`, optional `description`, `tokensBlock` |
37
- | `figura_create_fig` | `html`, optional `title`, `description`, `area`, `tags[]`, `orientation`, `mode`, `model`, `issue`, `publish` |
37
+ | `figura_create_fig` | `html`, optional `title`, `description`, `area`, `tags[]`, `orientation`, `mode`, `model`, `issue`, `publish`, `commit` |
38
38
  | `figura_generate_fig` | `brief`, optional `title`, `area`, `orientation`, `mode`, `fidelity`, `publish` |
39
- | `figura_revise_fig` | `id`, `html`, optional `title`, `description`, … |
39
+ | `figura_revise_fig` | `id`, `html`, optional `title`, `description`, `commit`, … |
40
40
  | `figura_list_revisions`| `id` |
41
41
  | `figura_update_fig` | `id`, optional `title`, `description`, `area`, `tags[]`, `status` |
42
+ | `figura_commit_fig` | `id`, optional `uncommit` |
42
43
  | `figura_publish_fig` | `id`, optional `unpublish` |
43
44
  | `figura_delete_fig` | `id`, optional `hard` |
44
45
  | `figura_list_figs` | optional `area`, `tag`, `limit`, `cursor` |
@@ -55,6 +56,15 @@ Enum values:
55
56
  - `mode`: `single | compare | variations`
56
57
  - `fidelity` (`figura_generate_fig`): `fast | high`
57
58
 
59
+ Capture ≠ commit: `figura_create_fig` / `figura_generate_fig` /
60
+ `figura_revise_fig` land the fig as a workspace **draft** — hosted and viewable
61
+ via `viewUrl`, visible to its author, but not in the team library and silent
62
+ for teammates. **Committing** (`figura_commit_fig`, or `commit: true` on the
63
+ final create/revise) puts it in the team library and notifies the team;
64
+ commit only the session's final recommendation. Publishing
65
+ (`figura_publish_fig`) is just the public share link and stays orthogonal —
66
+ a shared draft's URL resolves fine.
67
+
58
68
  Each tool returns its JSON result as a text content block; failures return
59
69
  `isError: true` with the server's error message.
60
70
 
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{promises as N,readFileSync as f}from"node:fs";import{homedir as I}from"node:os";import{dirname as y,join as M}from"node:path";import S from"axios";import{McpServer as z}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as h}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as W}from"zod";function A(K,Q,Z){return K.toUpperCase()!=="GET"&&Q==="global"&&!Z}var n="https://api.figura.so",p="mcp",F=M(I(),".figura","config.json"),l=M(".figura","config.json");async function j(K){try{let Q=JSON.parse(await N.readFile(K,"utf8"));if(Q&&typeof Q==="object")return Q}catch{}return{}}async function u(K=process.cwd()){let Q=I(),Z=K;for(;;){if(Z!==Q){let q=M(Z,l);try{return await N.access(q),q}catch{}}let $=y(Z);if($===Z)return null;Z=$}}async function k(){let K=await u(),Q=K?await j(K):{},Z=await j(F),$={...Z,...Q},q=process.env.FIGURA_TOKEN||$.token||void 0,D=process.env.FIGURA_TOKEN?"env":Q.token?"project":Z.token?"global":"none",J=(process.env.FIGURA_API_URL||$.apiUrl||n).replace(/\/+$/,"");return{token:q,apiUrl:J,team:$.team,source:D,configPath:D==="project"?K:D==="global"?F:null}}async function H(K,Q={}){let Z=await k(),$=Z.token;if(!$)throw Error("No Figura token. Run `figura link` in this repo (or `figura login`, or set FIGURA_TOKEN).");let q=(Q.method??"GET").toUpperCase();if(A(q,Z.source,process.env.FIGURA_ALLOW_GLOBAL==="1"))throw Error(`this project isn't linked to a Figura team — the global credential${Z.team?` (team '${Z.team}')`:""} would receive this write. Ask the user to run \`figura link\` in this repo (or set FIGURA_ALLOW_GLOBAL=1 to use the global team deliberately).`);let D=`${Z.apiUrl}${K.startsWith("/")?"":"/"}${K}`,J={Accept:"application/json",Authorization:`Bearer ${$}`,"X-Figura-Source":p},R;if(Q.body!==void 0)J["Content-Type"]="application/json",R=JSON.stringify(Q.body);let B=await S.request({url:D,method:Q.method??"GET",headers:J,data:R,validateStatus:()=>!0,transformRequest:[(U)=>U],transformResponse:[(U)=>U]}),C=typeof B.data==="string"?B.data:"",G=void 0;if(C)try{G=JSON.parse(C)}catch{G=C}if(B.status<200||B.status>=300){let U=G&&typeof G==="object"&&G!==null&&"error"in G?String(G.error):typeof G==="string"&&G?G:B.statusText||`HTTP ${B.status}`;throw Error(`${U} (HTTP ${B.status})`)}return G}var O=2500,c=600000,i=60000,v=(K)=>new Promise((Q)=>setTimeout(Q,K));async function d(K){let Q=Date.now(),Z=null;for(;;){let $;try{$=await H(`/v1/jobs/${encodeURIComponent(K)}`),Z=null}catch(q){if(Z??=Date.now(),Date.now()-Z>=i){let D=q instanceof Error?q.message:String(q);throw Error(`Lost contact with the Figura API while waiting for job ${K} (${D}). `+`The job may still complete — resume by checking GET /v1/jobs/${K} instead of regenerating.`)}await v(O);continue}if($.status==="done")return $;if($.status==="error")throw Error($.error||"The generation job failed. Please retry.");if(Date.now()-Q>c)throw Error(`Timed out waiting for job ${K} — it may still complete; `+`check GET /v1/jobs/${K} instead of regenerating.`);await v(O)}}function V(K){return{content:[{type:"text",text:JSON.stringify(K,null,2)}]}}function Y(K){return{content:[{type:"text",text:K instanceof Error?K.message:String(K)}],isError:!0}}var x=()=>W.string().optional().describe("Product area / surface the fig belongs to, e.g. 'dashboard', 'settings', 'onboarding'. Free text."),w=["portrait","landscape","both"],P=["single","compare","variations"],t=(()=>{try{let K=JSON.parse(f(new URL("../package.json",import.meta.url),"utf8"));return typeof K.version==="string"?K.version:"0.0.0"}catch{return"0.0.0"}})(),X=new z({name:"figura",version:t});X.registerTool("figura_whoami",{title:"Figura: whoami",description:"Return the identity (team, actor, scopes) for the configured Figura token, plus `binding` — which config tier supplied it (env | project | global). A `global` binding means this project is not linked to a team and writes will be refused; ask the user to run `figura link` in the repo.",inputSchema:{}},async()=>{try{let K=await H("/v1/whoami"),{source:Q,configPath:Z}=await k();return V({...K,binding:{source:Q,configPath:Z}})}catch(K){return Y(K)}});X.registerTool("figura_set_brand",{title:"Figura: set brand profile",description:'Set (upsert) the team brand profile that the fig generator renders against. Use this to "introduce a repo to Figura": scan the codebase for its design system, synthesize the brand, then push it here. `name` is required; `appDescription` is a one-clause product summary; `tokensBlock` is the freeform design-token reference (colors + hex + role, type scale + faces, spacing, the brand gradient, plus voice/conventions); `surfaces` is the product\'s own key areas/sections (e.g. "Feed", "Settings", "Checkout"), NOT another product\'s — drives the Studio Area picker. This is a partial merge — fields you omit keep their saved value (pass an explicit empty string / empty array to clear one), so a name-only update will not wipe existing tokens or surfaces. Editing the team brand — confirm with the user before calling.',inputSchema:{name:W.string().describe("Brand / app name (required)."),appDescription:W.string().optional().describe("One clause on what the product is + who it is for."),tokensBlock:W.string().optional().describe("Freeform plain-text design-token reference: colors (hex + role), type scale + faces, spacing, the brand gradient, and voice/conventions. Injected verbatim into the fig-generation prompt."),surfaces:W.array(W.string()).optional().describe(`The product's own key areas/sections (e.g. ["Feed", "Settings", "Checkout"]) — read from the repo's real navigation/routes, never invented or copied from another product. Drives the Studio Area picker; capped at 24 areas / 40 characters each. Pass [] to clear.`)}},async(K)=>{try{let Q={name:K.name};if(K.appDescription!==void 0)Q.appDescription=K.appDescription;if(K.tokensBlock!==void 0)Q.tokensBlock=K.tokensBlock;if(K.surfaces!==void 0)Q.surfaces=K.surfaces;return V(await H("/brand",{method:"PUT",body:Q}))}catch(Q){return Y(Q)}});var g={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",avif:"image/avif",svg:"image/svg+xml"},L=5242880;function E(K){let Q=K.split(/[?#]/)[0].split(".").pop()?.toLowerCase()??"";return g[Q]}async function _(K,Q){let Z,$=Q;if(/^https?:\/\//i.test(K)){let q=await S.get(K,{responseType:"arraybuffer",maxContentLength:L,timeout:30000});if(Z=Buffer.from(q.data).toString("base64"),!$){let D=String(q.headers["content-type"]??"").split(";")[0].trim().toLowerCase();$=D.startsWith("image/")?D:E(K)}}else{let{size:q}=await N.stat(K);if(q>L)throw Error(`'${K}' is ${(q/1048576).toFixed(1)}MB — over the ${L/1048576}MB asset limit.`);if(Z=(await N.readFile(K)).toString("base64"),!$)$=E(K)}if(!$)throw Error(`Could not determine an image content-type for '${K}' — pass contentType (e.g. image/png).`);return{data:Z,contentType:$}}async function o(K,Q){let{data:Z,contentType:$}=await _(K,Q),q=await H("/v1/assets",{method:"POST",body:{data:Z,contentType:$}});if(!q||typeof q.url!=="string")throw Error("Asset upload did not return a URL.");return q.url}var T=()=>W.array(W.object({source:W.string().describe("The image — a local file path or an http(s) URL (e.g. a screenshot)."),match:W.string().optional().describe('Exact string in the HTML to replace with the hosted URL (defaults to `source` — i.e. write <img src="<source>"> in the HTML).')})).optional().describe("Images to attach: each is uploaded to durable figura hosting (stable non-expiring URL, allowed by the fig CSP) and substituted into the HTML in place of `match`/`source`. Use for real screenshots/photos/logos instead of base64 data-URIs (HTML size cap) or external URLs (expire/CSP-blocked).");async function b(K,Q){if(!Q?.length)return{html:K,uploaded:[]};let Z=K,$=[];for(let q of Q){let D=q.match??q.source;if(!Z.includes(D))throw Error(`images: '${D}' does not occur in the html — reference it (e.g. <img src="${D}">) so it can be swapped for the hosted URL, or set 'match'.`);let J=await o(q.source);Z=Z.split(D).join(J),$.push({source:q.source,url:J})}return{html:Z,uploaded:$}}function m(K,Q){if(!Q.length)return K;if(K&&typeof K==="object"&&!Array.isArray(K))return{...K,assets:Q};return{result:K,assets:Q}}X.registerTool("figura_create_fig",{title:"Figura: create fig",description:"Create a new visualization from an HTML document — this HOSTS it (the create IS the hosting; there is no separate \"land it in Figura\" step). Every create returns `viewUrl`: ALWAYS a link you can open for approval, whether or not the fig is published — the public share url once published, else a team-scoped viewer link only signed-in teammates can open. Do NOT pass publish:true just to get something viewable — omit it (default false) so the fig lands team-private, open `viewUrl` for review, and only publish when the human wants a link they can share outside the team (figura_publish_fig, once approved). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only (never blocks the create). If it comes back non-empty, read it and consider a figura_revise_fig call that binds the missing tokens before telling the user you're done. To include REAL images (screenshots/photos/logos), pass them via `images` — they are uploaded to durable figura hosting and their stable URLs substituted into the HTML. TIP — injected components: if your HTML shows phone frames (a compare/multi-screen mock), wrap each state in <div class=\"fig-device portrait|landscape\"><div class=\"fig-screen\">…</div><div class=\"fig-di\"></div><div class=\"fig-homebar\"></div></div> and do NOT style those classes — Figura injects accurate iPhone frame CSS at save. Same for an iOS status bar (<div class=\"fig-statusbar\" data-time=\"9:41\"></div>), a compare control bar (.fig-controls/.fig-seg), and the .frame-web browser window.",inputSchema:{html:W.string().describe("The full HTML document for the visualization."),images:T(),title:W.string().optional().describe("Title (otherwise derived from the HTML)."),description:W.string().optional(),area:x(),tags:W.array(W.string()).optional(),orientation:W.enum(w).optional(),mode:W.enum(P).optional(),model:W.string().optional().describe("Model attribution."),device:W.string().optional().describe("Catalog iPhone id the fig targets (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3') — keys the injected frame/safe-area geometry."),issue:W.number().optional().describe("Linked issue number."),publish:W.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team."),prompt:W.string().optional().describe("OPTIONAL: the brief that produced this HTML, if there was one — you wrote the HTML yourself, so Figura never sees a prompt unless you pass it here. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig — lets teammates see 'why' this fig exists.")}},async(K)=>{try{let{html:Q,uploaded:Z}=await b(K.html,K.images),$={html:Q};for(let q of["title","description","area","tags","orientation","mode","model","device","issue","publish","prompt"])if(K[q]!==void 0)$[q]=K[q];return V(m(await H("/v1/figs",{method:"POST",body:$}),Z))}catch(Q){return Y(Q)}});X.registerTool("figura_upload_asset",{title:"Figura: upload asset",description:"Upload a durable image asset and get back a STABLE, figura-hosted URL that never expires — use it in a fig's <img src> instead of a base64 data-URI (bloats the HTML past the publish size) or an external URL that expires (Instagram/CDN signed links). The fig CSP allows figura-hosted images. Provide `path` (a local image file), `url` (an http(s) image to fetch and re-host), or `data` (base64 bytes) + `contentType`. Accepts png/jpeg/gif/webp/avif/svg, up to 5MB. Returns `{ url }`.",inputSchema:{path:W.string().optional().describe("Local path to an image file to upload (content-type inferred from the extension)."),url:W.string().optional().describe("http(s) URL of an image to fetch and re-host (alternative to `path`) — use for expiring/signed source URLs so the fig keeps rendering after they die."),data:W.string().optional().describe("Base64-encoded image bytes (alternative to `path`/`url`). Requires `contentType`."),contentType:W.string().optional().describe("MIME type, e.g. image/png. Required with `data`; inferred from the extension/response for `path`/`url`.")}},async(K)=>{try{let Q=K.path??K.url;if(Q){let{data:Z,contentType:$}=await _(Q,K.contentType);return V(await H("/v1/assets",{method:"POST",body:{data:Z,contentType:$}}))}if(!K.data)throw Error("Provide `path` (a local image file), `url` (an http(s) image), or `data` (base64 bytes).");if(!K.contentType)throw Error("`contentType` is required with `data` — e.g. image/png.");return V(await H("/v1/assets",{method:"POST",body:{data:K.data,contentType:K.contentType}}))}catch(Q){return Y(Q)}});X.registerTool("figura_generate_fig",{title:"Figura: generate fig",description:"Generate a new fig server-side from a natural-language brief, then persist it — this HOSTS it (the create IS the hosting). The engine runs Figura's on-brand generation — you do NOT write the HTML; just describe the screen. SPENDS TEAM CREDITS: only use when the user explicitly opts for a server render; otherwise default to writing the HTML yourself and landing it with figura_create_fig (free, uses the calling model). Runs as an async server job; this call polls until the fig lands (typically 30–90s). Returns the created fig (id, slug) and `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published). Requires an active paid subscription.",inputSchema:{prompt:W.string().describe("A natural-language brief describing the screen/UI to generate."),area:x(),orientation:W.enum(w).optional(),mode:W.enum(P).optional(),platform:W.enum(["mobile","web"]).optional().describe("Output platform: 'mobile' (iPhone, default) or 'web' (browser frame)."),device:W.string().optional().describe("Catalog iPhone id to target (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3'). Mobile only; default iphone-16."),title:W.string().optional().describe("Title (otherwise derived from the generated HTML)."),fidelity:W.enum(["fast","high"]).optional().describe("Generation fidelity (default fast)."),publish:W.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team.")}},async(K)=>{try{let Q={prompt:K.prompt};for(let $ of["area","orientation","mode","platform","device","title","publish"])if(K[$]!==void 0)Q[$]=K[$];if(K.fidelity!==void 0)Q.model=K.fidelity;Q.async=!0;let Z=await H("/v1/figs/generate",{method:"POST",body:Q});if(Z&&typeof Z==="object"&&typeof Z.jobId==="string"){let $=await d(Z.jobId),q=$.result??{};return V(q.fig??$.result)}return V(Z)}catch(Q){return Y(Q)}});X.registerTool("figura_revise_fig",{title:"Figura: revise fig",description:"Create a new revision (version) of an existing fig from updated HTML — this HOSTS it, same as create. Returns the new fig (id, slug, version) and `viewUrl` — ALWAYS a link to open and re-review. Publishing is NOT inherited from the fig being revised: the new revision defaults to unpublished (a team-scoped viewer `viewUrl`) even if the fig it revises is currently live — the existing public link keeps serving the OLD version until you pass `publish: true` on THIS call, which republishes at the SAME stable link (same publicId across the chain). The new version joins the original fig's chain — it does not replace it in place. Use list_revisions for the chain (rootFig + every version). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only. If it comes back non-empty, consider one more revision that binds the missing tokens. To include REAL images (screenshots/photos/logos), pass them via `images` — uploaded to durable figura hosting, stable URLs substituted into the HTML.",inputSchema:{id:W.string().describe("The fig to revise — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),html:W.string().describe("The full updated HTML document."),images:T(),title:W.string().optional().describe("Title (otherwise derived from the HTML)."),description:W.string().optional(),area:W.string().optional().describe("Product area / surface; free text. Inherited from the parent if omitted."),tags:W.array(W.string()).optional(),orientation:W.enum(w).optional().describe("Inherited from the parent if omitted."),mode:W.enum(P).optional(),model:W.string().optional().describe("Model attribution."),issue:W.number().optional().describe("Linked issue number."),publish:W.boolean().optional().describe("Publish the new revision to a shareable public URL (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately. If the fig being revised is ALREADY published, pass true here too or its public link keeps serving the prior version."),prompt:W.string().optional().describe("OPTIONAL: the brief/ask that produced this revision, if there was one. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig.")}},async(K)=>{try{let{html:Q,uploaded:Z}=await b(K.html,K.images),$={html:Q};for(let q of["title","description","area","tags","orientation","mode","model","issue","publish","prompt"])if(K[q]!==void 0)$[q]=K[q];return V(m(await H(`/v1/figs/${encodeURIComponent(K.id)}/revise`,{method:"POST",body:$}),Z))}catch(Q){return Y(Q)}});X.registerTool("figura_list_revisions",{title:"Figura: list revisions",description:"List the full revision chain for a fig (all versions sharing a root), oldest to newest.",inputSchema:{id:W.string().describe("Any fig in the chain — its id (`fig_…`), a UUID, share link/URL, or publicId.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.id)}/revisions`))}catch(Q){return Y(Q)}});X.registerTool("figura_publish_fig",{title:"Figura: publish fig",description:"Publish (or unpublish) an existing fig to its shareable public URL. The fig is ALREADY hosted and viewable team-privately without this (figura_create_fig/figura_revise_fig's `viewUrl`) — only call this once the human wants a link they can share outside the team. Requires a paid plan. Returns { isPublic, url, publicId, viewUrl }.",inputSchema:{id:W.string().describe("The fig to publish — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),isPublic:W.boolean().optional().describe("Set false to unpublish (make private). Defaults to true (publish).")}},async(K)=>{try{let Q={};if(K.isPublic!==void 0)Q.isPublic=K.isPublic;return V(await H(`/v1/figs/${encodeURIComponent(K.id)}/publish`,{method:"POST",body:Q}))}catch(Q){return Y(Q)}});X.registerTool("figura_list_figs",{title:"Figura: list figs",description:"List visualizations for the team, optionally filtered by area or tag.",inputSchema:{area:x(),tag:W.string().optional(),limit:W.number().optional(),cursor:W.string().optional()}},async(K)=>{try{let Q=new URLSearchParams;if(K.area)Q.set("area",K.area);if(K.tag)Q.set("tag",K.tag);if(K.limit!==void 0)Q.set("limit",String(K.limit));if(K.cursor)Q.set("cursor",K.cursor);let Z=Q.toString();return V(await H(`/v1/figs${Z?`?${Z}`:""}`))}catch(Q){return Y(Q)}});X.registerTool("figura_get_fig",{title:"Figura: get fig",description:"Fetch a single visualization by id. The response carries `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published).",inputSchema:{id:W.string().describe("The fig — its id (`fig_…`), a UUID, a share link/URL, or its publicId.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.id)}`))}catch(Q){return Y(Q)}});X.registerTool("figura_comment",{title:"Figura: comment",description:"Add a comment to a visualization.",inputSchema:{fig_id:W.string().describe("The fig to comment on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),body:W.string().describe("Comment text.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.fig_id)}/comments`,{method:"POST",body:{body:K.body}}))}catch(Q){return Y(Q)}});X.registerTool("figura_request_feedback",{title:"Figura: request feedback",description:"Ask a teammate to review a fig. Resolve `from` to a team member (by name or email); they get a notification (in-app, push, email) and the discussion happens in the fig's comment thread. Use this when the user says they want a specific person's feedback/eyes/review on a fig. Returns { ok, request: { to, status }, figUrl }.",inputSchema:{id:W.string().describe("The fig to request feedback on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),from:W.string().describe("The teammate to ask, by name or email (resolved within the team)."),note:W.string().optional().describe("Optional message for them (e.g. what to look at).")}},async(K)=>{try{let Q={from:K.from};if(K.note!==void 0)Q.note=K.note;return V(await H(`/v1/figs/${encodeURIComponent(K.id)}/request-feedback`,{method:"POST",body:Q}))}catch(Q){return Y(Q)}});X.registerTool("figura_list_sources",{title:"Figura: list sources",description:"List the company-brain source-spine connectors for the team, with event counts and last-synced times.",inputSchema:{}},async()=>{try{return V(await H("/v1/sources"))}catch(K){return Y(K)}});X.registerTool("figura_sync_source",{title:"Figura: sync source",description:"Ingest new events from a connected source into the spine. Returns { ingested, fetched, nextCursor }.",inputSchema:{id:W.string().describe("The source id to sync (from figura_list_sources).")}},async(K)=>{try{return V(await H(`/v1/sources/${encodeURIComponent(K.id)}/sync`,{method:"POST"}))}catch(Q){return Y(Q)}});X.registerTool("figura_update_fig",{title:"Figura: update fig",description:"Update a fig's METADATA only (title/description/area/tags/status). Set status='archived' to soft-delete. To change the fig's HTML, use figura_revise_fig — it saves a new version and never destroys the old one. update cannot touch the HTML.",inputSchema:{id:W.string().describe("The fig to update — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),title:W.string().optional(),description:W.string().optional(),area:x(),tags:W.array(W.string()).optional().describe("Replaces the existing tags."),status:W.enum(["draft","published","archived"]).optional()}},async(K)=>{try{let Q={};if(K.title!==void 0)Q.title=K.title;if(K.description!==void 0)Q.description=K.description;if(K.area!==void 0)Q.area=K.area;if(K.tags!==void 0)Q.tags=K.tags;if(K.status!==void 0)Q.status=K.status;return V(await H(`/v1/figs/${encodeURIComponent(K.id)}`,{method:"PATCH",body:Q}))}catch(Q){return Y(Q)}});X.registerTool("figura_delete_fig",{title:"Figura: delete fig",description:"Archive a fig (soft-delete; leaves the library, public link stops resolving) or permanently delete it with hard=true.",inputSchema:{id:W.string().describe("The fig to delete — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),hard:W.boolean().optional().describe("Permanently delete instead of archive.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.id)}${K.hard?"?hard=true":""}`,{method:"DELETE"}))}catch(Q){return Y(Q)}});await X.connect(new h);
2
+ import{promises as N,readFileSync as y}from"node:fs";import{homedir as v}from"node:os";import{dirname as f,join as M}from"node:path";import S from"axios";import{McpServer as z}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as h}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as W}from"zod";function A(K,Q,Z){return K.toUpperCase()!=="GET"&&Q==="global"&&!Z}var n="https://api.figura.so",p="mcp",j=M(v(),".figura","config.json"),l=M(".figura","config.json");async function F(K){try{let Q=JSON.parse(await N.readFile(K,"utf8"));if(Q&&typeof Q==="object")return Q}catch{}return{}}async function u(K=process.cwd()){let Q=v(),Z=K;for(;;){if(Z!==Q){let q=M(Z,l);try{return await N.access(q),q}catch{}}let $=f(Z);if($===Z)return null;Z=$}}async function _(){let K=await u(),Q=K?await F(K):{},Z=await F(j),$={...Z,...Q},q=process.env.FIGURA_TOKEN||$.token||void 0,Y=process.env.FIGURA_TOKEN?"env":Q.token?"project":Z.token?"global":"none",J=(process.env.FIGURA_API_URL||$.apiUrl||n).replace(/\/+$/,"");return{token:q,apiUrl:J,team:$.team,source:Y,configPath:Y==="project"?K:Y==="global"?j:null}}async function H(K,Q={}){let Z=await _(),$=Z.token;if(!$)throw Error("No Figura token. Run `figura link` in this repo (or `figura login`, or set FIGURA_TOKEN).");let q=(Q.method??"GET").toUpperCase();if(A(q,Z.source,process.env.FIGURA_ALLOW_GLOBAL==="1"))throw Error(`this project isn't linked to a Figura team — the global credential${Z.team?` (team '${Z.team}')`:""} would receive this write. Ask the user to run \`figura link\` in this repo (or set FIGURA_ALLOW_GLOBAL=1 to use the global team deliberately).`);let Y=`${Z.apiUrl}${K.startsWith("/")?"":"/"}${K}`,J={Accept:"application/json",Authorization:`Bearer ${$}`,"X-Figura-Source":p},R;if(Q.body!==void 0)J["Content-Type"]="application/json",R=JSON.stringify(Q.body);let B=await S.request({url:Y,method:Q.method??"GET",headers:J,data:R,validateStatus:()=>!0,transformRequest:[(U)=>U],transformResponse:[(U)=>U]}),C=typeof B.data==="string"?B.data:"",G=void 0;if(C)try{G=JSON.parse(C)}catch{G=C}if(B.status<200||B.status>=300){let U=G&&typeof G==="object"&&G!==null&&"error"in G?String(G.error):typeof G==="string"&&G?G:B.statusText||`HTTP ${B.status}`;throw Error(`${U} (HTTP ${B.status})`)}return G}var O=2500,c=600000,i=60000,E=(K)=>new Promise((Q)=>setTimeout(Q,K));async function d(K){let Q=Date.now(),Z=null;for(;;){let $;try{$=await H(`/v1/jobs/${encodeURIComponent(K)}`),Z=null}catch(q){if(Z??=Date.now(),Date.now()-Z>=i){let Y=q instanceof Error?q.message:String(q);throw Error(`Lost contact with the Figura API while waiting for job ${K} (${Y}). `+`The job may still complete — resume by checking GET /v1/jobs/${K} instead of regenerating.`)}await E(O);continue}if($.status==="done")return $;if($.status==="error")throw Error($.error||"The generation job failed. Please retry.");if(Date.now()-Q>c)throw Error(`Timed out waiting for job ${K} — it may still complete; `+`check GET /v1/jobs/${K} instead of regenerating.`);await E(O)}}function V(K){return{content:[{type:"text",text:JSON.stringify(K,null,2)}]}}function D(K){return{content:[{type:"text",text:K instanceof Error?K.message:String(K)}],isError:!0}}var x=()=>W.string().optional().describe("Product area / surface the fig belongs to, e.g. 'dashboard', 'settings', 'onboarding'. Free text."),w=["portrait","landscape","both"],P=["single","compare","variations"],t=(()=>{try{let K=JSON.parse(y(new URL("../package.json",import.meta.url),"utf8"));return typeof K.version==="string"?K.version:"0.0.0"}catch{return"0.0.0"}})(),X=new z({name:"figura",version:t});X.registerTool("figura_whoami",{title:"Figura: whoami",description:"Return the identity (team, actor, scopes) for the configured Figura token, plus `binding` — which config tier supplied it (env | project | global). A `global` binding means this project is not linked to a team and writes will be refused; ask the user to run `figura link` in the repo.",inputSchema:{}},async()=>{try{let K=await H("/v1/whoami"),{source:Q,configPath:Z}=await _();return V({...K,binding:{source:Q,configPath:Z}})}catch(K){return D(K)}});X.registerTool("figura_set_brand",{title:"Figura: set brand profile",description:'Set (upsert) the team brand profile that the fig generator renders against. Use this to "introduce a repo to Figura": scan the codebase for its design system, synthesize the brand, then push it here. `name` is required; `appDescription` is a one-clause product summary; `tokensBlock` is the freeform design-token reference (colors + hex + role, type scale + faces, spacing, the brand gradient, plus voice/conventions); `surfaces` is the product\'s own key areas/sections (e.g. "Feed", "Settings", "Checkout"), NOT another product\'s — drives the Studio Area picker. This is a partial merge — fields you omit keep their saved value (pass an explicit empty string / empty array to clear one), so a name-only update will not wipe existing tokens or surfaces. Editing the team brand — confirm with the user before calling.',inputSchema:{name:W.string().describe("Brand / app name (required)."),appDescription:W.string().optional().describe("One clause on what the product is + who it is for."),tokensBlock:W.string().optional().describe("Freeform plain-text design-token reference: colors (hex + role), type scale + faces, spacing, the brand gradient, and voice/conventions. Injected verbatim into the fig-generation prompt."),surfaces:W.array(W.string()).optional().describe(`The product's own key areas/sections (e.g. ["Feed", "Settings", "Checkout"]) — read from the repo's real navigation/routes, never invented or copied from another product. Drives the Studio Area picker; capped at 24 areas / 40 characters each. Pass [] to clear.`)}},async(K)=>{try{let Q={name:K.name};if(K.appDescription!==void 0)Q.appDescription=K.appDescription;if(K.tokensBlock!==void 0)Q.tokensBlock=K.tokensBlock;if(K.surfaces!==void 0)Q.surfaces=K.surfaces;return V(await H("/brand",{method:"PUT",body:Q}))}catch(Q){return D(Q)}});var g={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",avif:"image/avif",svg:"image/svg+xml"},L=5242880;function I(K){let Q=K.split(/[?#]/)[0].split(".").pop()?.toLowerCase()??"";return g[Q]}async function k(K,Q){let Z,$=Q;if(/^https?:\/\//i.test(K)){let q=await S.get(K,{responseType:"arraybuffer",maxContentLength:L,timeout:30000});if(Z=Buffer.from(q.data).toString("base64"),!$){let Y=String(q.headers["content-type"]??"").split(";")[0].trim().toLowerCase();$=Y.startsWith("image/")?Y:I(K)}}else{let{size:q}=await N.stat(K);if(q>L)throw Error(`'${K}' is ${(q/1048576).toFixed(1)}MB — over the ${L/1048576}MB asset limit.`);if(Z=(await N.readFile(K)).toString("base64"),!$)$=I(K)}if(!$)throw Error(`Could not determine an image content-type for '${K}' — pass contentType (e.g. image/png).`);return{data:Z,contentType:$}}async function o(K,Q){let{data:Z,contentType:$}=await k(K,Q),q=await H("/v1/assets",{method:"POST",body:{data:Z,contentType:$}});if(!q||typeof q.url!=="string")throw Error("Asset upload did not return a URL.");return q.url}var T=()=>W.array(W.object({source:W.string().describe("The image — a local file path or an http(s) URL (e.g. a screenshot)."),match:W.string().optional().describe('Exact string in the HTML to replace with the hosted URL (defaults to `source` — i.e. write <img src="<source>"> in the HTML).')})).optional().describe("Images to attach: each is uploaded to durable figura hosting (stable non-expiring URL, allowed by the fig CSP) and substituted into the HTML in place of `match`/`source`. Use for real screenshots/photos/logos instead of base64 data-URIs (HTML size cap) or external URLs (expire/CSP-blocked).");async function m(K,Q){if(!Q?.length)return{html:K,uploaded:[]};let Z=K,$=[];for(let q of Q){let Y=q.match??q.source;if(!Z.includes(Y))throw Error(`images: '${Y}' does not occur in the html — reference it (e.g. <img src="${Y}">) so it can be swapped for the hosted URL, or set 'match'.`);let J=await o(q.source);Z=Z.split(Y).join(J),$.push({source:q.source,url:J})}return{html:Z,uploaded:$}}function b(K,Q){if(!Q.length)return K;if(K&&typeof K==="object"&&!Array.isArray(K))return{...K,assets:Q};return{result:K,assets:Q}}X.registerTool("figura_create_fig",{title:"Figura: create fig",description:"Create a new visualization from an HTML document — this HOSTS it (the create IS the hosting; there is no separate \"land it in Figura\" step). Every create returns `viewUrl`: ALWAYS a link you can open for approval, whether or not the fig is published — the public share url once published, else a team-scoped viewer link only signed-in teammates can open. The fig lands as a workspace DRAFT (`status: 'draft'`): hosted and viewable, visible to its author, but NOT in the team library and silent for teammates — iterate freely without claiming the team's attention, then COMMIT only the session's final recommendation (`commit: true` on that final create/revise, or figura_commit_fig). Do NOT pass publish:true just to get something viewable — omit it (default false) so the fig lands team-private, open `viewUrl` for review, and only publish when the human wants a link they can share outside the team (figura_publish_fig, once approved). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only (never blocks the create). If it comes back non-empty, read it and consider a figura_revise_fig call that binds the missing tokens before telling the user you're done. To include REAL images (screenshots/photos/logos), pass them via `images` — they are uploaded to durable figura hosting and their stable URLs substituted into the HTML. TIP — injected components: if your HTML shows phone frames (a compare/multi-screen mock), wrap each state in <div class=\"fig-device portrait|landscape\"><div class=\"fig-screen\">…</div><div class=\"fig-di\"></div><div class=\"fig-homebar\"></div></div> and do NOT style those classes — Figura injects accurate iPhone frame CSS at save. Same for an iOS status bar (<div class=\"fig-statusbar\" data-time=\"9:41\"></div>), a compare control bar (.fig-controls/.fig-seg), and the .frame-web browser window.",inputSchema:{html:W.string().describe("The full HTML document for the visualization."),images:T(),title:W.string().optional().describe("Title (otherwise derived from the HTML)."),description:W.string().optional(),area:x(),tags:W.array(W.string()).optional(),orientation:W.enum(w).optional(),mode:W.enum(P).optional(),model:W.string().optional().describe("Model attribution."),device:W.string().optional().describe("Catalog iPhone id the fig targets (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3') — keys the injected frame/safe-area geometry."),issue:W.number().optional().describe("Linked issue number."),publish:W.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team. Share link ONLY — does not commit the fig to the team library (that's `commit`)."),commit:W.boolean().optional().describe("Commit to the team library on create (sends status: 'published'). Default false — the fig lands as a workspace DRAFT: hosted + viewable via `viewUrl`, visible to its author, but not in the team library and silent for teammates. Set true ONLY when this write is the session's final recommendation (the fig the user asked for / signed off on); explorations and intermediate iterations stay drafts. Committing later: figura_commit_fig."),prompt:W.string().optional().describe("OPTIONAL: the brief that produced this HTML, if there was one — you wrote the HTML yourself, so Figura never sees a prompt unless you pass it here. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig — lets teammates see 'why' this fig exists.")}},async(K)=>{try{let{html:Q,uploaded:Z}=await m(K.html,K.images),$={html:Q};for(let q of["title","description","area","tags","orientation","mode","model","device","issue","publish","prompt"])if(K[q]!==void 0)$[q]=K[q];if(K.commit)$.status="published";return V(b(await H("/v1/figs",{method:"POST",body:$}),Z))}catch(Q){return D(Q)}});X.registerTool("figura_upload_asset",{title:"Figura: upload asset",description:"Upload a durable image asset and get back a STABLE, figura-hosted URL that never expires — use it in a fig's <img src> instead of a base64 data-URI (bloats the HTML past the publish size) or an external URL that expires (Instagram/CDN signed links). The fig CSP allows figura-hosted images. Provide `path` (a local image file), `url` (an http(s) image to fetch and re-host), or `data` (base64 bytes) + `contentType`. Accepts png/jpeg/gif/webp/avif/svg, up to 5MB. Returns `{ url }`.",inputSchema:{path:W.string().optional().describe("Local path to an image file to upload (content-type inferred from the extension)."),url:W.string().optional().describe("http(s) URL of an image to fetch and re-host (alternative to `path`) — use for expiring/signed source URLs so the fig keeps rendering after they die."),data:W.string().optional().describe("Base64-encoded image bytes (alternative to `path`/`url`). Requires `contentType`."),contentType:W.string().optional().describe("MIME type, e.g. image/png. Required with `data`; inferred from the extension/response for `path`/`url`.")}},async(K)=>{try{let Q=K.path??K.url;if(Q){let{data:Z,contentType:$}=await k(Q,K.contentType);return V(await H("/v1/assets",{method:"POST",body:{data:Z,contentType:$}}))}if(!K.data)throw Error("Provide `path` (a local image file), `url` (an http(s) image), or `data` (base64 bytes).");if(!K.contentType)throw Error("`contentType` is required with `data` — e.g. image/png.");return V(await H("/v1/assets",{method:"POST",body:{data:K.data,contentType:K.contentType}}))}catch(Q){return D(Q)}});X.registerTool("figura_generate_fig",{title:"Figura: generate fig",description:"Generate a new fig server-side from a natural-language brief, then persist it — this HOSTS it (the create IS the hosting). The engine runs Figura's on-brand generation — you do NOT write the HTML; just describe the screen. SPENDS TEAM CREDITS: only use when the user explicitly opts for a server render; otherwise default to writing the HTML yourself and landing it with figura_create_fig (free, uses the calling model). Runs as an async server job; this call polls until the fig lands (typically 30–90s). Returns the created fig (id, slug) and `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published). Like figura_create_fig, the generated fig lands as a workspace DRAFT — not in the team library, silent for teammates — commit the session's final pick with figura_commit_fig. Requires an active paid subscription.",inputSchema:{prompt:W.string().describe("A natural-language brief describing the screen/UI to generate."),area:x(),orientation:W.enum(w).optional(),mode:W.enum(P).optional(),platform:W.enum(["mobile","web"]).optional().describe("Output platform: 'mobile' (iPhone frame) or 'web' (browser frame). When omitted, the engine INFERS it from the brief — a desktop/web/browser/landing-page/dashboard brief renders web, an iPhone/mobile/app-screen brief renders mobile — falling back to mobile only when neither is signalled. Pass this to force a platform regardless of the wording."),device:W.string().optional().describe("Catalog iPhone id to target (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3'). Mobile only; default iphone-16."),title:W.string().optional().describe("Title (otherwise derived from the generated HTML)."),fidelity:W.enum(["fast","high"]).optional().describe("Generation fidelity (default fast)."),publish:W.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team.")}},async(K)=>{try{let Q={prompt:K.prompt};for(let $ of["area","orientation","mode","platform","device","title","publish"])if(K[$]!==void 0)Q[$]=K[$];if(K.fidelity!==void 0)Q.model=K.fidelity;Q.async=!0;let Z=await H("/v1/figs/generate",{method:"POST",body:Q});if(Z&&typeof Z==="object"&&typeof Z.jobId==="string"){let $=await d(Z.jobId),q=$.result??{};return V(q.fig??$.result)}return V(Z)}catch(Q){return D(Q)}});X.registerTool("figura_revise_fig",{title:"Figura: revise fig",description:"Create a new revision (version) of an existing fig from updated HTML — this HOSTS it, same as create. Returns the new fig (id, slug, version) and `viewUrl` — ALWAYS a link to open and re-review. The revision lands as a workspace DRAFT regardless of the parent's status — not in the team library, silent for teammates — so iterate freely; when a revision is the session's final agreed version, commit it (`commit: true` on that call, or figura_commit_fig). Publishing is NOT inherited from the fig being revised: the new revision defaults to unpublished (a team-scoped viewer `viewUrl`) even if the fig it revises is currently live — the existing public link keeps serving the OLD version until you pass `publish: true` on THIS call, which republishes at the SAME stable link (same publicId across the chain). The new version joins the original fig's chain — it does not replace it in place. Use list_revisions for the chain (rootFig + every version). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only. If it comes back non-empty, consider one more revision that binds the missing tokens. To include REAL images (screenshots/photos/logos), pass them via `images` — uploaded to durable figura hosting, stable URLs substituted into the HTML.",inputSchema:{id:W.string().describe("The fig to revise — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),html:W.string().describe("The full updated HTML document."),images:T(),title:W.string().optional().describe("Title (otherwise derived from the HTML)."),description:W.string().optional(),area:W.string().optional().describe("Product area / surface; free text. Inherited from the parent if omitted."),tags:W.array(W.string()).optional(),orientation:W.enum(w).optional().describe("Inherited from the parent if omitted."),mode:W.enum(P).optional(),model:W.string().optional().describe("Model attribution."),issue:W.number().optional().describe("Linked issue number."),publish:W.boolean().optional().describe("Publish the new revision to a shareable public URL (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately. If the fig being revised is ALREADY published, pass true here too or its public link keeps serving the prior version. Share link ONLY — does not commit the revision to the team library (that's `commit`)."),commit:W.boolean().optional().describe("Commit this revision to the team library (sends status: 'published'). Default false — the revision lands as a workspace DRAFT even when the fig it revises is committed. Set true ONLY when this revision is the session's final agreed version; intermediate iterations stay drafts. Committing later: figura_commit_fig."),prompt:W.string().optional().describe("OPTIONAL: the brief/ask that produced this revision, if there was one. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig.")}},async(K)=>{try{let{html:Q,uploaded:Z}=await m(K.html,K.images),$={html:Q};for(let q of["title","description","area","tags","orientation","mode","model","issue","publish","prompt"])if(K[q]!==void 0)$[q]=K[q];if(K.commit)$.status="published";return V(b(await H(`/v1/figs/${encodeURIComponent(K.id)}/revise`,{method:"POST",body:$}),Z))}catch(Q){return D(Q)}});X.registerTool("figura_list_revisions",{title:"Figura: list revisions",description:"List the full revision chain for a fig (all versions sharing a root), oldest to newest.",inputSchema:{id:W.string().describe("Any fig in the chain — its id (`fig_…`), a UUID, share link/URL, or publicId.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.id)}/revisions`))}catch(Q){return D(Q)}});X.registerTool("figura_commit_fig",{title:"Figura: commit fig",description:"Commit a fig to the team library (status draft → published). Machine-created figs land as workspace DRAFTS — hosted, viewable via `viewUrl`, visible to their author, but NOT in the team library and silent for teammates. Committing is the explicit act that puts one in the team library and notifies the team (digest-batched) — so commit ONLY the session's final recommendation (the fig the user asked for / signed off on), never every exploration or intermediate iteration. If the final fig is one you're about to create/revise anyway, prefer `commit: true` on that call over a separate commit here. Orthogonal to figura_publish_fig (the public share link): a shared draft's URL resolves fine, and committing creates no link. Idempotent. Pass `uncommit: true` to reverse it (published → draft; the share link is untouched). Returns { id, status, committed, alreadyCommitted, viewUrl }.",inputSchema:{id:W.string().describe("The fig to commit — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),uncommit:W.boolean().optional().describe("Set true to uncommit: the fig leaves the team library back to an author-visible workspace draft. Defaults to false (commit).")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.id)}/${K.uncommit?"uncommit":"commit"}`,{method:"POST",body:{}}))}catch(Q){return D(Q)}});X.registerTool("figura_publish_fig",{title:"Figura: publish fig",description:"Publish (or unpublish) an existing fig to its shareable public URL. The fig is ALREADY hosted and viewable team-privately without this (figura_create_fig/figura_revise_fig's `viewUrl`) — only call this once the human wants a link they can share outside the team. Share link ONLY — it does not put the fig in the team library (that's figura_commit_fig; a shared draft's URL resolves fine). Requires a paid plan. Returns { isPublic, url, publicId, viewUrl }.",inputSchema:{id:W.string().describe("The fig to publish — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),isPublic:W.boolean().optional().describe("Set false to unpublish (make private). Defaults to true (publish).")}},async(K)=>{try{let Q={};if(K.isPublic!==void 0)Q.isPublic=K.isPublic;return V(await H(`/v1/figs/${encodeURIComponent(K.id)}/publish`,{method:"POST",body:Q}))}catch(Q){return D(Q)}});X.registerTool("figura_list_figs",{title:"Figura: list figs",description:"List visualizations for the team, optionally filtered by area or tag.",inputSchema:{area:x(),tag:W.string().optional(),limit:W.number().optional(),cursor:W.string().optional()}},async(K)=>{try{let Q=new URLSearchParams;if(K.area)Q.set("area",K.area);if(K.tag)Q.set("tag",K.tag);if(K.limit!==void 0)Q.set("limit",String(K.limit));if(K.cursor)Q.set("cursor",K.cursor);let Z=Q.toString();return V(await H(`/v1/figs${Z?`?${Z}`:""}`))}catch(Q){return D(Q)}});X.registerTool("figura_get_fig",{title:"Figura: get fig",description:"Fetch a single visualization by id. The response carries `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published).",inputSchema:{id:W.string().describe("The fig — its id (`fig_…`), a UUID, a share link/URL, or its publicId.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.id)}`))}catch(Q){return D(Q)}});X.registerTool("figura_comment",{title:"Figura: comment",description:"Add a comment to a visualization.",inputSchema:{fig_id:W.string().describe("The fig to comment on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),body:W.string().describe("Comment text.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.fig_id)}/comments`,{method:"POST",body:{body:K.body}}))}catch(Q){return D(Q)}});X.registerTool("figura_request_feedback",{title:"Figura: request feedback",description:"Ask a teammate to review a fig. Resolve `from` to a team member (by name or email); they get a notification (in-app, push, email) and the discussion happens in the fig's comment thread. Use this when the user says they want a specific person's feedback/eyes/review on a fig. Returns { ok, request: { to, status }, figUrl }.",inputSchema:{id:W.string().describe("The fig to request feedback on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),from:W.string().describe("The teammate to ask, by name or email (resolved within the team)."),note:W.string().optional().describe("Optional message for them (e.g. what to look at).")}},async(K)=>{try{let Q={from:K.from};if(K.note!==void 0)Q.note=K.note;return V(await H(`/v1/figs/${encodeURIComponent(K.id)}/request-feedback`,{method:"POST",body:Q}))}catch(Q){return D(Q)}});X.registerTool("figura_list_sources",{title:"Figura: list sources",description:"List the company-brain source-spine connectors for the team, with event counts and last-synced times.",inputSchema:{}},async()=>{try{return V(await H("/v1/sources"))}catch(K){return D(K)}});X.registerTool("figura_sync_source",{title:"Figura: sync source",description:"Ingest new events from a connected source into the spine. Returns { ingested, fetched, nextCursor }.",inputSchema:{id:W.string().describe("The source id to sync (from figura_list_sources).")}},async(K)=>{try{return V(await H(`/v1/sources/${encodeURIComponent(K.id)}/sync`,{method:"POST"}))}catch(Q){return D(Q)}});X.registerTool("figura_mine_screens",{title:"Figura: mine screens",description:"Build the app's screen catalog from a connected git source's CURRENT file tree (framework-aware, convention-based — no file bodies, no fig generation, no credits). Returns { screens: [{slug,name,path,framework}], framework, count, commitSha, truncated }.",inputSchema:{id:W.string().describe("The git source id to mine (from figura_list_sources).")}},async(K)=>{try{return V(await H(`/v1/sources/${encodeURIComponent(K.id)}/mine-screens`,{method:"POST"}))}catch(Q){return D(Q)}});X.registerTool("figura_list_screens",{title:"Figura: list screens",description:"Read the team's mined screen catalog — the app's current screens ({slug,name,path,framework}) plus when it was last mined. Free, read-only. Run figura_mine_screens first to populate it.",inputSchema:{}},async()=>{try{return V(await H("/v1/sources/screens"))}catch(K){return D(K)}});X.registerTool("figura_update_fig",{title:"Figura: update fig",description:"Update a fig's METADATA only (title/description/area/tags/status). Set status='archived' to soft-delete. For library membership (draft ⇄ published) prefer figura_commit_fig — a status PATCH here is a silent metadata setter that never notifies the team. To change the fig's HTML, use figura_revise_fig — it saves a new version and never destroys the old one. update cannot touch the HTML.",inputSchema:{id:W.string().describe("The fig to update — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),title:W.string().optional(),description:W.string().optional(),area:x(),tags:W.array(W.string()).optional().describe("Replaces the existing tags."),status:W.enum(["draft","published","archived"]).optional()}},async(K)=>{try{let Q={};if(K.title!==void 0)Q.title=K.title;if(K.description!==void 0)Q.description=K.description;if(K.area!==void 0)Q.area=K.area;if(K.tags!==void 0)Q.tags=K.tags;if(K.status!==void 0)Q.status=K.status;return V(await H(`/v1/figs/${encodeURIComponent(K.id)}`,{method:"PATCH",body:Q}))}catch(Q){return D(Q)}});X.registerTool("figura_delete_fig",{title:"Figura: delete fig",description:"Archive a fig (soft-delete; leaves the library, public link stops resolving) or permanently delete it with hard=true.",inputSchema:{id:W.string().describe("The fig to delete — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),hard:W.boolean().optional().describe("Permanently delete instead of archive.")}},async(K)=>{try{return V(await H(`/v1/figs/${encodeURIComponent(K.id)}${K.hard?"?hard=true":""}`,{method:"DELETE"}))}catch(Q){return D(Q)}});await X.connect(new h);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figura-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Model Context Protocol server for the Figura visualization SaaS",
5
5
  "license": "MIT",
6
6
  "type": "module",