Brian Gershon

Building products, sharing best practices.

Gargoyle masthead mark

Build a Registry of AI Tools. Generate MCP Servers and CLIs.

Define prompts and tools in markdown files by describing them to an LLM. A registry framework converts those markdown specs into MCP servers and CLI tools automatically. You iterate in markdown, not framework code.

Build a Registry of AI Tools. Generate MCP Servers and CLIs.

My goal was to quickly create prompts and tools by just describing them, then consume those same prompts and tools within different AI coding assistants like Claude Code, OpenAI Codex, and Roo Code without being locked into any single platform. I wanted to work primarily with markdown files (easy to edit manually, evolve using an LLM, and save to source control) then let a framework convert them to MCP servers and CLI tools on my behalf, without having to work directly in code files.

This article is meant to spark ideas for your own setup. With Claude Code or any modern AI coding assistant, you can build exactly the system you need to manage prompts and tools in a couple of afternoons.

Although I built this as a personal system, it also works well for teams. When you commit markdown definitions to a shared repository, everyone gets the same tools. The files are easy to review in pull requests, and when one developer improves a tool and regenerates the outputs, the whole team gets the update.

The Problem: Slow Iteration and Scattered Prompts

When I had an idea for a prompt or tool, I wanted to test it immediately—iterate fast, maybe throw it away if it didn't work. But MCP servers forced me into implementation details: set up a new server, define request handlers, write response structures, handle parameters. A simple idea like "review my git diff" turned into debugging handler functions and wrestling with TypeScript types.

My prompts ended up scattered everywhere: Claude Code configs, OpenAI Codex settings, custom MCP servers, text files. Each AI tool meant recreating prompts in different formats. Want to use the same code review prompt in both Claude Code and the terminal? Build it twice, maintain it in two places, hope they don't drift.

The solution: markdown definitions that describe what tools do, letting a framework generate MCP servers, CLI adapters, whatever. Focus on the tool concept, not the ceremony.

The Solution: AI-Generated Definitions + Registry Framework

The solution flips the traditional approach: instead of manually writing tool definitions, you describe what you want and let an LLM generate them for you.

Here's how it works. I built a registry framework and created a detailed AGENTS.md file that teaches LLMs how to generate tool definitions in my format. When I need a new prompt or tool, I just describe it to an LLM (Claude Code, OpenAI Codex, whatever). The LLM reads AGENTS.md, understands my patterns, and generates both the markdown definition and the TypeScript implementation.

AGENTS.md: An instruction file that teaches LLMs how to generate code and configurations in your project's specific format and patterns. Modern AI coding tools automatically read this file from your project root.

I can edit and tweak the generated markdown afterward if needed, but I don't start by manually creating files. The LLM does that initial generation.

The LLM generates the markdown definition and the TypeScript handler function that actually implements the functionality. Both following the patterns defined in AGENTS.md.

This is a condensed version demonstrating the type of rules you might include in an AGENTS.md file. A production version would be more comprehensive and tailored to your specific project needs.

# MCP Server Structure Rules

## Tool/Capability Naming

- Use underscore path convention: `domain_resource_verb`
  - Examples: `blog_post_create`, `wallet_transaction_list`, `code_diff_get`
- Use canonical verbs only: `get`, `list`, `create`, `update`, `delete`, `publish`, `render`, `validate`, `import`, `export`
- Use singular for single items (`post`, `notice`) and plural for collections (`posts`, `notices`)

## Tool Documentation Pattern

All capability markdown files require YAML frontmatter:

- `id`: Exact MCP component name (e.g., `code_diff_get`)
- `title`: Human-readable name
- `description`: Concise action statement describing what it does, where, and with what side effect
- `version`: Semantic version (x.y.z)
- `tags`: Array of functional tags (e.g., `[code, git, review]`)

Each capability needs a companion TypeScript file exporting:

- `InputSchema`: Zod schema for input validation
- `OutputSchema`: Zod schema for structured responses
- `handler`: Async function implementing the logic

## Prompt Naming

