Here's something a bit funny: this blog post was published by Claude.
I didn't open an admin page. I didn't paste anything into a text editor. I had a conversation with Claude about what I wanted to write, and Claude called my own app to upload the cover image, create the post, set the category and publish it. My app did the work. Claude was just the one talking to it.
That works because my app has an MCP server. In this post I want to explain what that actually means, how function calling and MCP tools work underneath, how I built it in .NET, and one lesson I learned the hard way: more tools is not always better.
First, what is function calling?
Before MCP makes sense, function calling has to.
A language model on its own can only produce text. It can't query your database, call your API or send an email. Function calling (also called tool calling) is the trick that lets it ask for those things.
The flow looks like this:
- Your app sends the model a message, plus a list of tools it's allowed to use. Each tool has a name, a description and a schema for its parameters.
- The model reads the message and decides it needs a tool. Instead of a normal answer, it replies with something like: "please call
search_questionswithkeyword = "async"". - Your app runs the function. The model never runs anything itself.
- Your app sends the result back to the model.
- The model uses the result to write its final answer, or asks for another tool.
The important part is step 3. The model only requests a call. Your code decides whether to run it, runs it, and controls what goes back. That's where all your validation and security lives.
In .NET, Microsoft.Extensions.AI makes this very clean. You turn a normal C# method into a tool, and middleware handles the back and forth:
[Description("Gets the number of published interview questions in a category.")]
static int CountQuestions(string category) => repo.CountByCategory(category);
IChatClient client = new ChatClientBuilder(providerClient)
.UseFunctionInvocation()
.Build();
var options = new ChatOptions
{
Tools = [AIFunctionFactory.Create(CountQuestions)]
};
var response = await client.GetResponseAsync(
"How many questions do I have about LINQ?", options);
AIFunctionFactory reads the method, its parameters and the Description attribute, and builds the schema for the model. UseFunctionInvocation handles the loop: when the model asks for the function, it runs it and sends the result back automatically.
This is great when your app is the one talking to the model. But what if you want someone else's AI app, like Claude, to use your functions? That's where MCP comes in.
So what is MCP?
The Model Context Protocol is an open standard for connecting AI applications to tools and data. If function calling is the idea, MCP is the plug that makes it work across different apps.
There are three roles:
- The host is the AI app you're using. For me, that's Claude.
- The client lives inside the host and handles the connection to a server.
- The server is your code. It tells the client what it can do, and does it when asked.
A server can offer a few different things, but the one I use the most is tools: functions the model can call, exactly like function calling above. The difference is that the tools live in my app, behind my API, and any MCP-compatible client can discover them and use them.
MCP servers can run locally over standard input and output (good for desktop tools), or remotely over HTTP. Mine is a remote server, hosted as part of my ASP.NET Core app, so Claude can reach it from anywhere.
The way I explain it to friends: a REST API lets other programs use your app. An MCP server lets AI assistants use your app.
Building an MCP server in .NET
There's an official C# SDK for MCP, maintained together with Microsoft. It comes as NuGet packages:
ModelContextProtocolfor the core server and client features.ModelContextProtocol.AspNetCorefor hosting a server over HTTP in ASP.NET Core.
If you've built a minimal API before, this will feel very familiar. In Program.cs:
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
var app = builder.Build();
app.MapMcp("/mcp");
Then the tools themselves are just classes and methods with attributes. Here's a simplified version of one of my blog tools:
[McpServerToolType]
public class BlogTools(IBlogService blog)
{
[McpServerTool(Name = "blog_create_post")]
[Description("Writes a new blog post (markdown content). Saved as a draft unless publish = true.")]
public async Task<PostResult> CreatePost(
[Description("The post title.")] string title,
[Description("The whole post body, in markdown.")] string content,
[Description("Publish it now, or keep it as a draft.")] bool publish = false)
{
var post = await blog.CreateAsync(title, content);
if (publish)
await blog.PublishAsync(post.Id);
return PostResult.From(post);
}
}
A few things to notice:
- Dependency injection works.
IBlogServiceis my normal service, the same one my admin pages use. The MCP tool is just another front door to it. - Descriptions are everywhere. On the tool, and on every parameter. The model reads these to decide what to call and what to pass.
- Defaults are safe. A new post is a draft unless publishing is explicitly asked for.
The SDK turns all of this into the schema the MCP client sees. When Claude connects, it gets the list of tools, their descriptions and their parameters, and that's all it needs.
Connecting it to Claude
Once the server is deployed with HTTPS, connecting it is the easy part. I added it to Claude as a custom connector, using the server's URL. After that, my tools show up in Claude like any built-in feature.
I actually run two of them: a UAT server and a production server, connected side by side. That turned out to be really useful. I test new tools on UAT first, by talking to Claude, before anything reaches production. I've even asked Claude to test all my blog tools on UAT and write a bug report, then fixed the bugs, and asked it to test again.
A remote MCP server is a public API, so it needs proper protection: HTTPS, authentication, and permission checks inside the tools themselves. Never assume only Claude will ever call it.
How this post was actually published
Here's what happened behind the scenes when I asked Claude to write and publish this post. Roughly, it went like this:
blog_get_guide: Claude read my guide tool first. It explains how posts are written, which markdown is supported, which code languages work, and lists the current categories and tags.blog_upload_image_from_url: Claude found a suitable free photo and asked my server to download it and store it in my own storage. The tool returned the new image URL.blog_create_post: Claude sent the title, the markdown content, the summary, the category, the tags and the cover image.blog_set_published: once I said "publish it", Claude published it, or scheduled it for a date I chose.
I could also say things like "copy this post from UAT to production" or "make this post a little longer", and Claude would read the post from one server, change it, and write it to the other.
One moment I liked: when Claude tried to copy images from my UAT storage to production, my production server refused to download them, because it only accepts images from public websites. That's a security rule I wrote myself, and it worked even when the request came from an AI. Claude found new free images instead. The rules in my code still won.
The guide tool pattern
That first step, blog_get_guide, is one of the best design decisions I made. Instead of putting every rule into every tool description, I have one tool that explains how to use the other tools: content format, image rules, how scheduling works. My server also gives short instructions to the client, like "call the guide first" and "new posts stay drafts unless the user asks to publish".
It's basically onboarding documentation, but for an AI.
Lessons from designing tools
Writing the tool code was the easy part. Designing the tools well took much longer.
- Names and descriptions are your API docs. The model picks a tool based on its name and description. A vague description means the wrong tool gets called, or the right tool gets called with the wrong values.
- Prefix your tools. All my blog tools start with
blog_and my interview tools start withinterview_. It helps the model, and it helps me when reading logs. - Return useful errors. If something fails, say why and what to do instead. The model can often fix its own mistake if the error message is clear.
- Make the safe thing the default. Drafts by default. Deletes only when the user clearly asks. Destructive tools should be rare and well described.
- Return what the model needs next. When a post is created, I return its id, its status and its URL, so the next step doesn't need another lookup.
The cons of too many tools
This is the lesson I learned the hard way.
When something works, you want to add more. My server started with a few tools, and now it has around 17 blog tools and 16 interview tools. And because I connect both UAT and production, Claude sees more than 60 tools from my servers alone, before counting anything else it's connected to.
That comes with real costs.
1. Every tool costs context. The name, description and full parameter schema of every tool has to be given to the model so it knows what's available. With dozens of detailed tools, that's a lot of tokens used before the conversation even starts. More tokens means more cost, slower responses, and less room for the actual work.
2. The model chooses worse. With five tools, picking the right one is easy. With sixty, especially when some sound similar, the chance of picking the wrong one goes up. blog_update_post versus blog_set_published versus blog_set_featured is clear to me, but the model has to work it out every time.
3. Overlapping tools confuse everyone. If two tools can do almost the same thing, the model will sometimes pick the "wrong" one, and you'll get different behaviour for the same request. Duplicate environments (like UAT and production with identical tool names) make this even trickier. I always have to be clear which one I mean.
4. More tools, more attack surface. Every tool is an action an AI can take, and every tool is something that can be misused through prompt injection. A tool you don't really need is a risk you don't need either.
5. More to maintain and test. Every tool needs validation, error handling, documentation and tests. When the underlying service changes, every tool built on it might need updating too.
6. Clients have limits. Some AI apps limit how many tools they load at once, or load them lazily only when needed. A huge server might not behave the same in every client.
What I'd do instead
- Start small. Add a tool when you have a real use for it, not because it might be handy.
- Combine where it makes sense. One
update_posttool with optional fields is often better than five tiny tools that each change one field. But don't go the other way and make one giant tool that does everything. - Split by domain. If one server grows too big, split it. A blog server and an interview server can be separate, and users connect only the ones they need.
- Separate read from write. Read-only tools are safe to use freely. Write tools deserve more care, and sometimes a separate, more restricted server.
- Only connect what you need. I don't need production connected when I'm testing new features on UAT.
Why I think this matters
A few years ago, every app needed a REST API so other software could talk to it. I really believe that soon, a lot of apps will also need an MCP server, so AI assistants can talk to them.
And the best part for .NET developers is that we already have the skills. It's ASP.NET Core, dependency injection, good API design, validation and security. The protocol is new, but the engineering isn't.
For me personally, it changed how I use my own app. I don't log into an admin panel to write posts anymore. I have a conversation about what I want to say, review the draft, and say "publish it". It still feels a little like magic, even though I know exactly what every tool does, because I wrote them.
If you want to try it, start with one simple read-only tool in an existing ASP.NET Core app, connect it to an AI client, and ask it a question about your own data. The first time it answers using your app, you'll get it.
Have you built an MCP server yet, or connected one to Claude or another assistant? I'd love to hear what tools you built and what you learned. Let me know in the comments.
Comments (0)
🔑 Log in to comment Sign in with your email to like, save and join the discussion.
No comments yet. Be the first!