← BACK TO DIARY
POSTMORTEM8 MIN READ

Building a Claude Code Plugin for an Internal Angular Library

How a plugin bundles an MCP server, skills, agents, and a hook into one distributable unit, plus the symlink bug and cache-snapshot trap that cost real debugging time.

PUBLISHED:August 11, 2026
TRACK:PLUGIN-DEVELOPMENT
TAGS:
#frontend#agentic-harness#mcp#architecture

Hero

I built a Claude Code plugin for our UI library, a set of roughly 30 internal Angular components. The goal: make Claude Code and other MCP-capable tools write correct code against that library, without every developer relearning its gotchas the hard way. I’ll call the plugin ui-lib-claude-plugin from here on.

The first version was just an MCP server, a small program that answers questions like “what does the select component (our dropdown) need?” Useful, but it’s only advice. Nothing stops a developer, or an AI agent, from ignoring the answer and writing wrong code anyway.

So the plugin wraps that server with the pieces that make the knowledge proactive: slash commands, skills that trigger on their own, specialist agents, and a hook that checks generated code against the library’s rules the moment it’s written. It all ships as one installable unit instead of config someone has to piece together by hand.

Two names to keep straight, because they show up together later. ui-lib-claude-plugin is the installable bundle. ui-lib-mcp is the MCP server inside it, a separate package the plugin points at.

Here’s what’s in the plugin. 14 MCP tools and 7 skills sit behind three slash commands (/ui, /from-screenshot, /from-figma) for on-demand use. Four more skills trigger on their own (component-usage, angular-patterns, ui-governance, vitest), so nobody has to remember to ask.

Then the agents. component-specialist does build and review work and only returns findings. No Write, no Edit, no Bash. component-migrator is scoped to ng update migrations, with write and verify permissions, and it is explicitly forbidden from committing. Both boundaries are pinned by a static contract test, tests/agents-static.spec.ts, not just described in a doc. If someone quietly widens an agent’s tool access later, the test fails instead of the change shipping unnoticed. The MCP suite sits at 183/183 with tsc -p tsconfig.test.json clean.

The plumbing underneath is what ate the time. Two of the failures produced a green result that was actually wrong, which is the worst kind. Nothing looks broken.

A new executable bin in the MCP package did nothing when invoked as its installed command. No error. It just exited.

npm exposes CLI binaries as a symlink in node_modules/.bin and the global bin directory, not as a copy of the file. When Node resolves process.argv[1] for a symlinked call, you get the symlink’s path. import.meta.url gives you the real target. So a guard that compares the two for equality is always false in the symlinked case, the “run the CLI body now” branch never fires, and the bin quietly exits having done nothing.

And almost no reasonable test catches it. Running node dist/x.js skips the symlink entirely. Unit-testing the build function never touches the entrypoint guard. I only found it by running the bin the way an end user would, as the installed global command.

The fix was pulling out isMainModule(argvEntry, metaUrl) as a pure function you can test on its own, and resolving the entry path with realpathSync before comparing. Every new bin has to copy this:

// src/server.ts pattern the bin entry guard every new bin must copy

export function isMainModule(
  argvEntry: string | undefined,
  metaUrl: string,
): boolean {
  if (!argvEntry) return false;

  try {
    return metaUrl === pathToFileURL(realpathSync(argvEntry)).href;
  } catch {
    return false;
  }
}

Stale docs from the MCP server

The second one was nastier. The MCP server’s served documentation can be stale relative to the branch you’re actually working on, because it serves a snapshot bundled at global-install time, not a live read of your checkout.

It showed up as a flat-out wrong answer. get_component('select') said our dropdown had a [multiple] input for picking several options. It didn’t. Multi-select is a completely separate component. The YAML source, select.prompt.yml, had it right the whole time. The drift was only in what the running MCP instance was serving, frozen from an earlier install.

Quick structural note, because it matters here: the MCP server is its own package, referenced by binary name. The plugin’s .mcp.json points at the server key ui-lib-mcp, so the tool-id prefix Claude Code exposes at runtime is mcp__plugin_ui-lib-claude-plugin_ui-lib-mcp__*. Read it left to right: the plugin name, then the server name. Plugin layer and server layer are separate pieces, even though a developer experiences them as one.

