Let people get designs from their own AI assistant, such as Claude or ChatGPT, by serving the design tools as a Model Context Protocol (MCP) server. They add your server’s address, sign in with your product’s account, and their assistant does the work.
This guide assumes a Node.js HTTP server and a sign-in flow; every CoDesign and MCP term is explained where it first appears, and every SDK name is in the API Reference. Before you start, you need:
- the SDK,
@imgly/codesign-sdkon npm, a CoDesign license key from IMG.LY, and the executor image,imgly/codesign-executoron Docker Hub ( What You Need Before You Start) - a design store and, for your fonts and files, plugin modules ( Connect Your Storage)
The code on this page is a working MCP server from the
Agentic Design Manager starter kit
(src/hosting/cloud/mcp/ in its repository). server.ts and auth.ts work in
any Node.js server as they are; index.ts connects them to that kit, and
Your Hosting says what each of its values is in your product.
Its sign-in is a fixed token per customer; MCP’s full sign-in is yours to build
(How MCP’s Sign-In Fits).
The Pieces#
MCP is an open standard for giving an AI assistant tools. An MCP server lists its tools (a name, a description, and the arguments as a JSON Schema) and runs them when the assistant, the MCP client, calls them. MCP’s transport over the internet is Streamable HTTP: the client sends each message as an HTTP POST to one URL, and the server answers with JSON or a stream.
Choose an MCP server when your customers already work in an assistant: its subscription pays for the model, and its chat is the one they see. When the chat belongs inside your product, with your prompt and your model, add a design chat instead ( Add a Design Chat). For every way to reach the agent, see Choose How Users Reach the Agent.
The assistant calls your MCP server over HTTPS with the person’s token, and your server calls the executor over HTTP:
- The assistant holds the model and the chat, and usually asks the person before a tool call runs. It also runs the agent loop, the code that sends the model the conversation and runs each tool it asks for.
- Your MCP server is a small Node.js HTTP route,
mcpHandlerbelow. It signs each request in and, per MCP session, callsprepareCoDesign, the SDK’s one function: given where your executors are, your design store, and your plugin modules (the JavaScript files that give the design engine your fonts, colors, and files), it returns an object,codesignon this page, with the tools and theirinstructionsfor the model. The other guides call this process the agent runner. - The executor is IMG.LY’s published Docker image, run unchanged: an HTTP server that holds CE.SDK, IMG.LY’s design engine, runs the JavaScript the model writes, and charges every export. To run it, see Host the Executor.
- Your design store keeps each design. The SDK reads and writes it through
the
designsoption ( The Design Store).
The MCP Server#
The SDK’s tools are already shaped like MCP tools: each has a name, a
description, an inputSchema (JSON Schema), annotations
(readOnlyHint: true on the tools that change nothing), and a call that
returns MCP’s result shape: text and images. So the server is a mapping,
written once, with @modelcontextprotocol/sdk, MCP’s own TypeScript SDK. The file has two parts: mcpServerFor turns
one codesign into an MCP server (McpServer, from MCP’s SDK), and
mcpHandler is your HTTP route, which signs each request in and keeps one such
server per MCP session. Caller is what your sign-in says about who is
calling: a tenant (one of your customers, whose designs, brand, and files a
session works on) and a user.
// The design tools as a remote MCP server, over Streamable HTTP: one prepareCoDesign per MCP session.import { randomUUID } from 'node:crypto';import type { IncomingMessage, ServerResponse } from 'node:http';
import type { CoDesign } from '@imgly/codesign-sdk';import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';import { CallToolRequestSchema, isInitializeRequest, ListToolsRequestSchema, type CallToolResult, type Tool} from '@modelcontextprotocol/sdk/types.js';
/** Who is calling: what your sign-in says about the token's owner. */export interface Caller { tenant: string; user: string;}
/** One MCP server for one CoDesign: its tools, and its instructions as the server's. Each `design` a call names goes in `named`. */export function mcpServerFor( codesign: CoDesign, named = new Set<string>()): McpServer { const mcp = new McpServer( { name: 'agentic-design-manager', version: '1.0.0' }, { capabilities: { tools: {} }, instructions: codesign.instructions } ); // The tools carry JSON Schema already, so they are served as they are rather than through `registerTool`'s zod. mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: codesign.tools.map( ({ name, description, inputSchema, annotations }): Tool => ({ name, description, inputSchema: inputSchema as Tool['inputSchema'], annotations }) ) })); mcp.server.setRequestHandler( CallToolRequestSchema, async ({ params }, { signal }): Promise<CallToolResult> => { const tool = codesign.tools.find((t) => t.name === params.name); if (tool === undefined) return failed(`There is no tool ${params.name}.`); const design = params.arguments?.design; if (typeof design === 'string') named.add(design); try { // A refusal the model can act on comes back as an `isError` result; a cancelled call aborts `signal`. return await tool.call(params.arguments ?? {}, { signal }); } catch (error) { // Nothing the model can fix (no credits, an executor or your store down): yours to log and alert on. console.error(`${params.name} failed:`, error); return failed( `The design service could not finish ${params.name}. Tell the person; trying again now will not help.` ); } } ); return mcp;}
const failed = (text: string): CallToolResult => ({ isError: true, content: [{ type: 'text', text }]});
/** Your HTTP route for MCP clients: `(req, res) => …` for `node:http`, Express, or any Node server. */export function mcpHandler(opts: { /** Yours: the caller this request's credentials name, or `undefined` to answer 401. */ authenticate(req: IncomingMessage): Promise<Caller | undefined>; /** Yours: the design tools for a caller, in a conversation of its own for each MCP session. */ prepare(caller: Caller, conversation: string): Promise<CoDesign>; /** Where clients learn how to sign in (OAuth protected-resource metadata), sent with every 401. */ resourceMetadata?: string;}): (req: IncomingMessage, res: ServerResponse) => Promise<void> { const sessions = new Map< string, { caller: Caller; transport: StreamableHTTPServerTransport } >(); return async (req, res) => { const caller = await opts.authenticate(req); if (caller === undefined) { res .writeHead(401, { 'www-authenticate': opts.resourceMetadata !== undefined ? `Bearer resource_metadata="${opts.resourceMetadata}"` : 'Bearer' }) .end(); return; } const id = req.headers['mcp-session-id']; let session = typeof id === 'string' ? sessions.get(id) : undefined; // A session id is no credential: another caller holding it finds nothing. if ( session !== undefined && (session.caller.tenant !== caller.tenant || session.caller.user !== caller.user) ) { session = undefined; } let body: unknown; try { body = req.method === 'POST' ? await readJSON(req) : undefined; } catch { res.writeHead(400).end(); return; } if (session === undefined) { if (id !== undefined || !isInitializeRequest(body)) { res.writeHead(id !== undefined ? 404 : 400).end(); return; } const sessionId = randomUUID(); const codesign = await opts.prepare( caller, `${caller.tenant}.${sessionId}` ); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId }); const named = new Set<string>(); transport.onclose = () => { sessions.delete(sessionId); for (const design of named) void codesign.release({ design }).catch(() => undefined); }; await mcpServerFor(codesign, named).connect(transport); session = { caller, transport }; sessions.set(sessionId, session); } await session.transport.handleRequest(req, res, body); };}
async function readJSON(req: IncomingMessage): Promise<unknown> { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const text = Buffer.concat(chunks).toString('utf8'); return text === '' ? undefined : (JSON.parse(text) as unknown);}What each part does, and why:
instructions.codesign.instructionsis a few sentences from IMG.LY: read the handbook (IMG.LY’s guide to writing design code, which one of the tools serves to the model) before the first edit, look at a preview of each change, and carry the design’s version. The version is made from the revision your design store returns with each save. The model passes it back with each edit, so it never edits a copy someone else has since changed. MCP sends a server’sinstructionsto the client when a session starts, and the assistant adds them to the model’s prompt. Leave them out and nothing fails: the model just skips the handbook unless a tool’s description persuades it, and learns about versions from its first refused edit.- Tools are listed as they are.
McpServer.registerTooltakes zod schemas (zod is a TypeScript validation library). The SDK’s tools carry plain JSON Schema, so the server answers MCP’s two requests,tools/listandtools/call, itself, throughmcp.server, the low-level server underMcpServer. - Results pass through unchanged. A tool result is
{ content, isError? }, with parts{ type: 'text', text }and{ type: 'image', data, mimeType }, a subset of MCP’s result. A preview returns the rendered page as an image part, and the assistant shows it to the model. Pass the tools’ names, descriptions, and schemas through as they are: they can change in any release, so nothing in your server should match on them. - Two kinds of failure. When the model made a mistake (its code threw, a
version is stale), the tool returns
isError: truewith words that tell the model what to do. When something the model cannot fix went wrong (no credits, an executor, or your store down), the call rejects with aCoDesignErrorthat has akind(test for one with the SDK’sisCoDesignError). The server logs that for you and tells the model to stop and say so. It treats any throw that way, a bug of yours included. - Canceling reaches the tool. When the person stops a call, the client
sends MCP’s cancel, and the
signalthe server passes totool.callaborts. The SDK stops waiting. Work already running on the executor finishes, and its answer is dropped. A canceled export closes its request to the executor, which charges nothing when the cancel arrives before it claims the charge. - One
prepareCoDesignper MCP session. A client opens a session with aninitializerequest, and the server answers with a session id (theMcp-Session-Idheader) that the client sends with every later request. Each session gets its ownconversation,${tenant}.${sessionId}, whichmcpHandlerhands to yourprepare(your function that callsprepareCoDesign, under Your Hosting). All of a conversation’s designs open on one executor, and that executor never serves another conversation, so one person’s model code never sees another’s design ( Security). - A session id is not a credential. Every request is authenticated again, and a session answers only the caller, tenant, and user that opened it. Another caller presenting that id gets 404.
Who Is Calling#
authenticate turns a request into a Caller: the tenant whose designs and
brand the session works on, and the user. This one knows a fixed token per
tenant, from a tenant=token,… list in the MCP_TOKENS environment variable:
// Yours to replace: who is calling the MCP server. This version knows a fixed token per tenant; your product checks a// token its own sign-in issued, and with MCP's OAuth that sign-in is the authorization server the client signs in at.import { createHash, timingSafeEqual } from 'node:crypto';import type { IncomingMessage } from 'node:http';
import type { Caller } from './server.js';
/** `spec` is `tenant=token,…`; the caller is the tenant whose token the request carries as `Authorization: Bearer`. */export function bearerTokens( spec: string): (req: IncomingMessage) => Promise<Caller | undefined> { const digest = (token: string) => createHash('sha256').update(token).digest(); const known = spec .split(',') .map((pair) => pair.trim()) .filter((pair) => pair.indexOf('=') > 0) .map((pair) => { const at = pair.indexOf('='); return { tenant: pair.slice(0, at), digest: digest(pair.slice(at + 1)) }; }); return async (req) => { const token = /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1]; if (token === undefined) return undefined; const presented = digest(token); const match = known.find((k) => timingSafeEqual(k.digest, presented)); return match && { tenant: match.tenant, user: match.tenant }; };}Replace this with your sign-in. In a product, the token is one your sign-in issued: verify it the way your API already verifies its tokens (a JSON Web Token (JWT) signature and audience, or a session lookup), and read the tenant and the user from it. A request that fails gets 401. The fixed-token version has no users of its own, so the user is the tenant.
How MCP’s Sign-In Fits#
Hosted assistants usually do not let a person paste a token. They follow MCP’s authorization spec, which is OAuth 2.1, the standard by which an app gets a token to act for a person on another service:
-
The client calls your server without a token. You answer 401 with
WWW-Authenticate: Bearer resource_metadata="https://<your server>/.well-known/oauth-protected-resource"(theresourceMetadataoption ofmcpHandlersends exactly that; see The MCP Server). -
That URL serves a small JSON document: your server’s URL as
resource, and your sign-in’s issuer URL first inauthorization_servers:{"resource": "https://mcp.acme.com","authorization_servers": ["https://login.acme.com"]} -
The client registers itself with your sign-in, by dynamic client registration or a client ID metadata document. It sends the person to your sign-in page with Proof Key for Code Exchange (PKCE, S256), and gets an access token for your server.
-
Every MCP request carries that token.
authenticateverifies it, as above.
Your sign-in is the authorization server. Most identity providers (Auth0, Clerk, Okta, Entra ID, Cognito) can play that part. Check that yours supports dynamic client registration or client ID metadata documents, and accept the redirect URI of each assistant you support (its documentation names it). Hosted assistants call your server from their cloud, so it must be on public HTTPS.
Your Hosting#
prepare builds the design tools for one MCP session, from pieces the other
guides build. In the starter kit, forTenant(tenant) returns that tenant’s
executors, design store, and plugins, and an export goes to the kit’s file
store:
// The Agentic Design Manager's tools as an MCP server, on the cloud hosting: IMG.LY's executor image, and the tenant's// designs and files in the store. A server process; nothing in the page imports this folder.import { randomUUID } from 'node:crypto';
import { prepareCoDesign } from '@imgly/codesign-sdk';
import { sessionPlugins } from '../../../plugins.js';import { httpFiles } from '../http-files.js';import { cloudSettings, forTenant } from '../tenant.js';import { bearerTokens } from './auth.js';import { mcpHandler } from './server.js';
const EXTENSIONS: Record<string, string> = { jpeg: 'jpg', pdfx: 'pdf' };
export function designPlatformMCP(env: { EXECUTOR_URLS?: string; VITE_STORE_URL?: string; STORE_TOKEN?: string; /** `tenant=token,…`: who may call, as `Authorization: Bearer <token>`. */ MCP_TOKENS?: string; /** Your IMG.LY key for the gateway: lets the agent generate images, charged to your IMG.LY account. */ IMGLY_GATEWAY_API_KEY?: string;}) { const settings = cloudSettings(env); const files = httpFiles({ url: settings.store, ...(settings.token !== undefined ? { token: settings.token } : {}) }); const gatewayKey = env.IMGLY_GATEWAY_API_KEY || undefined; return mcpHandler({ authenticate: bearerTokens(env.MCP_TOKENS ?? ''), async prepare(caller, conversation) { // The page's gateway arguments: the cloud hosting does not use them; this server's own key is below. const at = await forTenant(caller.tenant, { gatewayBaseURL: '', meteringBaseURL: '', bearer: async () => undefined }); return prepareCoDesign({ conversation, // Exports run on the executor image and are charged to the CoDesign licence key it runs with. A server // cannot ask the person first: the MCP client's approval of each tool call is that confirmation. executors: at.executors, designs: at.designs, plugins: sessionPlugins(at.plugins), gateway: gatewayKey !== undefined ? { bearer: async () => gatewayKey } : false, // An MCP client cannot take a download: the file goes to the store, and the model gets its link. async uploadExport({ design, format, bytes }) { const path = `tenants/${caller.tenant}/exports/${design}/${randomUUID()}.${EXTENSIONS[format] ?? format}`; await files.writeFile(path, bytes); return { downloadURL: `${settings.store}/${path}` }; } }); } });}What each value is in your product:
executors. The SDK’sremoteExecutors({ acquire, token })over IMG.LY’s image:acquire({ conversation })returns{ url }, the address of an executor no other conversation used ( A New Container for Each Conversation), andtokenis the executors’EXECUTOR_TOKEN, without which the image refuses every call. The starter kit passes notoken, because a proxy in front of its executors adds it.designs. Your design store:readand a conditionalwrite( The Design Store). The write stores only while the design is still at the version it was given. A store that ignores that overwrites newer work without an error; the SDK’stestDesignStorecatches it.plugins. Your brand’s fonts, colors, and images, as engine plugins the executor loads ( Fonts and Colors as a Plugin).uploadExport. Lets the agent hand people files, and gives each file somewhere to go. An MCP client cannot take a download, so the file goes into your storage and the model gets a link to give the person. Leave it out and the agent cannot export at all. It receives{ design, version, format, title, mimeType, bytes, signal }and returns{ downloadURL }or{ note }:designis the design’s id, andversionthe revision your store returned for it.formatispng,jpeg,webp,svg,pdf,pdfx,html, orimgly. For video and audio, see Status.bytesis the file, aUint8Array.
- The download link must open wherever the person is. Use a signed, expiring link, because the link ends up in a chat transcript.
gateway. IMG.LY’s AI gateway serves AI models and image generation. With a key for it (your CoDesign license key works), the agent can also generate images, charged to your IMG.LY account. The option is required, so every integration decides whether its agent can generate images: pass{ bearer }orfalse.
Run It and Connect#
Start the executors first ( Host the Executor; in the
starter kit, the containers its README starts). Then mount the route on any
Node.js HTTP server. This entry point listens on 127.0.0.1 and serves it at
/mcp; in the starter kit, set MCP_TOKENS=<tenant>=<a long random string>
in .env and run it with npx vite-node src/hosting/cloud/mcp/serve.ts:
// Serves the MCP route at http://127.0.0.1:<MCP_PORT>/mcp, with its settings from `.env`. Online, an HTTPS proxy goes in front.import { existsSync } from 'node:fs';import { createServer } from 'node:http';
import { designPlatformMCP } from './index.js';
if (existsSync('.env')) process.loadEnvFile('.env');const port = Number(process.env.MCP_PORT ?? 8791);const handler = designPlatformMCP(process.env);createServer((req, res) => { if (new URL(req.url ?? '/', 'http://x').pathname !== '/mcp') { res.writeHead(404).end(); return; } handler(req, res).catch((error: unknown) => { console.error(error); if (!res.headersSent) res.writeHead(500).end(); });}).listen(port, '127.0.0.1', () => console.log(`MCP server: http://127.0.0.1:${port}/mcp`));When it starts, it prints MCP server: http://127.0.0.1:8791/mcp. Connect
Claude Code to it with one of the tokens in MCP_TOKENS:
claude mcp add --transport http design http://127.0.0.1:8791/mcp \ --header "Authorization: Bearer <your token>"Then ask it to “make a square poster for our spring sale and export it as a PNG”. It designs the poster, looks at previews of it, and answers with a link to the PNG.
Deploying#
The server runs on Node.js 22 and listens on 127.0.0.1; online, put it behind
your HTTPS reverse proxy or load balancer, which also limits request sizes and
rates. The sessions live in the process’s memory:
- Behind a load balancer, route by the
Mcp-Session-Idheader (sticky sessions), or a session’s next request reaches a process that does not know it and gets 404. The client then has to start again. - Idle sessions are not timed out by
mcpHandler. A client that disconnects without closing its session leaves a small object behind. An idle session holds no executor: the executor unloads a design after 3 idle minutes and exits soon after, so the session’s next call gets a fresh one fromacquire. Add an idle timeout if your processes live long. - Closing a session releases its designs.
mcpServerForcollects thedesignargument of every tool call in a set, and when the MCP session closes,mcpHandlercallscodesign.release({ design })for each, so the executor unloads them at once instead of after 3 idle minutes. - Designs outlive sessions. A new session reaches the same designs through your store, under a new conversation and on a fresh executor.
Confirmations and Spending#
Exports and generated images are charged to your IMG.LY account, whoever asked for them ( Billing); the person’s assistant pays for the model.
A server cannot ask the person. There is no dialog to show. The
confirmation is the MCP client’s: assistants ask the person before a tool call
they have not allowed, unless the person chose to always allow that tool. The
tools that export or generate have no readOnlyHint, so clients treat them as
actions.
So decide what a caller may do in prepare, which receives the Caller. If a
plan has no exports, leave uploadExport out of that caller’s
prepareCoDesign, and the agent cannot export at all; if it has no image
generation, pass gateway: false. uploadExport is too late to refuse: an
export is charged when it renders, before your code sees the file. A quota
counted per call is a check in your own code, before the session’s tools are
handed out. To attribute spending, record each file uploadExport receives
with its Caller.
Before Production#
- Check
Origin. Refuse a request whoseOriginheader is present and not one of yours. MCP’s HTTP transport asks servers to, because a web page could otherwise reach a server running on a person’s machine or network. - The model’s code. It runs on the executor, which must reach your files and nothing else. To run it safely, see Host the Executor.
- Tenants. Each session’s design store and plugins are built for its caller’s tenant, so the model reaches only what they return. Keep them that way: never build a store from an id the model supplies.
- Two editors on one design. A person in your editor and a session may save the same design; the version check refuses the stale save, and the model is told ( Concurrent Edits).
- Export links. The model returns whatever
downloadURLyou give it. If your bucket is private and the link unsigned, the person gets a link that fails, and nothing on your side notices. - Watching it. The handler logs failures with
console.error; send them where you alert. A refused call (isErrorfrom a tool) is the model’s to fix; a logged one is yours.
Then test your server with an MCP Client from MCP’s SDK, over Streamable
HTTP, against your route with fakeExecutors() from
@imgly/codesign-sdk/testing and your real authenticate. The fake executor
answers in milliseconds, so the test needs no container. Cover these cases,
the first three of which fail silently when broken:
- The client receives the
instructions. - Another caller’s token with an open session id gets 404.
- Each
downloadURLyouruploadExportreturns, fetched without your server’s credentials, answers 200. - A request without a valid token gets 401.
- Each MCP session gets its own conversation.
- A full run, from a file to a preview image, returns an image part.
- Both kinds of failure reach the model as
isError. - Canceling a call aborts it.
Next Steps#
- Host the Executor — run IMG.LY’s image hardened, one container per conversation.
- Connect Your Storage — build the design store, and connect your files and exported files.
- Add a Design Chat — offer the same tools in a chat inside your product, with your model.
- Security — see why each conversation gets its own executor and what the model’s code can reach.