>M_
Published on

Building an MCP Server with Mastra: What It Actually Taught Me

Authors

I have been asking myself a simple question lately: is MCP the future of how we expose APIs to software, or is it just the current hype wrapper around tool calling? The only way I know to get a real answer is to build something, so I followed Mastra's guide on publishing an MCP server and built the simplest version I could.

This post is not a tutorial. It is a record of what I built, what happened when I connected it to two different clients, and what I still do not know.

What MCP actually is

The Model Context Protocol (MCP) is a standard way for an AI model to discover and call tools, read resources, and use prompts exposed by a server, without the model's host application needing custom integration code for every single tool. Before MCP, if I wanted an agent to call my API, I had to write a bespoke tool definition for that specific agent framework. With MCP, I expose the same server once, and any MCP-compatible client (an agent, a chatbot, an IDE) can talk to it using the same protocol.

Under the hood it is JSON-RPC over a transport (stdio for local processes, HTTP/SSE for remote ones). The server advertises a list of tools with their input schemas, and the client decides when to call them based on the conversation.

What I built

Mastra's tutorial walks you through scaffolding a server with a handful of tools using their SDK. I kept mine deliberately small: one tool that returns a canned response and one that takes a simple typed input. Here is roughly the shape of the tool definition:

import { MCPServer } from '@mastra/mcp'

const server = new MCPServer({
  name: 'my-first-mcp-server',
  version: '1.0.0',
  tools: {
    getStatus: {
      description: 'Returns the current status of the demo service',
      parameters: {},
      execute: async () => {
        return { status: 'ok', timestamp: new Date().toISOString() }
      },
    },
  },
})

That is it. No database, no auth, no real business logic. The point of this first pass was to understand the wiring, not to ship something production-worthy.

Connecting it to a self-built agent

Mastra lets you build both sides, the server and the agent that consumes it, so the first test was pointing a small agent I wrote at my own server. This is where the protocol clicked for me: the agent did not need any hardcoded knowledge of my tool. It read the tool's description and parameter schema from the server at connection time, and decided on its own, based on the user's prompt, whether calling getStatus was relevant.

What surprised me is how much the quality of the tool's description field matters. When I wrote a vague description, the agent either ignored the tool or called it at the wrong moment. Rewriting the description to be specific about when the tool should be used fixed most of that. This is not really an MCP-specific lesson, it is the same lesson from function calling in general, but seeing it play out live made it concrete instead of theoretical.

Connecting it to Claude

The second test was pointing Claude Desktop at the same server using the standard MCP client configuration. This worked with less friction than I expected: same server, same tool definitions, no code changes on my side. Claude picked up the tool, showed it to me as an available capability, and called it when I asked something that matched the tool's description.

The interesting difference from the self-built agent test was in how conservative Claude was about calling the tool unprompted. It asked clarifying questions before invoking getStatus in situations where my own agent would have called it immediately. That is a client-side behavior difference, not a protocol difference, but it is a useful reminder that "MCP-compatible" does not mean "behaves identically across clients."

When it gets less simple: a real local MCP issue

The demo server above is trivial enough that it hides a class of problems that show up as soon as a server gets slightly more realistic. I hit one directly while testing locally with Claude Desktop: tools I had registered were not showing up at all, with no error on my side, the server started fine and tools/list returned normally.

Claude's own docs on local MCP servers were the thing that actually got me unstuck. The relevant issue for me was that Claude silently drops all tools when the tools/list response includes an outputSchema. My tool definitions had an output schema attached because that is what the Mastra SDK generates by default, and Claude Desktop's client apparently does not handle that field the way I assumed, so instead of rejecting one tool or raising a visible error, it dropped the entire tool list for that server. Removing outputSchema from the tool definitions fixed it immediately.

The lesson here is not really about this one bug. It is that "MCP-compatible" clients can fail silently and inconsistently on parts of the spec that are technically valid, and the failure mode is not always a helpful error message, it can just look like your server has no tools at all. Debugging that meant checking the server side first (it was fine), then going client-by-client to see whether the same server behaved differently, which is how I found the Claude-specific note above.

Is this the future of APIs?

I do not think I can honestly answer that yet from one tutorial-sized server. What I can say is that the separation of concerns is real: I wrote the tool once, and two very different clients (an agent I control fully, and a chatbot I do not) were able to use it without me writing client-specific glue code. That is the same value proposition REST had over ad-hoc RPC, applied one layer up, at the "how does an AI model discover and use my API" layer.

What I have not tested yet, and what would actually tell me something: exposing a real API I already run, with real auth and real data, as an MCP server, and seeing whether the same protocol holds up when the tool has side effects and the input space is larger than a demo. That is the next thing on my list.

What I would do differently

I would spend more time on the tool description and parameter schema from the start rather than treating it as an afterthought. It is the only interface the model actually sees, so it deserves the same care I would put into a public API contract, arguably more, since there is no human reading the docs to compensate for an ambiguous field name.

Reference