Today I decided to work on creating an MCP service and a related admin interface.

MCP, or Model Context Protocol, provides a standard way for an AI agent to interact with external services and tools. In this case, the idea is to give an AI agent a way to explore the products, services and technical capabilities I offer through Protoworks and, if appropriate, raise an enquiry that I can respond to.

The interesting part for me isn’t really the MCP itself. It’s the possibility of providing another route through which someone can discover and potentially commission my services.

That someone might be a human directly using an AI agent to investigate possible suppliers on their behalf or, perhaps increasingly in the future, an agent acting with a greater degree of autonomy.

Either way, if the human on the other end — or eventually the agent itself — can pay for my services, I’m quite happy to give them a means of introduction.

I’m not providing a means for an agent to actually pay me through the interface quite yet.

The basic model looks something like this:

Human / Customer
       |
       v
    AI Agent
       |
       v
 Protoworks MCP
       |
       v
Conversation Database
       |
       v
Avalonia Admin Interface
       |
       v
      Me
       |
       v
Conversation Database
       |
       v
 Protoworks MCP
       |
       v
    AI Agent
       |
       v
Human / Customer

The MCP is effectively the agent-facing interface to Protoworks, while the admin application is my human-facing interface to the same underlying conversation system.

An agent can raise an enquiry and receive a unique conversation identifier and access token. It can then return from time to time to check whether I’ve replied and continue the conversation.

The MCP Service#

The MCP service is relatively simple and is written in .NET 10 using ASP.NET Core and the MCP C# SDK.

The MCP server is registered with the ASP.NET Core dependency injection container in Program.cs, along with the class containing the tools I want to expose to agents.

In this case that class is called ProtoworksMcpTools.

Methods exposed as MCP tools are decorated with an McpServerTool attribute along with a Description attribute. The tool metadata tells the MCP client what is available, while the descriptions provide the agent with information about what each tool does and when it should be used.

I’ll include some relevant snippets below. I’ll probably cover building a generic MCP service in more detail in a later post.

Program.cs

var builder = WebApplication.CreateBuilder(args);

// Configuration related stuff...

// Database stuff for storing conversation history.
// Usual Entity Framework stuff...

// Rate limiting stuff...

// Then MCP stuff...

var mcpSettings =
    builder.Configuration
        .GetSection(McpOptions.SectionName)
        .Get<McpOptions>()
    ?? new McpOptions();

builder.Services
    .AddMcpServer(options =>
    {
        options.ServerInfo = new Implementation
        {
            Name = mcpSettings.ServerName,
            Version = mcpSettings.ServerVersion,
        };
    })
    .WithTools<ProtoworksMcpTools>()
    .WithHttpTransport(http =>
    {
        http.SessionMode = HttpServerSessionMode.Stateless;
    });

// More stuff...

app.MapMcp(
    app.Services
        .GetRequiredService<IOptions<McpOptions>>()
        .Value.EndpointPath);

This uses the HTTP transport because the intention is for the MCP to be remotely accessible rather than something running locally alongside an AI application.

One slightly interesting detail is that I’ve configured the MCP transport itself to be stateless.

That doesn’t mean the Protoworks enquiry system is stateless.

The HTTP MCP connection doesn’t need to maintain a long-lived MCP session between requests, but conversations raised through the MCP absolutely do have state. That state is stored in the application’s database and is identified using the conversation ID and access token returned when an enquiry is created.

In other words, the MCP transport can remain stateless while the business process sitting behind it remains stateful.

The tool class currently looks broadly like this:

ProtoworksMcpTools.cs

public sealed class ProtoworksMcpTools(
    ICatalogueService catalogueService,
    IConversationService conversationService,
    IConversationPresenter presenter,
    IClientKeyProvider clientKeyProvider)
{
    [McpServerTool(
        Name = "get_provider_info",
        Title = "Get Protoworks provider information",
        ReadOnly = true,
        Idempotent = true,
        Destructive = false,
        OpenWorld = false)]
    [Description(
        "Returns factual information about Protoworks: who they are, " +
        "where they are located, their contact details, websites, " +
        "the type of services they provide, their engagement models, " +
        "and whether they accept asynchronous enquiries initiated by AI agents. " +
        "Call this before anything else when you need to know what Protoworks is " +
        "or how to reach a human.")]
    public ProviderInfoDto GetProviderInfo()
        => catalogueService.GetProviderInfo();

    [McpServerTool(
        Name = "get_capabilities",
        Title = "List Protoworks technical capabilities",
        ReadOnly = true,
        Idempotent = true,
        Destructive = false,
        OpenWorld = false)]
    [Description(
        "Returns a structured list of Protoworks' technical capabilities " +
        "and areas of expertise, for example embedded systems, PCB design, " +
        "firmware, STM32, ESP32, RP2040, Raspberry Pi and .NET. " +
        "Use the returned information to judge whether a project matches " +
        "Protoworks' expertise.")]
    public CapabilityDto[] GetCapabilities()
        => [.. catalogueService.GetCapabilities()];

    [McpServerTool(
        Name = "get_services",
        Title = "List Protoworks commercial services",
        ReadOnly = true,
        Idempotent = true,
        Destructive = false,
        OpenWorld = false)]
    [Description(
        "Returns the commercial services Protoworks currently offers. " +
        "Use the returned service ids when creating an enquiry with create_enquiry.")]
    public ServiceDto[] GetServices()
        => [.. catalogueService.GetServices()];

    [McpServerTool(
        Name = "get_pricing",
        Title = "Get Protoworks pricing information",
        ReadOnly = true,
        Idempotent = true,
        Destructive = false,
        OpenWorld = false)]
    [Description(
        "Returns Protoworks' current pricing information. " +
        "Prices are identified as published rates, quotation required or free. " +
        "Never state a project cost that is not present in this data. " +
        "If pricing requires quotation, tell the user that and create an enquiry instead.")]
    public PricingEntryDto[] GetPricing()
        => [.. catalogueService.GetPricing()];

    [McpServerTool(
        Name = "create_enquiry",
        Title = "Start a project scoping enquiry with Protoworks",
        ReadOnly = false,
        Idempotent = false,
        Destructive = false,
        OpenWorld = true)]
    [Description(
        "Starts a new asynchronous project or scoping enquiry with Protoworks " +
        "on behalf of the user. Returns a conversation_id and an access_token; " +
        "both are required for later conversation calls. " +
        "Only create an enquiry when the user has clearly expressed an interest " +
        "in engaging Protoworks or asks to be contacted.")]
    public async Task<CreateEnquiryResult> CreateEnquiry(
        [Description(
            "Short summary of what the enquiry is about, " +
            "e.g. 'Firmware for custom STM32 sensor board'. Required.")]
        string subject,

        [Description(
            "The initial message to Protoworks describing the project, " +
            "need, timeline and any constraints. Required.")]
        string initialMessage,

        [Description(
            "Optional id of a Protoworks service from get_services " +
            "that this enquiry relates to.")]
        string? serviceId = null,

        [Description(
            "Optional structured project summary: goals, scope, " +
            "current status and expected deliverables.")]
        string? projectSummary = null,

        [Description(
            "Optional list of relevant technologies, " +
            "e.g. ['STM32', 'RS485', '.NET'].")]
        string[]? technologies = null,

        [Description(
            "Optional client location, " +
            "e.g. 'Belfast, Northern Ireland'.")]
        string? location = null,

        [Description(
            "Optional reference code supplied by the client or calling agent " +
            "so they can track this enquiry in their own system.")]
        string? agentReference = null)
    {
        // Implementation code...
    }

    // More stuff...
}

I’ve shortened some of the descriptions above for readability, but you get the idea.

What I find particularly interesting here is that these descriptions aren’t simply documentation for another programmer.

They’re effectively instructions to an AI agent about how my business interface should be used.

For example, the pricing tool explicitly tells the agent not to invent a project price where Protoworks hasn’t published one. If something needs to be quoted individually, the agent should say so rather than attempting to extrapolate a price.

Likewise, create_enquiry tells the agent that it should only open a conversation when the user has actually expressed an interest in engaging Protoworks.

The parameters themselves are also described, giving the agent enough information to assemble a useful enquiry from the information it already has rather than simply sending me something like:

Hello. Customer wants electronics. Please advise.

At least that’s the theory.

Informational tools such as GetServices() simply return structured data, in this case an array of ServiceDto records.

A tool such as CreateEnquiry() is slightly more involved because it writes information to the system and creates the conversation that both the agent and I subsequently interact with.

When the enquiry is created the agent receives a unique conversation_id and an access_token. It needs to retain both if it wants to retrieve that conversation or send additional messages later.

That also gives me a fairly straightforward separation between a conversation’s identifier and the credential required to access it.

Because this is intended to become a public Internet-facing service, I’ve also included rate limiting and other fairly mundane defensive measures around the endpoint. The implementation is still evolving, but anything capable of creating data in a public-facing system obviously needs more thought than simply exposing a method and hoping everybody behaves themselves.

Admin Interface#

The other side of this is the admin interface.

This is a GUI application, once again written in .NET, using Avalonia.

Avalonia is a XAML-based cross-platform GUI framework somewhat similar to WPF. This means I can run the application on both Windows and Linux, which is useful as I intend to switch my main development environment fully over to Linux in the next few months.

The Admin Interface

The Admin Interface

The interface is conversational in nature. On the left is a list of enquiries along with their current state, allowing me to quickly see whether a conversation is waiting for me or waiting for the agent. Selecting an enquiry displays its metadata and complete conversation history, and I can reply directly from the panel at the bottom.

The example above is an intentionally absurd test conversation, but it demonstrates the complete asynchronous workflow: an agent creates an enquiry through the MCP, I respond through the admin application, and the agent can subsequently retrieve that response and continue the conversation.

Each conversation is effectively its own asynchronous thread identified by a unique ID.

This means neither I nor the person using the agent needs to be present at the same time.

I don’t expect an AI agent to phone me for a scoping call, after all.

Instead, it has a machine-friendly way of asking a question, going away, and coming back later to see whether the human has replied.

I’ll detail the admin application further in another post as it’s still very much in development.

Where This Could Go#

Whether AI agents will actually start discovering and interacting with businesses in this fashion to any meaningful degree remains to be seen.

But I don’t think it’s particularly difficult to imagine.

We’re already using agents to investigate products, research companies, compare services and perform increasingly complicated tasks on behalf of humans.

Giving one a structured mechanism through which it can ask a supplier a question doesn’t seem like a particularly large conceptual jump.

And that’s really the experiment here.

Rather than expecting an AI agent to navigate a website intended entirely for humans, interpret a contact form and somehow deal with a telephone-based sales process, I’m giving it an interface designed specifically for software to interact with.

The website remains there for humans.

The MCP provides another door for agents.

Whether anything interesting eventually walks through it is something I’ll find out.

Next Steps#

My next steps are to refine the codebases for both the MCP and the admin interface, iron out the remaining bugs and do some more testing around the conversation and authentication mechanisms.

Once I’m reasonably happy with it, I’ll deploy the MCP alongside the existing Protoworks website infrastructure and start experimenting with how agents discover and use it.

Hopefully that will be sometime in the next few days — or weeks, other work permitting.

I’ll document the more interesting parts as I go.