Skip to content

Pine MCP: A Case Study

Pine MCP is a server that lets AI coding tools read Pine, Kajabi's design system. Instead of guessing at components, an agent can look up the real ones, get Pine's rules before it starts and check what it wrote against them. I started it in June 2025 and co-maintain it with a teammate. The biggest...

Phillip Lovelace
Oct 14, 20268 min read

ine MCP is a server that lets AI coding tools read Pine, Kajabi’s design system. Instead of guessing at components, an agent can look up the real ones, get Pine’s rules before it starts and check what it wrote against them.

I started it in June 2025 and co-maintain it with a teammate. The biggest surprise? AI agents are a new kind of design system user with limits of their own, and most of the hard problems turned out to be documentation problems.

Role
Sr. UX Developer, co-maintainer
Company
Kajabi · 2020 to present
On Pine MCP
2025 to present
Focus
Lookup, response budgets, testing and upkeep
Framework
Next.js
Protocol
MCP, via the official SDK
  • 15 MCP tools
  • 3 tool groups
  • 5+ AI clients
  • 64–99% smaller replies

The problem

If you’ve pointed an AI coding tool at a design system, you’ve probably seen this. In 2025, the tools writing UI for Kajabi kept inventing Pine components and props that didn’t exist. The code looked right. It wasn’t. Pine’s docs were written for people, as pages you browse, and an agent can’t query those reliably. So it filled the gaps with whatever was most common on the public web.

MCP (Model Context Protocol) is a standard way for AI tools to call outside services. We looked at other options first. Rules pasted into each tool’s prompt drift out of date. An instructions file in the repo helps, and we kept one, but it can’t answer questions. A custom API would have worked for nobody until every tool learned to call it. MCP won because the tools already speak it, which made it an easy call. (For Pine itself, see the Pine case study.)

How it works

At build time the server reads from three sources: Pine’s repo for component source, docs and page patterns, plus the design tokens and the icon catalog. A data build fetches everything, checks that nothing is partial or empty and writes a static snapshot. The server is one stateless endpoint, and every tool answers from that snapshot.

Under the hood it’s a small Next.js app, with the official MCP SDK handling the protocol itself. That leaves almost all of our own code about Pine: what to return, in what order and how much.

