Ömer
Özbay

Full-Stack Engineer

LOADING
©2026
What I Learned Using MCP in a Real AI Agent Project: Mistakes, Wins, and Architecture Decisions
ARTIFICIAL INTELLIGENCEMCPAI Agents

What I Learned Using MCP in a Real AI Agent Project: Mistakes, Wins, and Architecture Decisions

calendar_todayJUL 13, 2026
schedule8 MIN READ
boltADVANCED LEVEL

What I Learned Using MCP in a Real AI Agent Project

The official documentation for Model Context Protocol (MCP) makes it look clean and simple. And in a tutorial, it is. But when you deploy an MCP-based agent pipeline into a real workflow — one that runs daily, touches production data, and integrates with multiple external services — you discover a very different reality.

This is not a tutorial. This is a post-mortem of hard lessons from building and running an MCP agent system in production. I will cover the mistakes I made, the architectural decisions I got right, and the things nobody warns you about before you start.


What We Were Building

The project was an internal automation pipeline for gucluyumhe.dev — an MCP server that allowed a Claude-based AI agent to:

  1. Read and analyze site performance data from logs
  2. Draft and save new blog post drafts to the content directory
  3. Query a local database of published posts for duplication checks
  4. Trigger build previews via a shell command

Simple in theory. Surprisingly complex in production.


Lesson 1: Tool Design Is Everything — And It Is Hard

The most common MCP tutorial mistake is designing tools that are too broad. Here is an example of what not to do:

// TOO BROAD — dangerous in production
{
  name: "execute_command",
  description: "Execute any shell command on the local machine",
  inputSchema: {
    type: "object",
    properties: {
      command: { type: "string", description: "The shell command to run" }
    },
    required: ["command"]
  }
}

Why is this dangerous? Because the LLM will use it exactly as designed. If an agent encounters a problem it cannot parse, it will try destructive shell commands because those are valid solutions. And it will not ask you first.

The Fix: Narrow, Purpose-Built Tools

// CORRECT: specific, safe, intentional
{
  name: "save_blog_draft",
  description: "Save a new blog post draft to the content/posts directory. Only accepts markdown content with valid frontmatter.",
  inputSchema: {
    type: "object",
    properties: {
      slug: {
        type: "string",
        pattern: "^[a-z0-9-]+$",
        description: "URL-safe slug for the post filename"
      },
      content: {
        type: "string",
        description: "Full markdown content including frontmatter"
      },
      locale: {
        type: "string",
        enum: ["en", "tr"],
        description: "Language of the blog post"
      }
    },
    required: ["slug", "content", "locale"]
  }
}

Rule of thumb: a tool should do exactly one thing, and its schema should make invalid inputs structurally impossible.


Lesson 2: Error Handling in Tools Is Your Responsibility, Not the LLM's

When a tool throws an unhandled error, MCP propagates a raw exception to the LLM. In some cases, the model interprets this as a permission issue and escalates — trying more aggressive approaches.

The Pattern That Saved Us

Every tool handler should return a structured error response, not throw:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    switch (name) {
      case 'save_blog_draft':
        return await handleSaveBlogDraft(args);
      case 'get_post_list':
        return await handleGetPostList(args);
      default:
        return {
          content: [{
            type: "text",
            text: JSON.stringify({
              error: "UNKNOWN_TOOL",
              message: `Tool '${name}' is not registered on this server.`,
              availableTools: ['save_blog_draft', 'get_post_list']
            })
          }],
          isError: true
        };
    }
  } catch (err) {
    const error = err instanceof Error ? err : new Error(String(err));
    console.error(`[MCP Tool Error] ${name}:`, error.message);

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          error: "TOOL_EXECUTION_FAILED",
          tool: name,
          message: error.message,
          hint: "The operation could not be completed. Please verify the input parameters."
        })
      }],
      isError: true
    };
  }
});

The isError: true flag tells the LLM that the tool failed, which triggers its retry or fallback reasoning rather than silent continuation.


Lesson 3: The LLM Will Misuse Tools You Did Not Think About

During testing, I gave the agent access to a read_file tool for reading blog posts. Within one session, it had:

  1. Read every markdown file in the posts directory — expected, correct
  2. Read package.json to understand the project structure — unexpected, but harmless
  3. Read .env because it was trying to understand project configuration — dangerous, security risk

The LLM is not malicious. It is genuinely trying to be helpful. But helpful in this context meant reading files you never intended to expose.

The Fix: Strict Path Allowlisting

const ALLOWED_DIRECTORIES = [
  '/content/posts',
  '/public/images'
] as const;