- Use format: `domain_[action_]deliverable[_tone]`
  - Examples: `blog_outline`, `blog_generate_ideas`, `hoa_draft_notice_formalLetter`
- Use nouns for deliverables (`outline`, `summary`, `ideas`) and verbs for actions (`generate`, `draft`, `create`)
- Add tone/audience suffixes in camelCase: `formalLetter`, `casualSummary`, `technicalGuide`

## Prompt Documentation Pattern

Prompts use the same frontmatter structure as tools but don't require TypeScript companion files—the markdown body becomes the template sent to the AI.

## Organization

- Directory structure: `src/core/{component-type}/{mcp-name}.ts`
  - Capabilities: `src/core/capabilities/code_diff_get.ts`
  - Prompts: `src/core/prompts/blog_generate_outline.ts`
  - Resources: `src/core/resources/code_review_standards.ts`
- File names must match MCP component names exactly
- Use registry-based registration: `registry.addCapability()`, `registry.addPrompt()`, `registry.addResource()`

## Registry Architecture

- Use Zod-first development: define schemas with Zod, generate JSON Schema automatically
- Single source of truth: define once in registry, emit to multiple formats (MCP, CLI, etc.)
- Builder pattern: use `createCapability()`, `createPrompt()`, `createResource()` with fluent API
- Side effects classification: `none`, `read-fs`, `write-fs`, `network`, `exec-shell`, `write-git`

Now comes the registry pattern. A registry builder loads these AI-generated markdown files, parses the frontmatter, and builds an internal representation of all your tools and prompts. The registry doesn't care about output format. It just knows about your tools: what they're called, what they do, how they're categorized, how fast they run.

From that registry, you generate whatever you need. Want an MCP server for Claude Code? Generate it from the registry. Want a CLI tool for terminal use? Generate it from the same registry. Want a config file for another AI assistant? Generate that too.

Builder pattern: A design pattern that constructs complex objects step-by-step, separating the construction process from the final representation, allowing the same construction process to create different outputs.

Registry: A centralized collection that knows about all your tools, prompts, and resources, providing a single source of truth that can generate multiple output formats.

The registry becomes your abstraction layer. One definition, many outputs. Update the markdown, rebuild the registry, regenerate everything. Your tools stay in sync because they come from the same source.

The workflow is: describe what you want → LLM generates markdown and implementation → registry loads definitions → generate outputs (MCP server, CLI, configs). You focus on describing intent. The LLM handles the boilerplate.

Let AI Generate Everything

Here's where this gets powerful: you describe what you want to an LLM, and it generates both the markdown definition and the TypeScript implementation. The AGENTS.md file guides the LLM to create these in your format.

When I need a new tool, I just describe it to an AI coding assistant:

"I need a code metrics collector tool that scans the codebase, counts files by type, calculates total lines of code, and returns structured metrics. It should be read-only. Follow our registry patterns from AGENTS.md."

The AI reads AGENTS.md, understands my patterns, and generates both files:

  1. The markdown definition with proper frontmatter:
---
id: code_metrics_collect
title: Code Metrics Collector
description: Analyzes codebase and collects metrics like lines of code, file counts, and complexity
version: 1.0.0
tags: [code, metrics, analysis]
---
  1. The TypeScript handler function that implements the functionality

I review both, test them, and commit. If I need to tweak the markdown or the implementation, I can edit either one directly or ask the AI to regenerate.

When I want to add features, I just describe the change:

"Update the code metrics tool to also collect cyclomatic complexity metrics."

The AI updates both the markdown definition (bumps version, updates description) and the TypeScript handler. I review and commit.

This dramatically speeds up iteration. I'm not writing boilerplate markdown frontmatter, MCP server code, or CLI argument parsing. I describe what I want. The AI handles the translation to working code and proper definitions.

Practical Examples: Prompt and Tool

Let me show two concrete examples of how this works: creating a prompt for generating draft pull requests, and creating a tool that retrieves GitHub PR information.

Example 1: Creating a Pull Request Prompt

