A Developer's Guide to MCP Servers: Bridging AI's Knowledge Gaps | Kong Inc.

We're Entering the Age of AI Connectivity

Products & Agents:

January 26, 2026

14 min read

Adam DeHaven

Senior Staff Software Engineer, Kong

Have you ever asked an AI assistant to generate code for a framework it doesn't quite understand? Maybe it produces something that looks right, but the syntax is slightly off, or it uses deprecated patterns. The AI is working hard, but it lacks the specific context it needs to truly help you.

The Model Context Protocol (MCP) was designed to bridge this knowledge gap by giving AI assistants access to domain-specific knowledge and capabilities they don't have built in.

## What is MCP?

MCP is an open standard that defines how AI clients communicate with remote servers. It provides a standardized protocol for clients like Claude, Cursor, or VS Code to access tools, resources, and capabilities from external systems.

Currently, MCP servers can expose several types of capabilities to AI clients:

  1. Resources: Data that can be read by clients, such as documentation, configuration files, or API responses
  2. Tools: Functions that the AI can call to perform actions, fetch data, or validate content
  3. Prompts: Pre-built structured message templates and instructions that help users accomplish specific tasks

The protocol continues to evolve, with multiple specification versions released in 2025 and a draft spec for future capabilities. The three core primitives above form the foundation, but expect the ecosystem to grow over time.

## Why build your own MCP server?

MCP works best when you build servers tailored to your specific domain. While the ecosystem includes servers for many common integrations and data sources, you or your organization likely has unique requirements that may warrant a custom implementation.

Consider these scenarios:

For each of these, you could build an MCP server that teaches AI assistants exactly what they need to know.

## A real-world example: The MDC syntax helper

Let's walk through a real example: an MCP server we are building for the Kong Developer Portal.

Kong's Dev Portal allows customers to create custom pages using MDC Syntax to supercharge Markdown and give customers the ability to integrate Vue components with slots and props inside their Markdown. Users can build hierarchical page structures with rich components, custom metadata, and styled content blocks to create a fully branded developer experience.

The problem? AI assistants don't inherently understand MDC syntax or know what components are available. When users ask their AI assistant or MCP client for help creating portal content, client might generate invalid syntax or suggest components that don't exist.

This matters because MDC isn't just about formatting text. Customers use it to create professional branded pages with full-width hero sections, responsive feature grids, stylized cards, and more.

Here's what a simple hero section looks like in MDC:

::page-hero
---
full-width: true
.title-color: "#ffffff"
description-color: "#e0e7ff"
background-color: "#29417a"
border-radius: "0"
padding: "80px 40px"
text-align: "center"
---
#title
Ship Faster with Our Payment API

#description
Process payments, manage subscriptions, and handle refunds with a single integration. Get started in minutes with our comprehensive SDKs.

#actions
  :::button
  ---
  appearance: "primary"
  size: "large"
  to: "#docs"
  background-color: "#3b82f6"
  ---
  View Documentation
  :::
::

And here's how the same hero section renders in the portal:

Without understanding MDC syntax and the available components, AI assistants can't help users build these content sections or full pages effectively.

The MCP server we are building bridges this knowledge gap by exposing tools that teach AI clients about MDC syntax, provide component examples and metadata, bundle resources for offline reference, and validate syntax to catch errors before they reach users.

Tools that teach AI assistants about MDC

Static resources for offline reference

Putting it all together: The AI client workflow

When a user asks for help creating portal content, the AI client will follow a natural workflow:

  1. Calls the syntax guide to understand MDC rules and structure
  2. Discovers available components and fetches their metadata (props, slots, interfaces)
  3. Reviews real usage examples to see patterns in action
  4. Generates the content and runs it through the validator to catch syntax errors

The end result is validated MDC content using real portal components with proper syntax, branded styling, and ready to publish.

You can use this same pattern whenever you have a custom syntax, component library, or API that AI needs to understand. An MCP server helps AI assistants work with your specific tools and formats.

## Building an MCP server

Let's build an MCP server from scratch. We'll use TypeScript and deploy to Cloudflare Workers using their Agents SDK.

Project setup

First, create a new project and install dependencies:

mkdir my-mcp-server && cd my-mcp-server && pnpm init

Install the required dependencies:

pnpm add @modelcontextprotocol/sdk agents zod

And the dev dependencies:

pnpm add -D typescript wrangler @types/node

TypeScript configuration

Create a tsconfig.json:

{
  "compilerOptions": {
    /* Language and Environment */
    "target": "ES2021",
    "lib": ["ES2022"],
    /* Modules */
    "module": "ES2022",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "types": ["node", "./worker-configuration.d.ts"],
    /* Emit */
    "noEmit": true,
    /* Interop Constraints */
    "isolatedModules": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    /* Type Checking */
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "allowUnreachableCode": false,
    /* Completeness */
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Wrangler configuration

Create a wrangler.toml file to configure your Cloudflare Worker:

main = "./src/index.ts"
name = "my-mcp-server"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]

# Bundle markdown files as text assets
rules = [\
  { type = "Text", globs = ["**/*.md"], fallthrough = true }\
]

# Durable Objects for persistent state
[[durable_objects.bindings]]
name = "MCP_OBJECT"
class_name = "MyMcpServer"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyMcpServer"]

The MCP server

Now let's create the server. Create src/index.ts and we'll build it step by step.

Imports and types

Start with the imports and a state interface for your server:

// src/index.ts
import { Agent, getAgentByName } from 'agents'
import { createMcpHandler } from 'agents/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'

// Define your server state (can be empty or hold session data)
interface State {}

The Agent class provides the foundation for stateful Workers. The createMcpHandler function generates an HTTP handler that speaks the MCP protocol. If you need to persist data across requests, you can add properties to the State interface.

The Agent class

Next, create your server class that extends Agent:

// src/index.ts
export class MyMcpServer extends Agent<Env, State> {
  server = new McpServer({
    name: 'My MCP Server',
    description: 'Helps AI clients generate and validate MDC content for the portal. **START HERE: Read the syntax guide resource first** to learn MDC rules before using other tools. Typical workflow: (1) Read mdc://docs/components resource, (2) list_components, (3) get_component_metadata, (4) get_component_examples (5) validate_mdc_syntax.',
    version: '1.0.0',
  })
}

The McpServer instance holds your server's metadata and handles tool and resource registration. Clients see this name and description when they connect.

Important: The description field should guide AI clients on how to use your server effectively. Include which tools to call first, any prerequisites, and the typical workflow sequence. AI clients use this to understand the intended usage pattern, so prioritize the most important information at the beginning.

Registering tools

The onStart() lifecycle method is where you register all your tools and resources.

Here's an example of a tool that fetches component metadata from our API.

First, define the tool metadata with input and output schemas:

// src/index.ts
async onStart() {
  this.server.registerTool(
    'get_component_metadata',
    {
      title: 'Get Component Metadata',
      description: 'Retrieves metadata for a specific component including props and slots.',
      inputSchema: {
        componentName: z.string().describe('The component name in kebab-case'),
      },
      outputSchema: z.looseObject({}).describe(
        'Component metadata including available props, types, slots, and documentation'
      ),
    },
    // Handler implementation below...
  )
}

Then implement the handler that fetches and returns the metadata:

// src/index.ts
async onStart() {
  this.server.registerTool(
    'get_component_metadata',
    { /* ...metadata from above */ },
    async (params: { componentName: string }) => {
      try {
        const requestUrl = `https://example.com/api/components/${params.componentName}`
        const response = await fetch(requestUrl)

if (!response.ok) {
          return {
            content: [{
              type: 'text',
              text: `Component "${params.componentName}" not found`
            }],
            isError: true,
          }
        }

const result = await response.json()

return {
          content: [{
            type: 'text',
            text: `\`
\`json\n${JSON.stringify(result, null, 2)}\n\`
\``,
          }],
          structuredContent: result as Record<string, unknown>,
        }
      } catch (error) {
        return {
          content: [{ type: 'text', text: `Error: ${error.message}` }],
          isError: true,
        }
      }
    },
  )
}

Tools have three parts: a name, metadata with input/output schemas, and an async handler. The outputSchema defines the structure of structuredContent, which AI clients can parse programmatically alongside the human-readable content.

This example demonstrates the basic pattern for tools that fetch external data: validate input, make the API request, handle errors with isError: true, and return both human-readable text and structured data.

Tool description best practices: Keep descriptions concise (1-2 sentences) and prioritize the most important information first. Specify when/why to use the tool, any prerequisites, and its place in the workflow. This helps AI clients understand the intended usage pattern.

Registering resources

Resources expose data that clients can read on demand. Extend the onStart() method to also register resources:

// src/index.ts
async onStart() {
  // ... tool registration from above

this.server.registerResource(
    'component_docs',
    'mdc://docs/components',
    {
      title: 'Component Documentation',
      description: 'Complete reference for all available MDC components including layouts and content blocks. Use after reading syntax guide to see component capabilities.',
      mimeType: 'text/markdown',
    },
    async (uri: any) => {
      const content = `# MDC Components

## Layout Components
- **page-hero**: Full-width hero sections with title, description, and actions
- **page-section**: Content sections with optional backgrounds
- **multi-column**: Responsive grid layouts

## Content Components
- **card**: Styled content cards with optional images
- **accordion**: Collapsible content sections
- **tabs**: Tabbed content panels`

return {
        contents: [
          {
            uri: uri.href,
            mimeType: 'text/markdown',
            text: content,
          },
        ],
      }
    },
  )
}

Resources are identified by a custom URI scheme (like mdc://docs/components). Unlike tools which AI calls on demand, resources are typically read once when the client needs reference material. Resource descriptions work the same way: be concise, specify when to use the resource, and mention any prerequisites or workflow context.

Handling MCP requests

Add a method to your MyMcpServer class to handle incoming MCP protocol requests:

// src/index.ts
export class MyMcpServer extends Agent<Env, State> {
  // ... server property and onStart() from above

async onMcpRequest(request: Request): Promise<Response> {
    return createMcpHandler(this.server)(request, this.env, {} as ExecutionContext)
  }
}

The createMcpHandler function takes your McpServer instance and returns an HTTP handler that implements the MCP protocol. This handles the bidirectional messaging between clients and your server.

The fetch handler

Finally, export a default handler that routes requests to your MCP server:

// src/index.ts
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const url = new URL(request.url)

if (url.pathname === '/mcp') {
      // Get session ID from header or generate a new one
      const sessionId = request.headers.get('mcp-session-id') ?? crypto.randomUUID()
      // Retrieve the agent instance for this session
      const agent = await getAgentByName<Env, MyMcpServer>(env.MCP_OBJECT, sessionId)
      return agent.onMcpRequest(request)
    }

return new Response('Not found', { status: 404 })
  },
}

The getAgentByName function retrieves (or creates) a Durable Object instance for each session. This gives each connected client its own isolated state. The session ID is in the mcp-session-id header, or you can generate one for new connections.

What's happening under the hood

To summarize the architecture:

  1. Agent class: Extends Agent from the Cloudflare Agents SDK, giving you access to Durable Object state and lifecycle hooks
  2. McpServer instance: Holds your server metadata and registered tools/resources
  3. onStart() lifecycle: Called when the Agent initializes, perfect for registering capabilities
  4. createMcpHandler(): Generates an HTTP handler that implements the MCP protocol
  5. getAgentByName(): Retrieves the Agent instance by session ID, enabling per-client state

Adding persistent session state

The basic example above works well for stateless servers, but production MCP servers often need to persist session state across requests. For this, you can add a WorkerTransport with Durable Object storage.

First, update your imports:

// src/index.ts
import { Agent, getAgentByName } from 'agents'
import { createMcpHandler, WorkerTransport } from 'agents/mcp'
import type { TransportState } from 'agents/mcp'

Then add a transport instance to your Agent class:

// src/index.ts
const STATE_KEY = 'mcp-transport-state'

export class MyMcpServer extends Agent<Env, State> {
  server = new McpServer({
    name: 'My MCP Server',
    description: 'A custom MCP server that provides domain-specific knowledge.',
    version: '1.0.0',
  })

// Worker transport for Streamable HTTP with persistent state
  transport = new WorkerTransport({
    sessionIdGenerator: () => this.name,
    storage: {
      get: async () => {
        return await this.ctx.storage.get<TransportState>(STATE_KEY)
      },
      set: async (state: TransportState) => {
        await this.ctx.storage.put(STATE_KEY, state)
      },
    },
  })
}

Finally, update your onMcpRequest() method to pass the transport to createMcpHandler:

// src/index.ts
export class MyMcpServer extends Agent<Env, State> {
  // ... server and transport properties from above

async onMcpRequest(request: Request): Promise<Response> {
    return createMcpHandler(this.server, {
      transport: this.transport,
    })(request, this.env, {} as ExecutionContext)
  }
}

The WorkerTransport uses your Durable Object's storage to persist session state between requests. This enables advanced MCP features like elicitation (asking the user for input) and sampling (requesting AI completions from the client). For most servers, the basic stateless approach is sufficient, but adding WorkerTransport gives you the full capabilities of the MCP protocol.

Adding more sophisticated tools

Tools can also perform complex operations like parsing and validation. Here's a MDC validator that checks for syntax errors, validates component structures, and ensures nested components are properly formed.

First, define the tool metadata with schemas for validation results:

// src/index.ts
async onStart() {
  this.server.registerTool(
    'validate_mdc_syntax',
    {
      title: 'Validate MDC Syntax',
      description: 'Validates MDC content and reports syntax errors with line numbers.',
      inputSchema: {
        content: z.string().describe('The MDC content to validate'),
      },
      outputSchema: z.object({
        valid: z.boolean().describe('Whether the MDC content is valid'),
        error_count: z.number().describe('Total number of errors found'),
        errors: z.array(z.object({
          line: z.number().describe('Line number where the error occurred'),
          message: z.string().describe('Description of the error'),
        })).describe('List of validation errors'),
      }),
    },
    // Handler implementation below...
  )
}

Then implement the validation logic using a stack to track nested components:

// src/index.ts
async onStart() {
  this.server.registerTool(
    'validate_mdc_syntax',
    { /* ...metadata from above */ },
    async (params: { content: string }) => {
      const errors: Array<{ line: number; message: string }> = []
      const lines = params.content.split('\n')
      const stack: Array<{ name: string; colons: number; line: number }> = []

lines.forEach((line, i) => {
        const openMatch = line.match(/^(:{2,})([\w-]+)/)
        const closeMatch = line.match(/^(:{2,})\s*$/)

if (closeMatch && !openMatch) {
          if (stack.length === 0) {
            errors.push({ line: i + 1, message: 'Closing tag without opening' })
          } else {
            if (closeMatch[1].length !== stack[stack.length - 1].colons) {
              errors.push({ line: i + 1, message: `Mismatched colons for \