How the Pine MCP server works Three sources (the Pine repo with component source, docs and patterns; design tokens; and icons) feed a data build that runs on every deploy. The build fetches everything, checks that nothing is partial or empty, and writes a static snapshot. If any check fails, the deploy stops and the last good version stays live. The snapshot is bundled into the Pine MCP server, a single stateless endpoint with a size budget on every response, offering three groups of tools: look up, check, and guide. AI coding tools, an edit hook and review agents call those tools, and the result is product UI built from real Pine components. /* Site tokens, so the figure matches the theme and its toggle (inlined by the case_figure shortcode) */ .pm { --bg:var(--site-bg-alt); --box:var(--site-bg); --chip:color-mix(in srgb, var(--site-bg-alt) 50%, var(--site-bg)); --stroke:var(--divider-color); --ink:var(--text-color); --ink2:var(--text-muted); --arrow:var(--text-muted); --accent:var(--interactive); --wash:light-dark(color-mix(in srgb, var(--interactive) 8%, transparent), color-mix(in srgb, var(--interactive) 16%, transparent)); font-family: inherit; } .pm-bg { fill: var(--bg); } .pm-box { fill: var(--box); stroke: var(--stroke); stroke-width: 1; } .pm-chip { fill: var(--chip); stroke: var(--stroke); stroke-width: 1; } .pm-server { fill: var(--wash); stroke: var(--accent); stroke-width: 2; } .pm-line { fill: none; stroke: var(--arrow); stroke-width: 1.5; } .pm-head { fill: var(--arrow); } .pm-eyebrow { fill: var(--ink2); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; } .pm-title { fill: var(--ink); font-size: 14px; font-weight: 600; text-anchor: middle; } .pm-chip-title { fill: var(--ink); font-size: 13px; font-weight: 600; text-anchor: middle; } .pm-server-title { font-size: 15px; } .pm-sub { fill: var(--ink2); font-size: 12px; text-anchor: middle; } .pm-small { fill: var(--ink2); font-size: 11.5px; text-anchor: middle; } .pm-label { fill: var(--ink2); font-size: 11.5px; } <rect class="pm-bg" x="0" y="0" width="760" height="752"/> <text class="pm-eyebrow" x="24" y="38">SOURCES, READ AT BUILD TIME</text> <rect class="pm-box" x="24" y="52" width="228" height="70" rx="10"/> <text class="pm-title" x="138" y="81">Pine repo</text> <text class="pm-sub" x="138" y="102">components, docs, patterns</text> <rect class="pm-box" x="266" y="52" width="228" height="70" rx="10"/> <text class="pm-title" x="380" y="81">Design tokens</text> <text class="pm-sub" x="380" y="102">core and semantic tokens</text> <rect class="pm-box" x="508" y="52" width="228" height="70" rx="10"/> <text class="pm-title" x="622" y="81">Icons</text> <text class="pm-sub" x="622" y="102">icon catalog</text> <line class="pm-line" x1="138" y1="122" x2="138" y2="171" marker-end="url(#pm-arrow)"/> <line class="pm-line" x1="380" y1="122" x2="380" y2="171" marker-end="url(#pm-arrow)"/> <text class="pm-label" x="388" y="151">fetched in batches, with retries</text> <line class="pm-line" x1="622" y1="122" x2="622" y2="171" marker-end="url(#pm-arrow)"/> <rect class="pm-box" x="24" y="172" width="712" height="116" rx="10"/> <text class="pm-title" x="380" y="198">Data build, on every deploy</text> <rect class="pm-chip" x="40" y="212" width="196" height="32" rx="8"/> <text class="pm-small" x="138" y="232">fetch everything</text> <line class="pm-line" x1="236" y1="228" x2="281" y2="228" marker-end="url(#pm-arrow)"/> <rect class="pm-chip" x="282" y="212" width="196" height="32" rx="8"/> <text class="pm-small" x="380" y="232">check: nothing partial or empty</text> <line class="pm-line" x1="478" y1="228" x2="523" y2="228" marker-end="url(#pm-arrow)"/> <rect class="pm-chip" x="524" y="212" width="196" height="32" rx="8"/> <text class="pm-small" x="622" y="232">write a static snapshot</text> <text class="pm-small" x="380" y="270">If any check fails, the deploy stops and the last good version stays live.</text> <line class="pm-line" x1="380" y1="288" x2="380" y2="339" marker-end="url(#pm-arrow)"/> <text class="pm-label" x="388" y="318">bundled with the server</text> <rect class="pm-server" x="24" y="340" width="712" height="146" rx="10"/> <text class="pm-title pm-server-title" x="380" y="367">Pine MCP</text> <text class="pm-sub" x="380" y="387">one stateless endpoint · a size budget on every response</text> <rect class="pm-box" x="40" y="402" width="196" height="68" rx="8"/> <text class="pm-chip-title" x="138" y="423">Look up</text> <text class="pm-small" x="138" y="442">components, tokens,</text> <text class="pm-small" x="138" y="459">icons, docs, patterns</text> <rect class="pm-box" x="282" y="402" width="196" height="68" rx="8"/> <text class="pm-chip-title" x="380" y="423">Check</text> <text class="pm-small" x="380" y="442">generated code against</text> <text class="pm-small" x="380" y="459">three tiers of rules</text> <rect class="pm-box" x="524" y="402" width="196" height="68" rx="8"/> <text class="pm-chip-title" x="622" y="423">Guide</text> <text class="pm-small" x="622" y="442">Pine's rules before</text> <text class="pm-small" x="622" y="459">an agent writes code</text> <line class="pm-line" x1="138" y1="487" x2="138" y2="539" marker-start="url(#pm-arrow)" marker-end="url(#pm-arrow)"/> <line class="pm-line" x1="380" y1="487" x2="380" y2="539" marker-start="url(#pm-arrow)" marker-end="url(#pm-arrow)"/> <text class="pm-label" x="388" y="517">MCP tool calls</text> <line class="pm-line" x1="622" y1="487" x2="622" y2="539" marker-start="url(#pm-arrow)" marker-end="url(#pm-arrow)"/> <rect class="pm-box" x="24" y="540" width="228" height="80" rx="10"/> <text class="pm-title" x="138" y="568">AI coding tools</text> <text class="pm-small" x="138" y="589">Claude Code, Cursor,</text> <text class="pm-small" x="138" y="606">Windsurf, VS Code</text> <rect class="pm-box" x="266" y="540" width="228" height="80" rx="10"/> <text class="pm-title" x="380" y="568">Edit hook</text> <text class="pm-small" x="380" y="589">checks Pine markup</text> <text class="pm-small" x="380" y="606">after each AI edit</text> <rect class="pm-box" x="508" y="540" width="228" height="80" rx="10"/> <text class="pm-title" x="622" y="568">Review agents</text> <text class="pm-small" x="622" y="589">design review</text> <text class="pm-small" x="622" y="606">on pull requests</text> <line class="pm-line" x1="138" y1="620" x2="138" y2="671" marker-end="url(#pm-arrow)"/> <text class="pm-label" x="146" y="650">write code</text> <line class="pm-line" x1="380" y1="620" x2="380" y2="671" marker-end="url(#pm-arrow)"/> <text class="pm-label" x="388" y="650">flag issues</text> <line class="pm-line" x1="622" y1="620" x2="622" y2="671" marker-end="url(#pm-arrow)"/> <text class="pm-label" x="630" y="650">review</text> <rect class="pm-box" x="24" y="672" width="712" height="56" rx="10"/> <text class="pm-title" x="380" y="696">Product UI</text> <text class="pm-sub" x="380" y="716">built from real Pine components, checked against Pine's rules</text>
Data is fixed at deploy time, and a bad build never replaces a good one.