Step 1: Generate the Prompt Definition with AI

Describe what you want to an AI coding assistant (Claude Code, OpenAI Codex, etc.):

"I need a prompt that creates draft pull requests with change summaries and testing instructions. It should run git status and git diff, analyze the changes, and format them as a bulleted summary with a test plan. Follow our registry patterns from AGENTS.md."

The AI reads AGENTS.md, understands your patterns, and generates git_pr_draft.md:

---
id: git_pr_draft
title: Create Draft Pull Request
description: Creates a draft pull request with change summary and testing instructions
version: 1.0.0
tags: [git, pr, documentation]
---

# Create Draft Pull Request

You are an expert software engineer creating draft pull requests with clear, concise descriptions.

## Workflow

1. **Gather Changes**: Run git status and git diff to identify all changes
2. **Analyze Changes**: Understand the purpose and scope
3. **Create Summary**: Write a brief bulleted list of major changes
4. **Add Testing Instructions**: Provide steps to test the changes
5. **Create Draft PR**: Use `gh pr create --draft` with structured description

## Output Format
## Summary
- [Bullet point summarizing major changes]

## Test Plan
- [Step-by-step testing instructions]

## Guidelines

- Be concise and specific
- Reference actual changes, not generalities
- Make testing instructions practical and easy to follow

The frontmatter defines metadata: id, title, description, versioning, and tags. The markdown body is the template that guides the AI.

Step 2: Register the Prompt

In core/index.ts:

export async function createMainRegistry(): Promise<Registry> {
  const registry = createRegistry()
    .addMarkdownPrompt('./prompts/git_pr_draft.md')  // ← Registration
    // ... other registrations

That's it for prompts—no TypeScript implementation needed. The registry reads the markdown, parses the frontmatter, extracts the template, and makes it available via MCP.

How It's Used

When Claude uses this prompt, the MCP server returns the template as instructions. Claude then executes the workflow: gathering changes, analyzing them, and creating a draft PR following the guidelines.

Example 2: Creating a GitHub PR Info Tool

Tools need both a markdown definition and a TypeScript implementation.

Step 1: Generate the Tool Definition with AI

Describe what you want to an AI coding assistant:

"I need a tool that retrieves GitHub PR information from a URL. It should get metadata, code diff, and comments. Include parameters for diff format, max size, and context lines. Make it read-only. Follow our registry patterns from AGENTS.md."

The AI reads AGENTS.md, understands your patterns, and generates both github_pr_info_get.md and the TypeScript implementation. First, the markdown definition:

---
id: github_pr_info_get
title: Get GitHub Pull Request Information
description: Retrieves comprehensive PR information including metadata, code diff, and comments
version: 1.0.0
tags: [github, code, review]
---

# Get GitHub Pull Request Information

Retrieves detailed information about a GitHub pull request using its URL.

## Input Parameters

- `url` (string, required): Full GitHub PR URL
- `includeDiff` (boolean, optional): Include code diff (default: true)
- `diffFormat` (enum, optional): Format of diff output (default: 'summary')
- `maxDiffSize` (number, optional): Maximum diff size in characters (default: 50000)
- `contextLines` (number, optional): Context lines in diff, 0-10 (default: 1)

## Output

Returns PR metadata (title, description, author, dates, stats), formatted diff content, and the original URL.

Step 2: Create the TypeScript Implementation

Create github_pr_info_get.ts with three required exports:

import { z } from 'zod';

// 1. INPUT SCHEMA - Validates all input parameters
export const InputSchema = z.object({
  url: z
    .string()
    .url()
    .refine(
      (url) => {
        const urlObj = new URL(url);
        return (
          urlObj.hostname === 'github.com' &&
          /^\/[\w.-]+\/[\w.-]+\/pull\/\d+/.test(urlObj.pathname)
        );
      },
      { message: 'Must be a valid GitHub pull request URL' }
    ),
  includeDiff: z.boolean().optional().default(true),
  diffFormat: z.enum(['full', 'stat', 'name-only', 'summary']).optional().default('summary'),
  maxDiffSize: z.number().optional().default(50000),
  contextLines: z.number().min(0).max(10).optional().default(1),
});

// 2. OUTPUT SCHEMA - Defines return structure
export const OutputSchema = z.object({
  success: z.boolean(),
  pr: z.object({
    number: z.number(),
    title: z.string(),
    author: z.object({
      login: z.string(),
      url: z.string(),
    }),
    state: z.enum(['OPEN', 'CLOSED', 'MERGED']),
    // ... additional fields
  }).optional(),
  diff: z.string().optional(),
  location: z.string(),
  error: z.string().optional(),
});

// 3. HANDLER FUNCTION - Implementation logic
export async function handler(input: z.input<typeof InputSchema>): Promise<z.infer<typeof OutputSchema>> {
  try {
    const validated = InputSchema.parse(input);

    // Extract repository info, authenticate, get PR metadata and diff
    const pr = await getPRMetadata(validated.url);
    const diff = validated.includeDiff ? await getPRDiff(validated.url, { ... }) : undefined;

    return { success: true, pr, diff, location: validated.url };
  } catch (error) {
    return { success: false, location: input.url, error: error.message };
  }
}

The Zod schemas provide both runtime validation and TypeScript types. The registry converts these to JSON Schema automatically for the MCP protocol.

Step 3: Register the Tool

In core/index.ts:

await registry.addMarkdownCapability("./capabilities/github_pr_info_get.md");

The registration is async because the registry imports the TypeScript module, extracts the schemas and handler, and creates a complete tool definition.

How the Registry Functions Work

Now let's look at how these registry functions actually work under the hood. The registry uses a builder pattern to load markdown files and create internal representations of your tools.

The addMarkdownPrompt() Function

For prompts, the registry reads the markdown file, parses the frontmatter, and uses the Builder pattern to create a Prompt object:

class RegistryBuilder {
  addMarkdownPrompt(filePath: string): this {
    // 1. Resolve path relative to current directory
    const currentDir = resolve(new URL(".", import.meta.url).pathname);
    const fullPath = resolve(currentDir, filePath);

    // 2. Read markdown file
    const fileContent = readFileSync(fullPath, "utf-8");

    // 3. Parse frontmatter using gray-matter
    const { data: frontmatter, content: template } = matter(fileContent);

    // 4. Build prompt using the Builder pattern
    const prompt = createPrompt(frontmatter.id)
      .title(frontmatter.title)
      .description(frontmatter.description)
      .version(frontmatter.version)
      .tags(
        ...(Array.isArray(frontmatter.tags)
          ? frontmatter.tags
          : [frontmatter.tags])
      )
      .template(template.trim())
      .build();

    // 5. Add to registry
    this.addPrompt(prompt);

    return this;
  }
}

The createPrompt() function provides a fluent API to construct the Prompt object with all metadata from the frontmatter, plus the markdown template. No TypeScript implementation file is needed—the markdown body becomes the template that gets sent to the AI.

The addMarkdownCapability() Function

For tools/capabilities, the process follows the same Builder pattern but adds an async TypeScript module import:

class RegistryBuilder {
  async addMarkdownCapability(path: string): Promise<this> {
    // 1. Read the markdown file
    const fullPath = resolve(__dirname, path);
    const content = readFileSync(fullPath, "utf-8");

    // 2. Parse frontmatter
    const { data: frontmatter } = matter(content);

    // 3. Import companion TypeScript file dynamically
    const tsPath = path.replace(".md", ".ts");
    const module = await import(tsPath);

    // 4. Extract the three required exports
    const { InputSchema, OutputSchema, handler } = module;

    // 5. Build capability using the Builder pattern
    const capability = CapabilityBuilder.create(frontmatter.id)
      .title(frontmatter.title)
      .description(frontmatter.description)
      .version(frontmatter.version)
      .tags(...frontmatter.tags)
      .inputSchema(InputSchema)
      .outputSchema(OutputSchema)
      .handler(handler)
      .build();

    // 6. Add to registry
    this.capabilities.push(capability);

    return this;
  }
}

The key difference from prompts: addMarkdownCapability() is async because it dynamically imports the TypeScript module (step 3) to get the Zod schemas and handler function.

Both prompts and capabilities use the Builder pattern consistently. The fluent API (chained method calls) ensures all required fields are provided and validates the structure at compile time. The .build() call at the end creates the final immutable object, whether it's a Prompt or Capability.

When you call registry.build(), it returns a Registry object containing all your prompts and capabilities, ready to be converted into MCP servers, CLIs, or any other format you need.

Generating the MCP Server from the Registry

Now that we have a registry loaded with our prompts and capabilities, how does it become a running MCP server that Claude can actually use? This happens in three steps: format emission, adapter registration, and server assembly.

Step 1: Format Emitters Convert to MCP Protocol

The runtime emitters in runtime/index.ts convert registry components into MCP protocol format:

// Convert capabilities to MCP tools
export function emitMcpTools(registry: Registry): McpTool[] {
  return registry.capabilities.map((cap) => ({
    name: cap.id,
    description: cap.description,
    input_zod_schema: cap.inputSchema,
    handler: cap.run,
  }));
}

// Convert prompts to MCP prompts
export function emitMcpPrompts(registry: Registry): McpPrompt[] {
  return registry.prompts.map((prompt) => ({
    name: prompt.id,
    description: prompt.description,
    arguments: createArgumentSchema(prompt.slots),
    handler: (args) => substituteTemplate(prompt.template, args),
  }));
}

These emitters transform internal registry objects into the structure the MCP protocol expects.

Step 2: MCP Adapter Registers Components

The adapter in adapters/mcp.ts takes emitted components and registers them with the actual MCP server instance:

export function registerCapabilitiesAsMcpTools(
  server: McpServer,
  registry: Registry
): void {
  const mcpTools = emitMcpTools(registry);

  for (const tool of mcpTools) {
    server.registerTool(
      tool.name,
      {
        description: tool.description,
        inputSchema: tool.input_zod_schema?.shape || {},
      },
      async (args: unknown) => {
        try {
          const result = await tool.handler(args);
          return {
            content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
          };
        } catch (error) {
          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(
                  {
                    success: false,
                    error: error.message,
                  },
                  null,
                  2
                ),
              },
            ],
          };
        }
      }
    );
  }
}