The standing rule that came out of this: never treat live MCP output as ground truth for anything going into a deliverable. When precision counts, read the YAML.

The hook

This is the piece that moves the plugin from advice to enforcement. get_component can tell you the right API. It can’t stop you writing the wrong code.

The validate-hook bin runs on every PostToolUse Write|Edit against an Angular file, calls validateCode, and feeds governance violations back to the model as additionalContext. The model sees its own mistake in the same turn. Nobody runs a linter and pastes the output back.

Its output is deliberately non-blocking:

// scripts/validate-hook.ts buildHookOutput shape (never throws)

export function buildHookOutput(violations: GovernanceViolation[]): HookOutput {
  if (violations.length === 0) return { continue: true };

  return {
    continue: true,
    hookSpecificOutput: {
      hookEventName: "PostToolUse",
      additionalContext: violations.map(formatViolation).join("\n"),
    },
    // dormant fallback path, not the primary shape:
    // decision: 'block'
  };
}

I watched it fire unprompted on real Write and Edit calls. That was the moment it stopped being a spec.

Getting it to a running session

The plugin manifest is the unit of distribution. plugin/.claude-plugin/plugin.json declares the version and a hooks key that wires in plugin/hooks/hooks.json. Add a hook or an agent, bump this manifest. It’s the one file Claude Code reads to know what the plugin grants.

But Claude Code doesn’t load the plugin from a live checkout. It loads from a cached snapshot of the marketplace the plugin was installed from, at a path like ~/.claude/plugins/cache/component-library/ui-lib-claude-plugin/<version>/ (component-library is the marketplace name, then the plugin, then its version). A version bump that hasn’t been pushed, merged into the marketplace-tracked ref, and followed by an explicit plugin update is invisible to a running session. Correct code, wrong behavior. Same failure class as the stale MCP output.

Anything shipped through a packaged or cached distribution path needs its own answer to “how does this reach a running session”, separate from “is the code correct.”

Fresh worktrees have their own version of this. Building the MCP package in a new one needs this order:

pnpm install
pnpm run generate:prompts
cd mcp
npm ci
npm run build

The MCP package’s prebuild step copies prompts/generated/, which only exists after pnpm run generate:prompts. npm ci alone isn’t enough, and any spec that spawns dist/server.js fails until the build has run.

A false “tsc clean”

During agent work, an implementer reported clean type-checking. It wasn’t. example-page.component.ts, a page in our demo app, had two implicitly has 'any' errors on GrapesJS collection lambdas, .components().forEach((c) => ...). Vitest compiles through esbuild, which strips types without checking them, so the tests passed and the claim passed and the type errors sat there anyway.

So now I run tsc myself after any agent-authored TypeScript change. Every time.

Agent permissions

The split is the design principle in miniature. component-specialist reads and returns. component-migrator writes and verifies. Neither commits. An agent should get the smallest set of tools that lets it do its one job, and that grant should be pinned by a test, not a docstring.

A prompt that says “don’t use Bash” is a suggestion, and the model can drift from it under pressure. A tool list the harness enforces is a wall. The static contract test does for agent permissions what a documentation contract gate does for served library facts: it turns an intention into something CI can fail on.

One shape, three bugs

The symlink bug and the stale MCP output are the same bug wearing different clothes. An npm symlinked bin, a cached marketplace snapshot, a globally installed MCP server: each one opens a gap between “the code is correct” and “what a user actually runs reflects that.” Testing the logic in isolation proves the first. It tells you nothing about the second.

The reusable version of this rule, with the failing comparison and the invocation test that actually catches it, is in Common Pitfalls Building a Claude Code Plugin or MCP Server. The server-side version of the trap is the subject of How We Built an MCP Server for Our UI Library.

With a plugin, “how does this reach a running session” is its own verification step. Just as necessary as the tests.

Flow diagram

References & Sources