Fixing the data at deploy time keeps answers fast and predictable, and an outage upstream can’t break a request. The cost is freshness, so a scheduled check compares the snapshot with Pine and flags drift. The tools fall into three groups:

Look up

Components, tokens, icons, docs and page patterns, found by name even when an agent’s guess is a little off.

Check

Reviews generated code against three tiers of Pine’s rules, and every problem it finds comes back with a fix.

Guide

Agents get Pine’s rules before they write anything, plus a quick answer on whether a request involves Pine at all.

The callers are AI coding tools like Claude Code, Cursor, Windsurf, VS Code and ChatGPT, a hook in the product repo that checks Pine markup after each AI edit, and design review agents on pull requests.

Agents read through a keyhole

In early 2026, Claude Code, Cursor and Windsurf started failing on some calls. The responses were too big for them to take in. My note on the fix didn’t sugarcoat it: “the verbose defaults were the problem.”

So every response got a size budget. The most important part of each answer comes first, so a component’s API is never the part that gets cut. Agents can ask for specific sections, fewer examples or the next page, and when an answer hits its budget, it says how to get the rest.

How much smaller responses got after adding size budgets Bar chart. Each bar shows how much smaller a response got after size budgets were added in March 2026, out of 100%. Icon list: 99% smaller. All tokens: 97% smaller. Table component: 93% smaller. Button component: 70% smaller. Box component: 69% smaller. Input component: 64% smaller. /* Site tokens, so the figure matches the theme and its toggle (inlined by the case_figure shortcode) */ .pr { --bg:var(--site-bg-alt); --ink:var(--text-color); --ink2:var(--text-muted); --muted:var(--text-muted); --track:color-mix(in srgb, var(--interactive) 22%, var(--site-bg-alt)); --fill:var(--interactive); font-family: inherit; } .pr-bg { fill: var(--bg); } .pr-title { fill: var(--ink); font-size: 16px; font-weight: 600; } .pr-subtitle { fill: var(--ink2); font-size: 12.5px; } .pr-row { fill: var(--ink); font-size: 13px; } .pr-track { fill: var(--track); } .pr-fill { fill: var(--fill); } .pr-value { fill: var(--ink); font-size: 13px; font-weight: 600; } .pr-note { fill: var(--muted); font-size: 11px; } <text class="pr-title" x="32" y="42">Response sizes after adding budgets</text> <text class="pr-subtitle" x="32" y="64">How much smaller each response got, out of 100%</text> <text class="pr-row" x="32" y="116">Icon list</text> <rect class="pr-track" x="196" y="106" width="320" height="12" rx="4"/> <rect class="pr-fill" x="196" y="106" width="316.8" height="12" rx="4"><title>Icon list: 99% smaller</title></rect> <text class="pr-value" x="532" y="116">99% smaller</text> <text class="pr-row" x="32" y="156">All tokens</text> <rect class="pr-track" x="196" y="146" width="320" height="12" rx="4"/> <rect class="pr-fill" x="196" y="146" width="310.4" height="12" rx="4"><title>All tokens: 97% smaller</title></rect> <text class="pr-value" x="532" y="156">97% smaller</text> <text class="pr-row" x="32" y="196">Table component</text> <rect class="pr-track" x="196" y="186" width="320" height="12" rx="4"/> <rect class="pr-fill" x="196" y="186" width="297.6" height="12" rx="4"><title>Table component: 93% smaller</title></rect> <text class="pr-value" x="532" y="196">93% smaller</text> <text class="pr-row" x="32" y="236">Button component</text> <rect class="pr-track" x="196" y="226" width="320" height="12" rx="4"/> <rect class="pr-fill" x="196" y="226" width="224" height="12" rx="4"><title>Button component: 70% smaller</title></rect> <text class="pr-value" x="532" y="236">70% smaller</text> <text class="pr-row" x="32" y="276">Box component</text> <rect class="pr-track" x="196" y="266" width="320" height="12" rx="4"/> <rect class="pr-fill" x="196" y="266" width="220.8" height="12" rx="4"><title>Box component: 69% smaller</title></rect> <text class="pr-value" x="532" y="276">69% smaller</text> <text class="pr-row" x="32" y="316">Input component</text> <rect class="pr-track" x="196" y="306" width="320" height="12" rx="4"/> <rect class="pr-fill" x="196" y="306" width="204.8" height="12" rx="4"><title>Input component: 64% smaller</title></rect> <text class="pr-value" x="532" y="316">64% smaller</text> <text class="pr-note" x="32" y="356">Measured when budgets were added in March 2026. Some components grew again</text> <text class="pr-note" x="32" y="372">later, when every code example was restored to their responses.</text>
Budgets cut responses by 64–99%, with the biggest ones shrinking the most.