The adapter calls the MCP SDK's server.registerTool() for each capability, wrapping the handler to return the proper MCP content format ({ content: [...] }). It adds error handling so failures return structured error responses instead of crashing.

A similar registerPromptsAsMcpPrompts() function registers prompts with server.registerPrompt().

Step 3: Main Server Assembly

Finally, index.ts brings everything together:

async function main(): Promise<void> {
  // 1. Create MCP server instance
  const server = new McpServer({
    name: "my-mcp-server",
    version: "1.0.0",
  });

  // 2. Load the registry
  const registry = await createMainRegistry();

  // 3. Register all components
  registerRegistryWithMcp(server, registry);

  // 4. Create stdio transport and connect
  const transport = new StdioServerTransport();
  await server.connect(transport);

  console.error("MCP Server is running...");
}

The server creates an MCP server instance, loads your registry, registers all components through the adapter, and connects to stdio transport. Now it's ready to receive requests from Claude.

When Claude calls a tool like github_pr_info_get, the MCP server receives the request via stdio, the adapter extracts arguments, the handler validates input with the InputSchema, executes the logic, validates output with the OutputSchema, wraps the result in MCP format, and sends it back to Claude.

In Conclusion

I built this system to solve my own prompt management problem: scattered definitions across tools, contexts, and projects. The solution is markdown files with frontmatter, a common registry, and generated outputs.

Define what tools do once in markdown. Let a registry load these definitions. Generate MCP servers for Claude Code, CLIs for terminal use, or configs for other AI assistants. Update the markdown and regenerate when you need to iterate.

Stop rebuilding prompts for every tool. Stop worrying about drift between versions. Define once, generate many, iterate fast.