async function handleReadFile(args: unknown): Promise<ToolResult> {
  const { path: filePath } = args as { path: string };

  const resolvedPath = path.resolve(PROJECT_ROOT, filePath);

  const isAllowed = ALLOWED_DIRECTORIES.some(dir => {
    const allowedResolved = path.resolve(PROJECT_ROOT, dir);
    return resolvedPath.startsWith(allowedResolved + path.sep) ||
           resolvedPath === allowedResolved;
  });

  if (!isAllowed) {
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          error: "ACCESS_DENIED",
          message: "This file path is not accessible. Only /content/posts and /public/images directories are readable."
        })
      }],
      isError: true
    };
  }

  try {
    const content = await fs.readFile(resolvedPath, 'utf-8');
    return { content: [{ type: "text", text: content }] };
  } catch (err) {
    const error = err instanceof Error ? err : new Error(String(err));
    return {
      content: [{ type: "text", text: JSON.stringify({ error: "FILE_READ_FAILED", message: error.message }) }],
      isError: true
    };
  }
}

Lesson 4: Rate Limiting Is Not Optional in Production

Without rate limiting, a single misbehaving agent session can exhaust your system resources. In one test session where the agent got into a retry loop, it called get_post_list 47 times in 90 seconds.

Simple Token Bucket Implementation

interface RateLimitEntry {
  tokens: number;
  lastRefill: number;
}

const rateLimits = new Map<string, RateLimitEntry>();

function checkRateLimit(toolName: string, maxTokens = 10, refillRateMs = 60000): boolean {
  const now = Date.now();
  const entry = rateLimits.get(toolName);

  if (!entry) {
    rateLimits.set(toolName, { tokens: maxTokens - 1, lastRefill: now });
    return true;
  }

  const elapsed = now - entry.lastRefill;
  const refillAmount = Math.floor(elapsed / refillRateMs) * maxTokens;

  if (refillAmount > 0) {
    entry.tokens = Math.min(maxTokens, entry.tokens + refillAmount);
    entry.lastRefill = now;
  }

  if (entry.tokens <= 0) {
    return false;
  }

  entry.tokens--;
  return true;
}

async function handleGetPostList(args: unknown): Promise<ToolResult> {
  if (!checkRateLimit('get_post_list', 20, 60000)) {
    return {
      content: [{
        type: "text",
        text: JSON.stringify({ error: "RATE_LIMITED", message: "Too many requests. Please wait before calling this tool again." })
      }],
      isError: true
    };
  }

  // actual implementation follows
}

Lesson 5: Resources vs Tools — The Distinction Matters

MCP has two primitives for exposing data: Resources (read-only data) and Tools (executable actions). Most tutorials use only Tools because they are simpler to demonstrate. But mixing read and write operations into a single primitive leads to design problems.

The correct model:

  • Resources: Blog post list, site metrics, published content catalog — expose as Resources
  • Tools: Write draft, trigger build, send notification — expose as Tools
// Resources: passive data the LLM can read
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    {
      uri: "content://posts/list",
      name: "Published Blog Posts",
      description: "Complete list of all published blog posts with metadata",
      mimeType: "application/json"
    }
  ]
}));

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params;

  if (uri === "content://posts/list") {
    const posts = await getAllPosts();
    return {
      contents: [{
        uri,
        mimeType: "application/json",
        text: JSON.stringify(posts, null, 2)
      }]
    };
  }

  throw new Error(`Resource not found: ${uri}`);
});

This separation also helps from a security standpoint: you can audit all write operations (Tools) separately from read operations (Resources).


What Actually Worked Well

After all the hard lessons, here is the production architecture and why it worked:

Claude Desktop / Agent Host
        |
  MCP Server (TypeScript)
        |
  Allowlisted Resources (read-only)
    content://posts/list
    metrics://pagespeed/latest
        |
  Rate-Limited Tools (write operations)
    save_blog_draft
    trigger_build_preview
        |
  Structured Error Responses (always returned)

Key design decisions that made it stable:

  1. Narrow tools — each tool does one thing
  2. Allowlisted paths — LLM cannot access sensitive files
  3. Structured errors — LLM always gets a parseable response
  4. Rate limiting — prevents runaway loops
  5. Resource and Tool separation — read vs write is architecturally enforced
  6. Audit logging — every tool call is logged with arguments and results

Conclusion

MCP is genuinely powerful. The ability to give an LLM safe, structured access to your local environment and production systems opens up automation possibilities that were previously impractical.

But safe is not a default — it is something you architect deliberately. The LLM will use every capability you give it, in ways you did not always expect. Your job as the architect is to make sure that what it can do is exactly what you want it to do, and nothing more.

Design narrow tools. Enforce strict boundaries. Return structured errors. Rate limit everything. Then the magic actually works.

Ömer Özbay
Written By

Ömer Özbay

Full-Stack Engineer specialized in bridging high-performance backend architectures with pixel-perfect frontend experiences. Building the future with AI and modern web technologies.

What I Learned Using MCP in a Real AI Agent Project: Mistakes, Wins, and Architecture Decisions | Ömer Özbay Blog | Ömer Özbay