Honestly, this was a documentation problem the whole time. What does a short description of a table component need to say first? Budgets also need tending. When we later put every code example back into component responses, some grew again, and I still think that was the right call.

Names are fuzzy

Agents ask for btn, radiogroup, pds-button or Button, and all of them should land on the right component. The obvious tool is edit distance, the number of single-letter changes between two words. By that measure, though, btn is closer to box than to button. Oops.

So lookup works in order: a short list of known aliases, then an exact match that ignores the pds- prefix, then a partial match, then edit distance with tight limits that only accepts one clear winner. If nothing wins, the agent gets “did you mean” suggestions. I’ll take a question over a confident wrong answer every time.

Healthy and serving nothing

In spring 2026, a credential problem meant the data build couldn’t read its sources. The deploy still succeeded and shipped empty data. In my own words at the time, the server was “healthy” and serving nothing. Not a great look for a tool whose whole job is accurate answers.

Now the build fails closed. It refuses to ship a partial component set or empty data, never deletes good data before new data arrives and leaves the last good version live when anything fails. The same work added the server’s first automated tests. They run without network access, so upstream Pine changes can’t break them, and I broke the code on purpose to prove they’d catch it. Now hundreds of tests run on every pull request.

My contributions

I started Pine MCP and co-maintain it with a teammate. They built the tools that check generated code and guide agents before they write, along with the logging that shows how the server gets used. We made most of the big calls together. These are the parts where I’ve had the biggest hand.

First versions

Built the first version in June 2025, six tools in one serverless function, then rebuilt it a week later as the Next.js app it still is.

Lookup

Token and icon lookup, plus the forgiving name matching that sends near-miss names to the right component, or asks.

Response budgets

Size budgets on every response, the most important part first, and ways for agents to ask for more or less.

Data build

The build that fails closed, so partial or empty data never ships and the last good version always stays live.

Tests and CI

The server’s first automated tests and CI, run offline on every pull request and proven by breaking code on purpose.

Security and upkeep

Patching a published SDK vulnerability, moving to MCP SDK version 2 and refactoring with output unchanged.

Pine MCP’s page in the Pine docs, next to the components it serves.

Outcomes

Where code gets written

Registered project-wide in Kajabi’s main product repo, so AI tools working in that codebase use it.

Answers that fit

Responses are 64–99% smaller, with the important part first and the rest a request away.

Steady use

Thousands of requests a month from engineers’ AI tools, the edit hook and review agents.

What I learned

Build the guardrails first

Size budgets, tests and the fail-closed build each showed up only after something broke. Next time they come first, before anyone depends on the output.

Agent-facing text is product copy

Tool descriptions and error messages are the interface an agent reads. They deserve the same care as a component’s docs, and they go stale the same way.

A checker has to be right

A rule that flags correct code teaches people and agents to ignore it. How often a checker is wrong matters more than how much it checks, so rules get tested against Pine’s own examples.

Refactors need proof

When other people’s agents depend on your exact output, confidence isn’t enough. I compared every response before and after a refactor, byte for byte, and shipped only when they all matched.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions