Putting an MCP server in front of a legacy module

The request arrives in roughly the same words every time. Someone wants an assistant that can answer "what is the open balance on this account" or "create the return authorization" and the data lives in a system written in 2003. The rewrite is eighteen months away, if it is funded at all. The question is whether an agent can use the old system in the meantime.

It can, carefully. The Model Context Protocol is an open standard for exposing tools and data to language-model clients, and the model does not care what runs behind the tool. What matters is what you let it call and what happens when it calls the wrong thing.

This tutorial builds that layer in the order we build it on real engagements: an API first, then the MCP server, then the four controls that make it safe to leave running. The stack is .NET 8 and the official C# SDK for MCP. The legacy system stays where it is.

Build the API before the MCP server

Skip this step and you will regret it. An MCP server is a transport and a description; it is not a place to put business logic. Write the boring API first, because it is the thing you can test, the thing the next integration reuses, and the thing that survives when the protocol changes.

Start with one module. Pick something with clear inputs and outputs — order lookup, inventory availability, customer balance — not the module that touches everything.

How the API reaches the legacy code depends on the stack:

  • VB6 or classic COM. Register the DLL and call it from a .NET wrapper over COM interop, or, better, keep the COM call inside a single-threaded worker process the API talks to. COM apartment rules and a web server's thread pool do not mix well.
  • Classic ASP or WebForms. Usually the fastest path is to call the same stored procedures the pages call, from a small read model in the API. Do not scrape the pages if the database is reachable.
  • Microsoft Access. Move the queries to the API using OLEDB against the back-end .accdb, single writer only, or replicate the tables into SQL Server and read from there. Access is not a concurrent server and no amount of wrapping makes it one.
  • Visual FoxPro. Read DBF files directly with the VFP OLEDB provider, or, where the data has already been mirrored into SQL Server, read the mirror.

What you want at the end is a handful of methods with typed inputs and outputs:

public interface IOrderService
{
    Task<OrderSummary?> GetOrderAsync(string orderNumber, CancellationToken ct);
    Task<IReadOnlyList<OrderSummary>> FindOrdersAsync(string customerCode, DateOnly since, CancellationToken ct);
    Task<HoldResult> PlaceCreditHoldAsync(string orderNumber, string reason, string actor, CancellationToken ct);
}

Two reads and one write. That is enough to be useful and small enough to reason about.

Stand up the MCP server

Project and host

dotnet new web -o Relic.Mcp
cd Relic.Mcp
dotnet add package ModelContextProtocol.AspNetCore
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IOrderService, LegacyOrderService>();
builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

var app = builder.Build();
app.MapMcp("/mcp").RequireAuthorization();
app.Run();

Use the HTTP transport, not stdio, for anything a colleague will connect to. Stdio is convenient while you are developing on your own machine and useless once the server needs to live behind your identity provider.

Write the read tools first

[McpServerToolType]
public sealed class OrderTools(IOrderService orders)
{
    [McpServerTool(Name = "get_order")]
    [Description("Look up one order by its order number, for example S-104233. " +
                 "Returns status, customer, order date, total and hold reason. " +
                 "Read-only. Returns null if the order number does not exist.")]
    public async Task<OrderSummary?> GetOrder(
        [Description("Order number exactly as printed on the order, e.g. S-104233")]
        string orderNumber,
        CancellationToken ct)
        => await orders.GetOrderAsync(orderNumber, ct);
}

The Description attributes are not documentation garnish. They are the prompt the model reads when it decides whether to call your tool, so write them the way you would write instructions for a new hire: what it does, what it returns, what the input looks like, and what it will not do. Vague descriptions are the single most common cause of an agent calling the wrong tool.

Keep the return shape small and flat. A model that receives forty fields of legacy schema, half of them nullable codes, will invent meanings for them. Return the eight fields a human would read off the screen, with codes already resolved to words.

Test it before any model sees it

npx @modelcontextprotocol/inspector

The Inspector connects to your server URL, lists the tools, and lets you invoke each one by hand. Every tool should be exercised this way, including its failure cases, before a client is pointed at it. Half the defects show up here.

The four controls

A read-only server over one module is already useful, and it is where we recommend most first deployments stop for a few weeks. When writes are added, these four controls are what make the difference between an integration and an incident.

Authentication that identifies a person

The MCP server authenticates through the customer's existing identity provider, and the token carries the human on whose behalf the agent is acting. A shared service account defeats the entire audit trail: you will know a credit hold was placed and never know who asked for it. The legacy call then runs as that user, or, if the old system cannot represent the user, records them explicitly.

Per-operation scopes

Read and write are separate scopes, and separate tools. Enforce it in the tool, not only at the gateway:

[McpServerTool(Name = "place_credit_hold")]
[Description("Place a credit hold on an open order. Writes to the order system. " +
             "Fails if the order is already shipped, cancelled or on hold.")]
public async Task<HoldResult> PlaceCreditHold(
    IHttpContextAccessor http, string orderNumber, string reason, CancellationToken ct)
{
    var user = http.HttpContext!.User;
    if (!user.HasClaim("scope", "orders.write"))
        return HoldResult.Denied("This connection is not authorized to modify orders.");
    ...
}

Most agent connections should be issued read scopes only. Write scopes go to named workflows with a human in the loop.

An allow-list of side effects

There is no run_query tool. There is no execute_procedure tool. Every state change is a named operation with validated arguments and its own preconditions, and the list of them is short enough to print on one page and show to the person who owns the process. If a new write is needed, someone adds a tool and reviews it. That review is the control.

While you are at it, cap the blast radius numerically: a hold tool that can only affect one order per call, a price-update tool with a maximum percentage, a refund tool with a dollar ceiling above which it returns a message telling the agent to route to a human.

An audit log you can hand to an auditor

Log every tool invocation as one record: timestamp, authenticated user, agent client, tool name, full arguments, outcome, and the legacy system's own transaction identifier where it returns one. Write it to a store the legacy application cannot overwrite. When someone asks in March what changed in January — and on any system with financial consequences, someone will — this log is the answer, and it needs to exist from the first write, not from the first incident.

The concurrency problem nobody warns you about

Legacy line-of-business systems were sized for the number of people who could physically sit at desks. An agent does not sit down. A retry loop that a human would never produce can open thirty connections in a second, and on Access, on a VB6 COM object with global state, or on a FoxPro DBF with file locking, that is how you corrupt something.

So the layer serializes. Put write operations behind a bounded queue with a small degree of parallelism — often one:

public sealed class LegacyGate(int concurrency = 1)
{
    private readonly SemaphoreSlim gate = new(concurrency, concurrency);

    public async Task<T> RunAsync<T>(Func<Task<T>> work, CancellationToken ct)
    {
        if (!await gate.WaitAsync(TimeSpan.FromSeconds(10), ct))
            throw new TimeoutException("The order system is busy. Try again shortly.");
        try { return await work(); }
        finally { gate.Release(); }
    }
}

Add a per-client rate limit in front of the transport, a hard timeout on every legacy call, and a circuit breaker that stops calling the old system after a run of failures instead of hammering it. Then load-test the thing at ten times the traffic you expect, because the failure mode you are protecting against is not load, it is a loop.

What this does and does not buy you

It buys real time. An agent that can answer questions from the order system today, correctly, removes a chunk of the pressure that pushes people into a rushed rewrite. It also produces something the migration needs anyway: a documented boundary around a module, with tests and an audit trail, which is exactly the seam a strangler slice is cut along later. The API you write here is not throwaway work.

It does not fix the legacy system. The data model is still whatever it was, the business rules are still buried in the module, and the runtime is still unsupported if it was unsupported yesterday. An agent layer over a system nobody can safely change is a better-lit version of the same problem, and we will say so rather than sell the layer as a substitute for the migration.

It also does not make the model reliable. It makes the model's mistakes bounded, logged and reversible, which is a different and more achievable goal. Start read-only, add one write, watch the audit log for a month, then decide what else the agent is allowed to touch.