Category: Agents

Building AI agents with Copilot Studio, MCP and more.

  • MCP, Part 5: Connect your MCP server to Copilot Cowork

    MCP, Part 5: Connect your MCP server to Copilot Cowork

    This series started with a Copilot Cowork plugin. I was building one and found that, to reach the data it needed, Cowork expected an MCP server. The YEG & Tell Library, a fictional library, now has one: it runs in Azure and signs people in with Microsoft Entra ID (Part 4).

    In this post, I turn that server into a Copilot Cowork plugin: the skills, the sign-in, the package, and what happened when I tested it. Claude and ChatGPT come in Parts 6 and 7.

    The full code is in a private GitHub repo; if you’d like access, reach out to me on LinkedIn.

    TL;DR

    • A Copilot Cowork plugin is a .zip with a manifest, skills and a connector that points at your MCP server. Cowork finds the tools itself.
    • With Entra ID sign-in, you register the plugin twice: as an app in Entra ID, and as an OAuth client in the Teams developer portal. The registration ID goes in the manifest.
    • Skills tell the model how to use the tools. Cowork’s docs don’t list MCP prompts or resources, so I covered those with skills too.
    • Gotcha: a cold start (the server and a paused database) was too slow for Cowork’s first call.
    In this post

    What’s in a Cowork plugin

    Microsoft’s Build plugins for Copilot Cowork guide describes a plugin as a .zip with three parts:

    • manifest.json, which lists the skills and the connector
    • skills: one folder each, with a SKILL.md file of instructions for the model
    • a connector: your MCP server’s URL and how to sign in to it

    You don’t describe the tools. The Manage plugins page says Cowork “calls initialize and tools/list at runtime to discover available tools automatically”. The same page lists what Cowork supports from MCP: the tool calls and authentication. Prompts and resources aren’t on the list, so I didn’t count on the server’s recommend_books prompt or its two resources reaching Cowork.

    Flow diagram in eight steps. 1: the member types "Reserve Dopamine Detox for me" in Copilot Cowork. 2: Cowork picks the reserve-a-book skill and the YEG & Tell Library connector from the plugin. 3: Cowork gets a token from Microsoft's token store, which holds the OAuth registration made in the Teams developer portal. 4: the first time only, the member signs in with Microsoft Entra ID and the token store swaps the code for a token for .../mcp/library.use. 5: Cowork calls search_books and reserve_book on the MCP server in Azure Container Apps with the token. 6: the server checks the token. 7: the server reserves the book in Azure SQL with its managed identity. 8: the result goes back to Cowork, which replies to the member.
    Figure 1: What happens when a member asks Copilot Cowork to reserve a book.

    Step 1: Write the skills

    Cowork can call the four tools without skills. The skills are where I tell the model how the library works. I wrote one per job a member would ask for:

    SkillWhat it coversTools it uses
    find-a-bookSearching the catalogue, one book’s detailssearch_books, get_book_details
    reserve-a-bookFinding the right book and reserving itsearch_books, get_book_details, reserve_book
    check-my-loansLoans, due dates, late booksget_my_loans
    recommend-booksFive available books for a genre and reading levelsearch_books, get_book_details
    library-infoLoan period, limits, late fees, programsNone

    The model reads each skill’s description to decide when to use it, so I put the phrases a member would type in it. The name must match the folder name. This is the top of reserve-a-book:

    ---
    name: reserve-a-book
    description: |
      Reserves a book for the signed-in library member.
      Use when the member says "reserve Dopamine Detox for me", "put a hold on this
      book", "I want to borrow this one", or "book it for me", including after a
      search or a recommendation.
    ---

    Two of the skills cover what Cowork may not read from the server. recommend-books does the job of the recommend_books prompt from Part 3, and library-info carries a copy of the borrowing policy and programs, which the server publishes as resources. The catch with the copy is that it can go out of date.

    Writing the skills also showed me a gap: no tool lists or cancels a reservation. A skill can’t add a tool, so the skills tell the model to say so instead of promising it.

    Step 2: Set up sign-in

    The connector doesn’t hold any credentials. It holds the ID of an “auth config” that Microsoft keeps in its token store. Microsoft’s OAuth 2.0 page is clear about Entra ID: “Dynamic client registration isn’t available”, and “The agentConnectors node has no Microsoft Entra authorization type.” So, as in Part 4, the client has to be registered up front, in two places.

    In Entra ID

    • A new app registration for Cowork, single tenant, with a Web redirect URI: https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect
    • The delegated library.use permission of the server’s app registration
    • On the server’s app registration, the new app added as an authorized client, as I did for VS Code in Part 4, so members aren’t asked to consent
    • A client secret

    In the Teams developer portal

    Under Tools > OAuth client registration:

    FieldValue
    Base URLhttps://mcp.consultantdiary.tech/mcp
    Restrict usage by Teams appAny Teams app
    Client ID and secretFrom the new app registration
    Authorization endpointhttps://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize
    Token and refresh endpointshttps://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
    Scopehttps://mcp.consultantdiary.tech/mcp/library.use, offline_access
    PKCEOn
    • Any Teams app: Microsoft warns that if you bind the registration to one app, “every tool call returns a 404 error”.
    • PKCE: Microsoft’s page says “It’s on by default”. On my form it was off, so check it.
    • The registration ID you get after saving goes in the manifest. When I decoded mine, it held my tenant ID, so I keep the built package out of the repo.

    Step 3: Build the package and upload it

    I wrote the skills in a folder laid out as a Claude Code plugin, because I’ll need that in Part 6. The Microsoft 365 Agents Toolkit CLI (version 1.1.18 in my case) turns it into a Cowork package:

    atk import openplugin --path ./plugin --output ./cowork-build `
        --privacy-url <privacy-url> --terms-url <terms-url> `
        --default-auth-type OAuthPluginVault

    The import left a placeholder referenceId on the connector, which you replace with the registration ID:

    "remoteMcpServer": {
        "mcpServerUrl": "https://mcp.consultantdiary.tech/mcp",
        "authorization": {
            "type": "OAuthPluginVault",
            "referenceId": "<oauth-client-registration-id>"
        }
    }

    Then atk package builds the .zip. To upload it, Microsoft’s docs point to a Plugins tab with Add plugin. My Cowork had Customize > Add > Upload instead. After the upload, I selected the connector, signed in, and it showed Connected.

    The YEG & Tell Library plugin page in Copilot Cowork. Under Connections, the YEG & Tell Library connector shows Connected with a Disconnect button. Under Skills, check-my-loans, find-a-book and library-info are listed.
    Figure 2: The plugin in Copilot Cowork, connected, with its skills.

    What testing showed

    The first call failed on a cold start

    My first question, “list my loans”, picked the right skill, but Cowork said the plugin “couldn’t be reached”. The server’s log in the Azure portal showed why. The container was starting from zero, Cowork’s token was valid, and then the first database call failed with TaskCanceledException: A task was canceled. while the database woke up from auto-pause.

    The Azure portal Log stream for the yeg-and-tell-mcp container app. The log shows the server starting, the token validated, and then System.Threading.Tasks.TaskCanceledException: A task was canceled, in LibraryDb.EnsureMemberAsync.
    Figure 3: The server’s log: a cold start, a valid token, then a cancelled database call (IDs hidden).

    As I wrote in Part 4, the server checks the member on every request, so the first request of any kind wakes the database. Part 4 measured about 27 seconds for the container alone, and Microsoft asks for “Less than 30 seconds per tool call”. Three minutes later, with both awake, the same question worked in 310 ms. The same thing happened when I came back later to test a reservation: the first try failed, and the second one, a minute later, worked.

    The test questions

    I asked each question in a new chat, signed in as myself. Cowork shows which skill and tools it used, but not the arguments, and my server doesn’t log them.

    QuestionWhat Cowork usedReply (quoted)Result
    “What do I have on loan?”check-my-loans; Get my loans“you don’t have any books on loan”Pass
    “Any self-help books I can borrow now?”find-a-book; Search books“There are 10 self-help books you can borrow right now”Pass
    “Show me self-hlep books”find-a-book; one Search books call“Showing 10 of 16”. It fixed the typo itself, as the skill says.Pass
    “What’s the late fee?”library-info; no tool“$0.25 a day for each book, up to a maximum of $5 per book”Pass
    “recommend kids books”recommend-books; Search books; Get book details“The library has only four children’s books available”Partly
    “Reserve Outliers by Malcolm Gladwell for me.”reserve-a-book; Search books; Reserve a book (after my approval)“I’ve reserved Outliers by Malcolm Gladwell (book 19) for you.”Pass
    “What books do I have reserved?”Two steps; the reply says it checked my loans“I can’t see your reservations” … “check your account on the library’s website”Partly

    For the reservation, Cowork asked me once, with its own approval card. The card shows the tool’s title from Part 2, “Reserve a book”, and the book ID. Microsoft says this confirmation comes from the tool’s annotations, and that for non-Microsoft MCP servers it “is being rolled out progressively”; in my tenant, it was already there. The model didn’t ask me first, which is what the skill says to do when the member names one book. It also didn’t make up pickup details: “The library system didn’t give any pickup details, so check with library staff”.

    A Cowork chat. The member asks "Reserve Outliers by Malcolm Gladwell for me." Cowork shows the reserve-a-book skill and the Search books tool, then an approval card titled "Reserve a book" from the YEG & Tell Library, with Book id 19 and Cancel and Approve buttons.
    Figure 4: Cowork asks once before running reserve_book.

    The late fee is a good example of a skill helping. In Part 3, Claude couldn’t answer it unless I attached the policy resource. Here, library-info answered from its copy with no tool call.

    The two misses are worth knowing. “recommend kids books” returned only the four books in the children genre. The catalogue also has six children’s fiction books (age group Children), and none came up. I can’t see the arguments, but those four books point to a genre search. And for reservations, the model said it can’t see them, as the skill asks, then suggested a library website that doesn’t exist.

    A Cowork chat. The member asks "recommend kids books". Cowork says the library has only four children's books available and lists Charlotte's Web, Matilda, The Gruffalo and Where the Wild Things Are, then offers to reserve one.
    Figure 5: “recommend kids books” found the four books in the children genre and missed six children’s fiction books.

    What’s next

    In Part 6, we connect the same server to Claude. The plugin folder from this post is already a Claude Code plugin, so the main work is another client registration in Entra ID. You can follow the series on the MCP series page.

    Sources


    A note on how this post was written: the experience, examples and voice are mine. The writing was enhanced with the help of AI.

  • MCP, Part 4: Deploy your MCP server to Azure and add sign-in

    MCP, Part 4: Deploy your MCP server to Azure and add sign-in

    In Part 3, the YEG & Tell Library’s MCP server ran locally and signed everyone in as a test member. That was fine for testing, but no AI app outside my computer could reach it, and every reservation went to the same member.

    In this post, we put the server online in Azure and replace the test member with a real sign-in through Microsoft Entra ID, so each person who signs in gets their own library membership. I test it with the MCP Inspector and GitHub Copilot in VS Code. Connecting it to Copilot Cowork, Claude and ChatGPT comes in Parts 5 to 7.

    The snippets in this post cover the important parts. The full code is in a private GitHub repo; if you’d like access, reach out to me on LinkedIn.

    TL;DR

    • The setup: Azure Container Apps for the server, Microsoft Entra ID for sign-in, and Azure SQL reached with the server’s managed identity, so there’s no database password.
    • The server checks sign-in itself: the MCP C# SDK tells the AI app where to sign in, and Microsoft.Identity.Web checks the token it comes back with.
    • Gotchas:
      • The Application ID URI must match the exact address clients use, including /mcp. Set the app registration to v2 tokens first.
      • Use the oid claim to identify the member. The default user ID (sub) is different for every app.
      • Entra ID only works with pre-registered clients, so every AI app has to be set up for it, even the MCP Inspector.
    In this post

    What we’re building

    The tools don’t change. What changes is where the server runs and who it thinks you are:

    PiecePart 3Part 4
    ServerRuns locally at localhost:5050Azure Container Apps at https://mcp.consultantdiary.tech/mcp
    Sign-inA development stand-in signs everyone in as test-memberMicrosoft Entra ID. Each person signs in as themselves
    Library memberThe seed test memberCreated the first time someone signs in
    Database accessMy own Azure sign-inThe server’s managed identity, with only the permissions it needs

    How sign-in works with MCP

    The MCP specification treats a protected MCP server as an OAuth resource server. The server doesn’t sign anyone in itself. It only says where to sign in, and then checks the token that comes back:

    • The AI app calls the server without a token and gets a 401, with a pointer to the server’s Protected Resource Metadata. The spec says a server “MUST implement” it.
    • That metadata names the server’s address (the resource), the authorization server (Entra ID) and the scope to ask for.
    • The AI app signs the user in with Entra ID and asks for a token for that resource.
    • The AI app calls the server again with the token. The server checks that the token is valid and was issued for this server, and then runs the tool.
    Flow of a signed-in call: the AI app calls the MCP server in Azure Container Apps and gets a 401 pointing to its metadata, signs the user in with Microsoft Entra ID, calls again with the token, and the server reaches Azure SQL with its managed identity. The image comes from GitHub Container Registry.
    Figure 1: How an AI app signs in and calls the server in Azure.

    The token only lets the AI app call the server. The server never passes it on: it reaches the database with its own managed identity (step 6), and uses the member ID from the token in its own queries. That’s also what the spec asks for: an MCP server “MUST NOT accept or transit any other tokens”.

    The spec gives AI apps three ways to register with the authorization server. Entra ID only supports one of them: pre-registration. So every AI app that connects has to be set up in Entra ID first. For this post, that’s VS Code and the MCP Inspector.

    What you need

    Everything from Part 3, plus:

    WhatVersion I usedWhy
    Microsoft.Identity.Web4.16.1Checks the Entra ID token.
    MCP C# SDK (ModelContextProtocol.AspNetCore)2.2.0Serves the metadata and the 401.
    Azure CLI and its containerapp extension2.90 and 1.3.0b5To create the container app.
    MCP Inspector2.13.0To test the sign-in directly.
    A domain you can add DNS records toFor the server’s address and its free certificate.
    A GitHub accountTo store the container image.

    The server runs on the Azure Container Apps Consumption plan, which has a monthly free grant of 180,000 vCPU-seconds, 360,000 GiB-seconds and 2 million requests, and no compute charges while the app has scaled to zero. GitHub says container image storage is currently free.

    When I checked Azure Cost Management on October 31, the resource group showed CA$0.00 for October, with the database on the free offer. The container app was created that same day, and its usage hadn’t shown up in Cost Management yet.

    Step 1: Register the server in Microsoft Entra ID

    I followed Microsoft’s Secure an MCP server with Microsoft Entra ID. The order matters:

    • Create an app registration for the server, single tenant.
    • Set the token version to 2 in the manifest (api.requestedAccessTokenVersion). Microsoft calls this “the step that most people miss”. Without it, you can’t set an https Application ID URI.
    • Set the Application ID URI to the server’s exact address, https://mcp.consultantdiary.tech/mcp, with no trailing slash.
    • Add a scope named library.use. The full scope name becomes https://mcp.consultantdiary.tech/mcp/library.use.
    • Pre-authorize VS Code under Authorized client applications, using the client ID from Microsoft’s App Service MCP guide, aebc6443-996d-45c2-90f0-388ff96faa56.

    Note that the address includes /mcp. RFC 9728 says the resource in the metadata “MUST be identical to the URL that the client used to make the request”, and clients connect to .../mcp, not the root. If the Application ID URI doesn’t match the resource the client sends, Entra ID refuses the token with AADSTS9010010.

    I expected to have to verify consultantdiary.tech in my Entra tenant before Entra ID would accept that URI. It accepted it without that, and real sign-ins got tokens. Microsoft’s identifier URI documentation says a verified domain “may still be required” for https URIs, so this may not work the same in every tenant.

    Microsoft Entra Expose an API page for the YEG & Tell MCP server app registration: Application ID URI https://mcp.consultantdiary.tech/mcp, the library.use scope, and the VS Code client ID as an authorized client application.
    Figure 2: The server’s app registration: the Application ID URI, the library.use scope and VS Code as an authorized client.

    A second app registration for the Inspector

    The MCP Inspector needs its own client app. If the client and the server are the same app registration, Entra ID refuses token refreshes with AADSTS90009, which the Inspector’s own code warns about. I created a second app registration as a public client (Mobile and desktop), gave it the library.use permission, and registered its redirect URI.

    The redirect URI caught me out. The Inspector’s docs give http://localhost:6274/oauth/callback, but it opened in my browser at 127.0.0.1, and the redirect follows the address bar. The first sign-in failed with:

    AADSTS50011: The redirect URI 'http://127.0.0.1:6274/oauth/callback' specified in the
    request does not match the redirect URIs configured for the application '<inspector-client-id>'.

    Registering both http://localhost:6274/oauth/callback and http://127.0.0.1:6274/oauth/callback fixed it.

    Step 2: Add sign-in to the server

    I kept the sign-in check in the code rather than in an Azure feature, so it works on any host and you can see it. It needs one new package:

    dotnet add package Microsoft.Identity.Web

    Two pieces do the work. The MCP C# SDK’s AddMcp serves the Protected Resource Metadata and writes the 401. It doesn’t check tokens. Microsoft.Identity.Web does that, and it’s Microsoft’s recommended library for this in ASP.NET Core. This is the new part of Program.cs:

    // The server's address. Clients send it as the OAuth "resource", and it's the
    // Application ID URI of the app registration in Microsoft Entra ID.
    var resource = builder.Configuration["McpResource"]!;
    var tenantId = builder.Configuration["AzureAd:TenantId"];
    
    builder.Services.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
        })
        // Serves the Protected Resource Metadata and the 401 that points to it.
        .AddMcp(options => options.ResourceMetadata = new()
        {
            Resource = resource,
            AuthorizationServers = { $"https://login.microsoftonline.com/{tenantId}/v2.0" },
            ScopesSupported = [$"{resource}/library.use"],
        })
        // Validates the Entra ID access token: signature, issuer, audience, expiry.
        .AddMicrosoftIdentityWebApi(
            jwt => jwt.Events = new() { OnTokenValidated = SignInMemberAsync },
            entra => builder.Configuration.Bind("AzureAd", entra));
    
    builder.Services.AddAuthorization();

    The authorization server is the v2.0 issuer, which Microsoft’s MCP guide requires. Then the MCP endpoint requires a token with the library.use scope:

    app.UseAuthentication();
    app.UseAuthorization();
    
    app.MapMcp("/mcp").RequireAuthorization(policy => policy.RequireScope("library.use"));

    The two Use lines are easy to leave out, because ASP.NET Core adds them for you. But it adds them at the start of the pipeline, before Part 3’s development stand-in, so every local request got a 401. Calling them after the stand-in fixes it. The stand-in still runs in Development only, and now also adds the library.use scope so it passes the same check.

    Which ID is the member?

    Part 2’s tools read the member from ClaimTypes.NameIdentifier. With an Entra ID token, that claim holds sub by default, and sub is different for every app the user signs in to. The ID that stays the same is the user’s object ID, oid. So once the token is validated, the server swaps it in and creates the member if it’s their first visit:

    // Runs after the token is validated. The member ID is the user's Entra object ID
    // ("oid"). It goes in ClaimTypes.NameIdentifier, where the tools read it.
    static async Task SignInMemberAsync(TokenValidatedContext context)
    {
        var user = context.Principal!;
        var objectId = user.GetObjectId();
        if (objectId is null)
        {
            context.Fail("The access token has no oid claim.");
            return;
        }
    
        var identity = (ClaimsIdentity)user.Identity!;
        identity.TryRemoveClaim(identity.FindFirst(ClaimTypes.NameIdentifier)); // "sub"
        identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, objectId));
    
        // Members are created on first sign-in.
        var db = context.HttpContext.RequestServices.GetRequiredService<LibraryDb>();
        await db.EnsureMemberAsync(objectId, user.FindFirst("name")?.Value ?? "Library member",
            context.HttpContext.RequestAborted);
    }

    Because of that swap, the tool code from Part 2 didn’t change at all. EnsureMemberAsync is a single insert that only runs if the member doesn’t exist yet:

    INSERT INTO dbo.Members (SignInId, DisplayName)
    SELECT @signInId, @displayName
    WHERE NOT EXISTS (SELECT 1 FROM dbo.Members WITH (UPDLOCK, HOLDLOCK)
                      WHERE SignInId = @signInId);

    The server is stateless, so there’s no “session start” to hook into. This check runs on every request, including listing the tools. It’s a small indexed query, but it also means the first request of any kind wakes a paused database.

    Step 3: Build and push the container image

    Azure Container Apps doesn’t run your code directly. It runs a container image: a packaged copy of the server, with the compiled code and the .NET runtime it needs. The image lives in a container registry, and Container Apps downloads it from there when it starts the server.

    Azure’s own registry, Azure Container Registry, has no free tier: the Basic tier is listed at about US$5 a month (US$0.17 a day in East US). GitHub says storage on its Container Registry is currently free. To keep the cost of this experiment close to $0, I used GitHub instead of Azure. GitHub also keeps the image private by default.

    You don’t need Docker to build the image. dotnet publish builds it and pushes it to the registry:

    $env:DOTNET_CONTAINER_REGISTRY_UNAME = "<github-user>"
    $env:DOTNET_CONTAINER_REGISTRY_PWORD = "<token>"
    dotnet publish src/YegAndTell.Mcp -c Release -t:PublishContainer `
        -p ContainerRegistry=ghcr.io `
        -p ContainerRepository=<github-user>/yeg-and-tell-mcp `
        -p ContainerImageTag=v1

    Before pushing, I scanned the published files, and the Release build had my local folder path, including my Windows user name, inside the .dll and .pdb files. The .pdb also had the repo’s URL. In a public image, anyone could read them. Two settings in the project file fixed it:

    <!-- Keep local file paths out of Release builds, and so out of the container image. -->
    <PropertyGroup Condition="'$(Configuration)' == 'Release'">
      <DebugType>none</DebugType>
      <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
    </PropertyGroup>

    I kept the image private anyway. GitHub notes that once a package is public, it can’t be made private again.

    Step 4: Create the container app and connect the database

    The app starts on a placeholder image, so its managed identity exists before I connect it to the database:

    az containerapp env create --name <environment> --resource-group <resource-group> `
        --location <region> --logs-destination none
    
    az containerapp create --name yeg-and-tell-mcp --resource-group <resource-group> `
        --environment <environment> --image mcr.microsoft.com/k8se/quickstart:latest `
        --system-assigned --ingress external --target-port 8080 `
        --min-replicas 0 --max-replicas 1 --cpu 0.25 --memory 0.5Gi `
        --env-vars "McpResource=https://mcp.example.com/mcp" `
            "AzureAd__TenantId=<tenant-id>" "AzureAd__ClientId=<server-app-client-id>" `
            "ConnectionStrings__Library=Server=tcp:<server>.database.windows.net,1433;Database=<database>;Authentication=Active Directory Managed Identity;Encrypt=True;Connect Timeout=60"
    • --min-replicas 0 lets the app scale to zero when nobody is using it, so it only uses the free grant when it’s called.
    • --logs-destination none skips the Log Analytics workspace, which would cost money.
    • There’s no password in the connection string. The app signs in to Azure SQL with its managed identity.

    Azure Container Apps ends HTTPS at its own front door and passes requests on to the app. For the server to know the request came in over https, and put https addresses in its metadata, the project file sets an environment variable that tells ASP.NET Core to trust the forwarded headers:

    <ContainerEnvironmentVariable Include="ASPNETCORE_FORWARDEDHEADERS_ENABLED" Value="true" />

    Database permissions

    The managed identity gets a database user with only the permissions the server needs:

    IF DATABASE_PRINCIPAL_ID('$(AppName)') IS NULL
        CREATE USER [$(AppName)] FROM EXTERNAL PROVIDER;
    
    -- Only what the server needs.
    GRANT SELECT ON dbo.Books TO [$(AppName)];
    GRANT UPDATE (Status) ON dbo.Books TO [$(AppName)];     -- reserve_book
    GRANT SELECT ON dbo.Genres TO [$(AppName)];
    GRANT SELECT, INSERT ON dbo.Members TO [$(AppName)];    -- created on first sign-in
    GRANT SELECT ON dbo.Loans TO [$(AppName)];
    GRANT INSERT ON dbo.Reservations TO [$(AppName)];       -- reserve_book
    GRANT SELECT ON dbo.Policies TO [$(AppName)];
    GRANT SELECT ON dbo.Programs TO [$(AppName)];

    The app can change a book’s status, but nothing else about the book, and it can add members and reservations but not delete them.

    The firewall trade-off

    The database firewall only let in my own IP address. Container Apps’ outbound IP addresses can change, and a fixed one needs a NAT Gateway, which isn’t free. So I used Azure SQL’s “Allow Azure services” rule:

    az sql server firewall-rule create --resource-group <resource-group> --server <server> `
        --name AllowAllWindowsAzureIps --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0

    Microsoft is clear about what that means: it allows connections from “all Azure services, including services running in other customers’ subscriptions”. What protects the database is that it only accepts Microsoft Entra sign-ins, so a connection still needs an identity with access. I accepted that for this demo.

    Step 5: Add the custom domain

    I used a subdomain of my blog, mcp.consultantdiary.tech, because the Application ID URI has to match the server’s address, and Microsoft’s documented formats for https URIs use your own domain rather than Azure’s generated one. It also gives the plugins in Parts 5 to 7 an address that won’t change.

    Container Apps gives every app an address under azurecontainerapps.io, and it gives custom domains a free managed certificate. I added two DNS records for mcp.consultantdiary.tech:

    TypeHostValue
    CNAMEmcpThe app’s generated address
    TXTasuid.mcpThe app’s domain verification ID

    Then I added the domain to the app and requested the certificate. It stayed pending for about four minutes before it was issued.

    az containerapp hostname add --hostname mcp.example.com `
        --resource-group <resource-group> --name yeg-and-tell-mcp
    az containerapp hostname bind --hostname mcp.example.com `
        --resource-group <resource-group> --name yeg-and-tell-mcp `
        --environment <environment> --validation-method CNAME

    On my network, hostname bind kept failing with connection resets, so I ran the same two steps as direct az rest calls instead. That was my network, not Azure, but if the command keeps failing for you, that’s another way to do it.

    Is it safe to publish the address?

    I asked myself this before naming the domain in this post. The address is public anyway: every AI app that connects needs it, DNS records are public, and certificates for a domain are normally published in public Certificate Transparency logs. The server’s metadata is public too, by design, and it includes my tenant ID in the sign-in address.

    Knowing the address doesn’t get anyone in. They still need a token for this server from my tenant, and the app registration is single tenant, so people outside it can’t get one. The real risk is cost: anyone can send requests that get a 401, and each one counts against the free grant and wakes the app. Max replicas is 1, so the compute is capped, and a budget alert in Azure Cost Management is a cheap way to catch it early.

    Step 6: Test it

    Without signing in

    With no token, the server should answer 401 and point to its metadata. One surprise: GET /mcp returns 405 Method Not Allowed, because the server is stateless and only accepts POST. So test with POST:

    curl.exe -i -X POST https://mcp.consultantdiary.tech/mcp -H "Content-Type: application/json" -d "{}"
    HTTP/1.1 401 Unauthorized
    www-authenticate: Bearer resource_metadata="https://mcp.consultantdiary.tech/.well-known/oauth-protected-resource/mcp"

    The metadata it points to has the server’s address, Entra ID’s v2.0 issuer and the scope. The spec says the 401 SHOULD also include the scope. SDK 2.2.0 doesn’t add it, but the AI apps I tested used scopes_supported from the metadata instead, which is what the spec says to fall back to.

    I also sent a valid Entra ID token that was issued for a different app, Azure Resource Manager. The server refused it with a 401, and the log showed why: IDX10214: Audience validation failed. The signature and expiry were fine; it just wasn’t a token for this server.

    Signing in with the MCP Inspector

    In the MCP Inspector, I added the server, entered the Inspector app’s client ID in its OAuth settings and selected Connect. After the redirect URI fix from Step 1, Entra ID asked me to pick an account, then showed two consent screens: one for the Inspector app and one for the server app. Because I’m an admin in the tenant, the second one also offered to consent for everyone in my organization:

    Microsoft sign-in page asking to pick an account, opened by the MCP Inspector, with the email, tenant and client IDs hidden.
    Figure 3: The Inspector sends me to Entra ID to sign in (account, tenant and client IDs hidden).
    Microsoft consent screen 1 of 2, asking to let the Inspector app use the YEG & Tell library.
    Figure 4: Consent for the Inspector to use the YEG & Tell library.
    Microsoft consent screen 2 of 2 for the YEG & Tell MCP server app, with the Consent on behalf of your organization box checked.
    Figure 5: Consent for the server app, with the option to consent for the whole organization.

    After I accepted, the Inspector connected, and search_books returned Malcolm Gladwell’s books from the deployed server:

    MCP Inspector connected to the deployed server, showing four tools and search_books results for six Malcolm Gladwell books.
    Figure 6: The Inspector connected to the deployed server.

    In the database, a new row appeared in dbo.Members with my Entra object ID and my name from the token.

    Query result from dbo.Members showing a new member row created on first sign-in, with the Entra object ID partly hidden.
    Figure 7: The member created on my first sign-in, with my Entra object ID (partly hidden).

    That one result answered three questions at once: Entra ID issued a token for https://mcp.consultantdiary.tech/mcp, the token had the oid and name claims, and the managed identity could reach the database and write to it.

    Signing in with GitHub Copilot in VS Code

    In VS Code, I added the deployed server to .vscode/mcp.json, selected Start and signed in when asked:

    "yeg-and-tell-azure": {
      "type": "http",
      "url": "https://mcp.consultantdiary.tech/mcp"
    }

    These tests ran in the Local harness. “Reserve a self-help book for me” called search_books, then asked before running reserve_book, as in Part 3:

    VS Code chat asking for approval before running reserve_book on the deployed YEG & Tell server.
    Figure 8: VS Code asks before reserving on the deployed server.

    After I allowed it, the reservation went through as me, not as a test member. The new row in dbo.Reservations has my member ID:

    VS Code chat in the Local harness: it lists six Malcolm Gladwell books, says Dopamine Detox is already reserved, then reserves Digital Minimalism by Cal Newport for a self-help request.
    Figure 9: Searching and reserving on the deployed server.

    One thing I didn’t expect: I’d left the local server in mcp.json too. Both servers have the same tools, so the model tried the local one first, found it wasn’t running and fell back to the Azure one. If you test both, stop the one you’re not using.

    VS Code chat where the model says the local server at localhost:5050 failed to start, then runs search_books on the Azure server and lists six Malcolm Gladwell books.
    Figure 10: The model tries the local server first, then uses the Azure one.

    Errors I hit, and the fix for each

    ErrorCauseFix
    AADSTS9010010The Application ID URI doesn’t exactly match the resource the client sendsUse the exact address, including /mcp, with no trailing slash
    AADSTS50011The Inspector’s redirect URI isn’t registeredRegister both localhost and 127.0.0.1
    AADSTS90009The client and the server are the same app registrationGive the Inspector its own app registration
    401 for every local requestUseAuthentication() ran before the development stand-inCall it explicitly, after the stand-in
    401 with a valid token, IDX10214The token was issued for a different appWorking as intended
    405 on GET /mcpThe server is stateless and only accepts POSTTest with POST

    What’s next

    The server is online and knows who’s signed in. In Part 5, we connect it to Copilot Cowork with a plugin, which needs its own setup in Entra ID. You can follow the whole series on the MCP series page.

    Sources


    A note on how this post was written: the experience, examples and voice are mine. The writing was enhanced with the help of AI.

  • MCP, Part 3: Build and test your MCP server

    MCP, Part 3: Build and test your MCP server

    In Part 2, we designed an MCP server for the YEG & Tell Library, a fictional library with an Azure SQL database of books: four tools, two resources and a prompt. In this post, we build it in C# and test it locally, first with the MCP Inspector and then by chatting with it through GitHub Copilot in VS Code.

    Testing also ended up changing the design. When I tested the prompt, it asked for something the database couldn’t answer, so I fixed the data and the tool, then tested again. I cover that near the end.

    The database already lives in Azure, but the server itself runs locally for now, so only apps on the same machine can reach it. Moving the server to Azure and adding a proper sign-in come in Part 4. Until then, the server signs everyone in as a test member. The snippets in this post cover the important parts. The full code is in a private GitHub repo; if you’d like access, reach out to me on LinkedIn.

    TL;DR

    • The stack: ASP.NET Core with the official MCP C# SDK 2.2.0, Streamable HTTP and Azure SQL. I used Dapper so you can see the SQL each tool runs.
    • Test without a model first: the MCP Inspector calls your tools directly. If a tool is wrong there, a model won’t fix it.
    • Gotchas:
      • Tools only get the user if the identity is authenticated. For local testing, sign in a test member with authenticationType: "Development".
      • When a tool fails, the model only sees your error message if you throw McpException. Any other exception gives it a generic error, so it can’t fix its call.
      • Signing in to Azure SQL with Microsoft Entra needs the Microsoft.Data.SqlClient.Extensions.Azure package.
    • Test in the AI apps your users will use: not all of them support prompts, and each one decides on its own when to ask before running a tool. In VS Code, for example, GitHub Copilot never showed my prompt, Codex reserved a book without asking, and Claude asked even before a read-only search.
    In this post

    What we’re building

    The server exposes what Part 2 designed, plus the two tools that Part 2 listed but didn’t show:

    KindNameWhat it does
    Toolsearch_booksSearches by title or author, genre, status and age group, 10 books per page, with the total number of matches.
    Toolget_book_detailsFull details for one book, including when it’s due back if it’s on loan.
    Toolreserve_bookReserves a book for the signed-in member.
    Toolget_my_loansThe signed-in member’s loans and due dates.
    Resourcelibrary://policies/borrowingLoan periods, borrowing limits and late fees.
    Resourcelibrary://programs/{programId}A library program, such as summer-reading.
    Promptrecommend_booksSuggests available books in a genre for a reading level.

    Here’s how the pieces fit together for now. Everything except the database runs locally:

    Architecture diagram: on the local machine, the MCP Inspector and VS Code with GitHub Copilot call the YEG & Tell MCP server at localhost:5050/mcp. The server (ASP.NET Core, MCP C# SDK 2.2.0, Dapper, with a development sign-in as test-member) queries an Azure SQL Database in Azure.
    Figure 1: The architecture in this post.

    What you need

    WhatVersion I usedWhy
    .NET SDK10.0The server targets .NET 10.
    MCP C# SDK (ModelContextProtocol.AspNetCore)2.2.0Version 2.0.0 brought the SDK “into stable alignment with the MCP 2026-07-28 specification”.
    Dapper2.1.89Data access with plain SQL.
    Microsoft.Data.SqlClient7.1.1The SQL Server driver.
    Azure CLI2.90To create the database.
    Node.js24.22The MCP Inspector needs 22.19 or newer.
    MCP Inspector2.12.0To call the server directly.
    VS Code with GitHub Copilot1.142To test the server in chat.

    The database uses the Azure SQL Database free offer: 100,000 vCore seconds of serverless compute, 32 GB of data and 32 GB of backup storage per database per month. With the auto-pause option, which is the portal’s default and what the command below sets, the database pauses instead of billing when the free amount runs out, so following along costs nothing.

    Step 1: Create the database

    The server and database

    I created everything with the Azure CLI. The server uses Microsoft Entra authentication only, so there’s no SQL user name or password anywhere, not even in the connection string:

    az sql server create --resource-group rg-yeg-and-tell --name <your-server> `
        --location canadacentral --enable-ad-only-auth `
        --external-admin-principal-type User `
        --external-admin-name <your-upn> --external-admin-sid <your-object-id>
    
    az sql db create --resource-group rg-yeg-and-tell --server <your-server> `
        --name yegandtell --edition GeneralPurpose --family Gen5 --capacity 2 `
        --compute-model Serverless --use-free-limit `
        --free-limit-exhaustion-behavior AutoPause

    The first command failed on my subscription, which had never used Azure SQL before:

    ERROR: (MissingSubscriptionRegistration) The subscription is not registered
    to use namespace 'Microsoft.Sql'.

    The fix is a one-off, free registration: az provider register --namespace Microsoft.Sql --wait. You’ll also need a firewall rule that lets your computer reach the server; az sql server firewall-rule create with your public IP address does it.

    The tables

    Seven tables: genres, books, members, loans, reservations, and two for the content behind the resources, policies and programs, each stored as Markdown text. I made three choices to keep the server code short:

    • A book’s status is a column, Available, OnLoan or Reserved, rather than something worked out from loans and reservations. Searching by status is a simple filter, and reserving can check and update in one statement.
    • A book’s age group is a column too, Children, Teen or Adult. I didn’t have it in the first version; I added it after testing, more on that later.
    • Members have a SignInId, the ID the sign-in gives the server. For now that’s test-member. In Part 4, it becomes the member’s Microsoft Entra ID.
    CREATE TABLE dbo.Books (
        Id      int IDENTITY PRIMARY KEY,
        Title   nvarchar(200) NOT NULL,
        Author  nvarchar(100) NOT NULL,
        GenreId int NOT NULL REFERENCES dbo.Genres (Id),
        Summary nvarchar(500) NOT NULL,
        Status  varchar(10) NOT NULL DEFAULT 'Available'
            CHECK (Status IN ('Available', 'OnLoan', 'Reserved')),
        AgeGroup varchar(10) NOT NULL DEFAULT 'Adult'
            CHECK (AgeGroup IN ('Children', 'Teen', 'Adult'))
    );

    The sample data has 45 real books in four genres. There are 16 self-help books, so a self-help search needs a second page, six by Malcolm Gladwell, and Dopamine Detox by Thibaut Meurisse is book 1. The test member, Ibn Battuta, has two books on loan.

    Step 2: The data layer

    The project is a plain ASP.NET Core web app with four main packages:

    dotnet new web -n YegAndTell.Mcp -o src/YegAndTell.Mcp
    dotnet add package ModelContextProtocol.AspNetCore --version 2.2.0
    dotnet add package Dapper
    dotnet add package Microsoft.Data.SqlClient
    dotnet add package Microsoft.Data.SqlClient.Extensions.Azure

    Note that you need the last package if you’re signing in to Azure SQL with Microsoft Entra. Since SqlClient 7.0, Microsoft moved Entra sign-in into a separate package. Microsoft’s release notes say apps using it “must now install the Microsoft.Data.SqlClient.Extensions.Azure NuGet package separately”. Without it, the connection string’s Authentication=Active Directory Default doesn’t work. The connection string itself goes in .NET user secrets, so it never ends up in the repo:

    dotnet user-secrets set "ConnectionStrings:Library" `
      "Server=tcp:<your-server>.database.windows.net,1433;Database=yegandtell;Authentication=Active Directory Default;Encrypt=True;"

    One more thing to watch for: when I built this, SqlClient 7.1.1 pulled in an older version of System.Security.Cryptography.Pkcs with two known vulnerabilities, so the project references the fixed version, 10.0.13, directly. dotnet list package --vulnerable --include-transitive is worth running on any new project.

    LibraryDb is one class with a method for each thing the tools and resources need. Here’s the search. One query counts the matches and a second returns the page:

    public async Task<SearchResult> SearchBooksAsync(string? text, string? genre,
        BookStatus? status, AgeGroup? ageGroup, int page, int pageSize, CancellationToken ct)
    {
        const string sql = """
            SELECT COUNT(*)
            FROM dbo.Books AS b
            JOIN dbo.Genres AS g ON g.Id = b.GenreId
            WHERE (@text IS NULL OR b.Title LIKE '%' + @text + '%' OR b.Author LIKE '%' + @text + '%')
              AND (@genre IS NULL OR g.Name = @genre)
              AND (@status IS NULL OR b.Status = @status)
              AND (@ageGroup IS NULL OR b.AgeGroup = @ageGroup);
    
            SELECT b.Id, b.Title, b.Author, b.Status
            FROM dbo.Books AS b
            JOIN dbo.Genres AS g ON g.Id = b.GenreId
            WHERE (@text IS NULL OR b.Title LIKE '%' + @text + '%' OR b.Author LIKE '%' + @text + '%')
              AND (@genre IS NULL OR g.Name = @genre)
              AND (@status IS NULL OR b.Status = @status)
              AND (@ageGroup IS NULL OR b.AgeGroup = @ageGroup)
            ORDER BY b.Title
            OFFSET @skip ROWS FETCH NEXT @pageSize ROWS ONLY;
            """;
    
        var args = new
        {
            text, genre, status = status?.ToString(), ageGroup = ageGroup?.ToString(), pageSize,
            skip = (Math.Max(page, 1) - 1) * pageSize
        };
    
        await using var db = new SqlConnection(connectionString);
        using var results = await db.QueryMultipleAsync(
            new CommandDefinition(sql, args, cancellationToken: ct));
    
        var total = await results.ReadSingleAsync<int>();
        var books = (await results.ReadAsync<BookSummary>()).AsList();
        return new SearchResult(books, total);
    }

    Part 2 said reserve_book should do the whole task in one call. Inside it, the check and the reservation also happen in one statement, inside a transaction, so two people can’t reserve the same book at the same moment:

    // Check and reserve in one statement: it only changes the book if it's available.
    var title = await db.QuerySingleOrDefaultAsync<string>(new CommandDefinition("""
        UPDATE dbo.Books
        SET Status = 'Reserved'
        OUTPUT inserted.Title
        WHERE Id = @bookId AND Status = 'Available';
        """, new { bookId }, tx, cancellationToken: ct));

    If nothing was updated, the method looks the book up and throws an McpException that says why and what to do next, such as “‘Blink’ can’t be reserved because it’s on loan. Use search_books with status Available to find another book.”

    Step 3: The MCP server

    Program.cs is short. The MCP registration is the same as in Part 2, plus MapMcp, which puts the MCP endpoint at /mcp:

    using System.Security.Claims;
    using YegAndTell.Mcp;
    
    var builder = WebApplication.CreateBuilder(args);
    
    var connectionString = builder.Configuration.GetConnectionString("Library")
        ?? throw new InvalidOperationException(
            "Set ConnectionStrings:Library in user secrets. See database/README.md.");
    builder.Services.AddSingleton(new LibraryDb(connectionString));
    
    builder.Services.AddMcpServer()
        .WithHttpTransport()
        .WithToolsFromAssembly()
        .WithResourcesFromAssembly()
        .WithPromptsFromAssembly();
    
    var app = builder.Build();
    
    // DEVELOPMENT ONLY: there's no sign-in yet, so every request is signed in
    // as the test member. Part 4 replaces this with Microsoft Entra ID.
    if (app.Environment.IsDevelopment())
    {
        app.Use((context, next) =>
        {
            context.User = new ClaimsPrincipal(new ClaimsIdentity(
                [new Claim(ClaimTypes.NameIdentifier, "test-member")],
                authenticationType: "Development"));
            return next(context);
        });
    }
    
    app.MapMcp("/mcp");
    
    app.Run();

    The server runs at http://localhost:5050/mcp. I fixed the port in launchSettings.json so the test setup doesn’t change between runs.

    A sign-in gotcha

    The development stand-in only works because of authenticationType: "Development". In version 2.2.0, the SDK only passes the user on to your tools when the user is authenticated, and a ClaimsIdentity without an authentication type isn’t. If you leave it out, the SDK treats the user as not signed in, and reserve_book replies “You need to be signed in to reserve a book”.

    The tools, resources and prompt

    The tool, resource and prompt classes are the ones published in Part 2, including search_books with the age group filter that testing added. The two new tools follow the same rules: read-only, a clear description, and errors that say how to fix the problem. get_my_loans takes the member from the sign-in, like reserve_book, so it has no inputs at all:

    [McpServerTool(Name = "get_my_loans", Title = "Get my loans",
        ReadOnly = true, OpenWorld = false)]
    [Description("Lists the books the signed-in member has on loan, with " +
        "the date each one is due back.")]
    public static async Task<IReadOnlyList<Loan>> GetMyLoans(
        LibraryDb db,
        ClaimsPrincipal user,
        CancellationToken ct = default)
    {
        // The member comes from the sign-in, never from the model.
        var memberId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
            ?? throw new McpException(
                "You need to be signed in to see your loans.");
    
        return await db.GetMyLoansAsync(memberId, ct);
    }

    Step 4: Test with the MCP Inspector

    The MCP Inspector calls your server directly, with no AI model involved. That’s why I test with it first: if a tool returns the wrong result here, a model won’t fix it. With the server running, start it with:

    npx @modelcontextprotocol/inspector

    Choose Streamable HTTP and connect to http://localhost:5050/mcp. The Tools tab shows each tool with the form the Inspector builds from its inputs and descriptions, and a READ-ONLY badge from the annotation:

    MCP Inspector Tools tab showing the four tools and the Search books form, with a READ-ONLY badge, a schema portability notice of 0 errors and 4 warnings, and inputs for text, genre (set to self-help), status and ageGroup.
    Figure 2: search_books in the MCP Inspector.

    Running it with the genre “self-help” returns the first 10 books and the total, the same shape we designed in Part 2:

    MCP Inspector result for search_books with genre self-help: a list of 10 books with ID, title, author and status, and totalMatches of 16.
    Figure 3: The first page of results, with totalMatches.

    Here’s everything I checked:

    CheckResult
    search_books with text “gladwell”6 books, totalMatches: 6
    search_books with genre “self-help”, page 2The last 6 of the 16 books (page 1 had the first 10), totalMatches: 16
    search_books with genre “self-hlep”Error: “Unknown genre ‘self-hlep’. Valid genres are: children, fiction, non-fiction, self-help.”
    get_my_loansIbn Battuta’s two loans, with due dates
    reserve_book for book 1, twice“Reserved ‘Dopamine Detox’ (book 1) for you.”, then an error saying it’s already reserved
    Read library://policies/borrowingThe borrowing policy, as Markdown
    Get recommend_books with self-help and adultThe prompt text, with both values filled in

    Error and schema behaviour to know about

    If this is your first MCP server, these are easy to miss. You can see each one in the Inspector:

    • The SDK adds a line in front of your error. If search_books throws McpException("Unknown genre 'self-hlep'…"), the model sees “An error occurred invoking ‘search_books’: Unknown genre ‘self-hlep’…”. Your message arrives unchanged, so write it for the model, and there’s no need to repeat the tool name.
    • Other exceptions are hidden. An invalid enum value, like status “Lost”, fails before the tool even runs, and the model only sees “An error occurred invoking ‘search_books’.” That’s fine here, because the input schema already lists the valid statuses, but it’s another reason to throw McpException for anything the model can fix.
    • Resource errors work differently. A missing program doesn’t come back as a tool result with an error flag. It’s a protocol-level error, with the McpException message passed through unchanged.
    • A schema warning. The Inspector warns that the optional inputs of search_books have a type of “string or null”, which some AI apps’ function formats don’t support. VS Code and the Inspector accept it, so I’ve left it. I’ll come back to it if one of the apps in Parts 5 to 7 rejects the tool.

    Step 5: Test in VS Code

    The Inspector shows that the server works. To see what a real model does with it, I used GitHub Copilot in VS Code. VS Code reads MCP servers from .vscode/mcp.json in the project folder. With this file in place, VS Code knows about the local server. Start it from that file, and GitHub Copilot can use it:

    {
      "servers": {
        "yeg-and-tell": {
          "type": "http",
          "url": "http://localhost:5050/mcp"
        }
      }
    }

    Note that in VS Code 1.142, the MCP: Add Server flow labels .vscode/mcp.json as deprecated. It still works, and VS Code forwards its servers to the GitHub Copilot harness. The newer portable files, .mcp.json in the project or ~/.copilot/mcp-config.json for you only, use mcpServers instead of servers.

    Which part is the MCP client?

    This confused me at first. In VS Code, the MCP client depends on which agent harness runs the chat, not on GitHub Copilot as a product. A harness is the layer that runs the agent session. Other AI extensions in VS Code bring their own client:

    Where you chatWhat runs the agentMCP client
    VS Code chat, Local harnessVS Code’s extension hostVS Code. It ran my prompt.
    VS Code chat, GitHub Copilot harnessThe GitHub Copilot runtime, shared with GitHub Copilot CLIGitHub Copilot’s runtime. Version 1.0.95 never listed my prompt.
    VS Code chat, Claude harnessAnthropic’s Claude harnessThe Claude harness. It asked before every tool, read-only or not.
    VS Code chat, Codex harnessOpenAI’s Codex harnessThe Codex harness. It ran every tool without asking, even reserve_book.
    Claude Code extensionClaude CodeClaude Code, with its own config and permission rules
    Codex extensionCodexCodex, with its own config and approval modes

    You pick the harness from the Session Target control in the chat input, before your first message. Note that starting with VS Code 1.142, Microsoft is gradually making the GitHub Copilot harness the default for new sessions, so the same steps can open a different harness. That’s most likely why my first tests ran in the Local harness and the later ones in the GitHub Copilot harness. In the picker, it’s just called Copilot. To use Local, start a new chat in the Chat view and select Local from Session Target.

    VS Code chat Session Target menu listing the harnesses Copilot, Local, Cloud, Claude and Codex, with Local selected.
    Figure 4: Picking the harness from Session Target, the button at the bottom of the chat input.

    I then asked the questions from Part 2’s “Test your design” table in GitHub Copilot Chat. Unless I say otherwise, the tests below ran in the GitHub Copilot harness, with Manual permissions selected at the bottom of the chat, and GitHub Copilot routed them to GPT-6 Luna.

    Searching

    “Do you have anything by Malcolm Gladwell?” went straight to search_books with the text “gladwell”, and the model grouped the six books by status:

    GitHub Copilot chat in VS Code answering "Do you have anything by Malcolm Gladwell?" with six books grouped as available, on loan and reserved.
    Figure 5: Searching the catalogue from GitHub Copilot Chat in VS Code.

    Reserving

    “Reserve Dopamine Detox for me.” got two confirmations. First, the model asked on its own whether to go ahead. Then GitHub Copilot asked before running reserve_book, using the tool’s Title, “Reserve a book”, as the heading. The search before it ran without asking.

    GitHub Copilot finds Dopamine Detox and asks the user to confirm the reservation with True or False before calling reserve_book.
    Figure 6: The model checks with the user first.
    GitHub Copilot approval prompt "Allow tool from yeg-and-tell?" for "Reserve a book", with Allow in this Session and Skip buttons.
    Figure 7: Then GitHub Copilot asks before running reserve_book.

    To check the Local harness too, I asked it to reserve any self-help book, with Default permissions. It behaved the same way: it ran search_books without asking and asked before reserve_book:

    VS Code Local harness with Default permissions: search_books ran without asking, and an approval card asks before running Reserve a book for book ID 5.
    Figure 8: The Local harness also asks before reserve_book, but not before search_books.

    The Claude harness behaved differently. With Claude Haiku 5.5 and Ask Before Edits, it asked the first time each tool was used, including the read-only search_books and get_my_loans. Allow in this Session then covered only that tool:

    VS Code Claude harness asking "Allow tool from yeg-and-tell?" before running the read-only search_books tool.
    Figure 9: The Claude harness asks even before the read-only search_books.

    The Codex harness went the other way. With GPT-5.2 and Default Permissions, it ran search_books, get_my_loans and reserve_book without asking at all. It only asked before running shell commands on my machine:

    VS Code Codex harness with Default Permissions reserving Daring Greatly after "book any other self-help", with no approval prompt.
    Figure 10: The Codex harness reserves a book without asking.

    The documentation doesn’t say whether the Local or GitHub Copilot harness uses the ReadOnly annotation to decide when to ask, so I’m only describing what I saw: in both, the read-only tools ran without asking and reserve_book needed approval. The Claude harness clearly doesn’t skip read-only tools. So don’t count on ReadOnly to avoid approval prompts, or on ReadOnly = false to get one; each app has its own rules. Keep the checks that matter, like whether a book is available, in the server itself.

    Recovering from a mistake

    “Show me self-hlep books” sent the typo straight to search_books, which failed with the list of valid genres. The model retried with “self-help” and told the user what it had done. This is the “errors that explain the fix” idea from Part 2, and it worked with a real model:

    GitHub Copilot recovering from the misspelled genre "self-hlep": the tool returns the list of valid genres and the model retries with self-help.
    Figure 11: The model recovers from a misspelled genre.

    Paging

    “Any self-help books I can borrow now?” returned the first 10 of 13 (Dopamine Detox was now reserved from the earlier test). GPT-6 Luna chose not to fetch the rest: its visible reasoning was “I don’t think any additional tools are necessary right now”, and it told the user there were three more. When I said “show more”, it called search_books with page: 2:

    GitHub Copilot calling search_books with page 2 after the user asks to see more self-help books, returning the remaining three.
    Figure 12: The model asks for page 2.

    Different models handle this differently. In an earlier test run in Claude Code 2.1.301, Claude Opus 5.5 fetched page 2 straight away because totalMatches was higher than the books returned, with the same tool and the same description.

    My loans

    “What do I have on loan?” called get_my_loans with no inputs. The member comes from the sign-in, so the model doesn’t need to know who is asking:

    GitHub Copilot answering "What do I have on loan?" with the test member's two loans, Blink and Atomic Habits, and their due dates.
    Figure 13: The test member’s loans.

    Attaching a resource

    In VS Code, resources are under Add Context › MCP Resources. The server’s two resources are listed by their titles and descriptions, including the program template:

    VS Code resource picker listing the server's two resources from yeg-and-tell: the Library program resource template and the Borrowing policy.
    Figure 14: The server’s resources in VS Code.

    With the borrowing policy attached, “What’s the late fee?” was answered from the policy, with no tool call:

    With the borrowing policy attached, GitHub Copilot answers "What's the late fee?" with $0.25 per book per day, capped at $5 per book, without calling a tool.
    Figure 15: An answer from the attached borrowing policy.

    The prompt depends on the client

    In Part 1, I mentioned that support for prompts is patchy across AI apps, and testing confirmed it. Prompts were the only feature that depended on the client, even in the same VS Code version (1.142). The Local harness, which runs inside VS Code itself, listed recommend_books and showed its argument form. The GitHub Copilot harness (GitHub Copilot CLI version 1.0.95, a pre-release build), which VS Code’s chat used in my later tests, never asked the server for prompts at all, so the prompt didn’t appear in its / menu. Tools worked in both, but prompts only worked in one. For the final test, I got the prompt from the Inspector and pasted its text into the chat:

    MCP Inspector Prompts tab showing the Recommend books prompt with its two required arguments filled in: genre fiction and level children.
    Figure 16: Getting recommend_books in the Inspector.

    What testing showed

    The first time I ran recommend_books for children’s fiction, the model said: “the catalogue doesn’t identify age suitability, and I can’t confidently recommend five for children”. And it was right. The prompt asks for a reading level, and the database had nothing to match it against. There’s a children genre, with books like The Gruffalo and Matilda, but the prompt asked for fiction suited to children, and nothing on a fiction book said who it suited. A reading level sounded like a reasonable input for the prompt when I designed it, but the model can only work with what the tools return.

    So I made the smallest fix I could:

    • an AgeGroup column on each book: Children, Teen or Adult
    • an AgeGroup enum, and an optional ageGroup input on search_books
    • a dozen children’s and teen fiction books, so there was something to recommend

    I left the prompt itself unchanged on purpose. It still only says “Pick five suited to children readers”. Then I ran the same test again. This time, the model called search_books with "ageGroup": "Children" on its own. Its visible reasoning said it needed to “make sure it fits the age group”:

    GitHub Copilot calling search_books with genre fiction, status Available and ageGroup Children for a children's fiction recommendation.
    Figure 17: The model uses the new ageGroup filter without being told.
    GitHub Copilot recommending five available children's fiction books, each with one sentence, then offering to reserve one.
    Figure 18: Five children’s fiction books, then an offer to reserve one.

    This is what Part 2 meant by testing your design: ask realistic questions, find what the model can’t do, fix the design and test again. The fix wasn’t a better prompt. It was giving the tool the data and the input it needed, and a clear description did the rest.

    What’s next

    The server works, but only locally and only for a test member. In Part 4, we’ll deploy it to Azure and replace the test sign-in with Microsoft Entra ID, so Copilot Cowork, Claude and ChatGPT can reach it in Parts 5 to 7. You can follow the whole series on the MCP series page.

    Planned architecture for Part 4: Copilot Cowork, Claude, ChatGPT and VS Code sign the member in with Microsoft Entra ID, then call the MCP server in Azure over HTTPS with a token. The server validates the token and reaches Azure SQL with its managed identity.
    Figure 19: Where Part 4 takes it (planned).

    Get the next part by email

    New posts in the MCP series and on AI, agents and Power Platform, straight to your inbox. No spam, unsubscribe anytime.

    Sources


    A note on how this post was written: the experience, examples and voice are mine. The writing was enhanced with the help of AI.

  • MCP, Part 2: How to design your MCP server

    MCP, Part 2: How to design your MCP server

    In Part 1, we covered what MCP is, how hosts, clients and servers fit together, and when it makes sense to build your own MCP server. This post is about design: how to shape the tools, resources and prompts your server offers so AI apps use them well.

    We’ll keep the same example: the YEG & Tell Library, a fictional library with an Azure SQL database of books. The code is in C# with the official MCP C# SDK, and everything reflects the MCP specification version 2026-07-28. There’s no shortage of opinions on MCP design, so the advice here comes from Anthropic’s and OpenAI’s guidance, the MCP and Microsoft documentation, and my own experience. The sources are listed at the end.

    TL;DR

    • Design your MCP server’s tools around the tasks users want done, not around your database or API.
    • Keep tools few and distinct, with clear names, descriptions and inputs.
    • Return only what the model needs, and write errors that explain the fix.
    • Mark which tools change data, so the AI app can ask before acting.
    • Use resources for reference content, and treat prompts as a bonus.
    In this post

    Tool, resource or prompt?

    The difference is who decides when each one is used: the model, the app or the user. Figure 1 turns that into three questions. Most of the design work is in the tools, so that’s where most of this post goes.

    Decision chart: if the model should decide when to use it, it's a tool; if it's reference content the app attaches, it's a resource; if the user starts it on purpose, it's a prompt
    Figure 1: Choosing between a tool, a resource and a prompt.

    How to design tools

    The model picks a tool by reading its name, description and inputs, then works with whatever the tool returns. Good tool design makes both of those easy.

    Where I started

    I was designing an MCP server for a Copilot Cowork plugin, and my first instinct was to give the model my system’s API documentation as a Markdown file, let it build the API calls itself, and have the server simply pass them on. It’s possible, and in Cowork the documentation could even live in a skill that only loads when it’s needed. But it creates a few problems:

    • Every API call is written from scratch. The model often gets it right, but not always. When the API returns an error, the model may try again, and every attempt costs tokens and adds to the context. When a mistake doesn’t cause an error, like a misspelled filter the API quietly ignores, the user gets a wrong answer and nobody notices.
    • The documentation fills the context. Whatever part of the API reference gets loaded stays in the context window. That leaves less room for the conversation, makes every turn slower and more expensive, and makes it harder for the model to recall what matters.
    • Approvals become all or nothing. The server sets annotations per tool, so one generic tool gets one setting for every call, and an app like Cowork follows it. If the server marks it ReadOnly = true, Cowork never asks, even before a delete. If it marks it ReadOnly = false, Cowork asks for approval, even for a simple search.
    • The server has to check everything. The model can call any endpoint, so the server must check every call, such as whether the user is allowed to make it.

    Letting the model write the request isn’t always wrong, though. Later in this post, we’ll look at a Fabric data agent that does exactly that and works well, because of what’s built around it.

    Common anti-patterns, and what to do instead

    Each anti-pattern below has three parts: what it looks like, what to do instead, and why.

    1. The wrong tools

    One tool per API endpoint or table

    • Anti-pattern: A list_books tool that returns the whole catalogue to answer “Do you have any self-help books?”
    • Instead: search_books, which returns only the matches.
    • Why: APIs are built for developers, whose code can loop through thousands of records at no cost. A model can’t: everything a tool returns lands in its limited context window, and reading it costs time and money.

    Overlapping tools

    • Anti-pattern: search_books_by_title, search_books_by_author and search_books_by_genre.
    • Instead: One search_books with optional filters. OpenAI’s rule of thumb is to have fewer than 20 tools available at a time.
    • Why: Near-identical tools make the model choose between them every time. As OpenAI’s guide to building agents puts it, the issue “isn’t solely the number of tools, but their similarity or overlap.”

    Chained simple steps

    • Anti-pattern: Reserving a book with check_availability, then get_member, then create_reservation.
    • Instead: One reserve_book tool that does all three on the server (Figure 2).
    • Why: Three calls mean three chances to go wrong, and every result passes through the model.
    Three chained tool calls through the model compared with one reserve_book task tool that does all three steps on the server
    Figure 2: Three chained tools versus one task tool.

    2. Confusing inputs

    Asking for what your code already knows

    • Anti-pattern: A tool that asks for a member_id.
    • Instead: Take the member from the sign-in.
    • Why: The model has to find the ID or guess it. Taking it from the sign-in is safer too: the model can’t reserve a book for someone else.

    Vague names, descriptions and inputs

    • Anti-pattern: A tool called books with a one-line description and inputs named q and s.
    • Instead: Describe each tool as you would to a new team member, use unambiguous names, like text instead of q (Anthropic’s example is user_id instead of user), and use enums, a fixed list of allowed values.
    • Why: The name, description and inputs are all the model has to go on. A status input that only accepts Available, OnLoan or Reserved can’t receive “checked out”.

    Figure 3 shows the difference in code. Both versions do the same search; only what the model can read changes.

    C# code comparing a vague tool named books with inputs q and s against a clear search_books tool with descriptions and a BookStatus enum
    Figure 3: A vague tool definition versus a clear one.

    3. Noisy outputs

    Dumping raw data

    • Anti-pattern: Returning full database rows, with internal codes and dozens of columns (Figure 4).
    • Instead: Return only the fields the model needs, cap the results, say how many matched in total, and let the model ask for the next page. For search_books, that’s the ID, title, author and status of up to 10 books per page.
    • Why: Everything a tool returns fills the context window, and noise makes it harder for the model to find what matters.
    Raw database rows full of internal codes compared with a trimmed result showing only ID, title, author and status, capped at 10 with a total
    Figure 4: Raw database rows versus a trimmed result.

    Unhelpful errors

    • Anti-pattern: An error code like “ERR 4031”.
    • Instead: Say what went wrong and how to fix it, like “Unknown genre ‘self-hlep’. Valid genres are business, history and self-help.”
    • Why: An error code leaves the model stuck. A clear message lets it correct itself and try again.

    4. Hidden risk

    Not saying which tools change data

    • Anti-pattern: Tools with no annotations, so the AI app can’t tell a search from a reservation.
    • Instead: Use MCP’s tool annotations. Mark search_books as read-only, and let reserve_book show that it changes data.
    • Why: The AI app needs to know which tools only read and which change things, so it can ask the user before acting. Cowork, for example, asks the user to confirm any tool that isn’t marked read-only, and treats tools with no annotations as destructive.

    Build a few tools, one for each task a user actually wants done. Each tool:

    • ✓ Does the whole task in one call, on the server.
    • ✓ Takes only what the user knows, and gets the rest from the sign-in or the app.
    • ✓ Has a clear name, a clear description, and enums for fixed values.
    • ✓ Returns only what the model needs, capped, with a total.
    • ✓ Returns errors that say how to fix the problem.
    • ✓ Is annotated, so the AI app knows whether it only reads or changes data.

    For YEG & Tell, that comes down to four tools:

    ToolWhat it doesChanges data?
    search_booksFinds books by title, author, genre, status or age group. Returns up to 10 matches per page with ID, title, author and status.No
    get_book_detailsReturns the full details for one book.No
    reserve_bookChecks availability and reserves the book for the signed-in member, in one step.Yes
    get_my_loansLists the signed-in member’s current loans and due dates.No

    Here’s search_books with the C# SDK. LibraryDb is a small class that talks to Azure SQL; we’ll write it in Part 3.

    [JsonConverter(typeof(JsonStringEnumConverter<BookStatus>))]
    public enum BookStatus { Available, OnLoan, Reserved }
    
    [JsonConverter(typeof(JsonStringEnumConverter<AgeGroup>))]
    public enum AgeGroup { Children, Teen, Adult }
    
    public record BookSummary(int Id, string Title, string Author, BookStatus Status);
    public record SearchResult(IReadOnlyList<BookSummary> Books, int TotalMatches);
    
    [McpServerToolType]
    public static class LibraryTools
    {
        [McpServerTool(Name = "search_books", Title = "Search books",
            ReadOnly = true, OpenWorld = false)]
        [Description("Searches the library catalogue by title, author, genre, " +
            "status or age group. Returns up to 10 books per page with their ID, " +
            "title, author and status, plus the total number of matches. Use the " +
            "ID with get_book_details or reserve_book.")]
        public static async Task<SearchResult> SearchBooks(
            LibraryDb db,
            [Description("Words from the title or author, such as 'gladwell'.")]
            string? text = null,
            [Description("A genre, such as 'self-help'.")]
            string? genre = null,
            [Description("Only return books with this status.")]
            BookStatus? status = null,
            [Description("Only return books for this age group.")]
            AgeGroup? ageGroup = null,
            [Description("The page of results to return, starting at 1. Ask for " +
                "the next page when totalMatches is higher than the books " +
                "returned so far.")]
            int page = 1,
            CancellationToken ct = default)
        {
            if (genre is not null && !await db.GenreExistsAsync(genre, ct))
            {
                var genres = string.Join(", ", await db.GetGenresAsync(ct));
                throw new McpException(
                    $"Unknown genre '{genre}'. Valid genres are: {genres}.");
            }
    
            return await db.SearchBooksAsync(text, genre, status, ageGroup,
                page, pageSize: 10, ct);
        }
    }

    Most of the fixes show up in these few lines:

    • The descriptions tell the model what each input means and how the tools connect: search first, then reserve.
    • The enums and their JSON converters make the model see Available, OnLoan and Reserved, and Children, Teen and Adult, instead of numbers.
    • The result is capped at 10 books per page, with four fields each, plus the total number of matches, so the model can ask for the next page.
    • The error explains the fix. The SDK passes an McpException message back to the model, so don’t put sensitive details in it.
    • The genre is the one input that isn’t an enum, on purpose. Genres live in the database and can change, so the server checks the value and, if it’s wrong, returns an error listing the valid ones.
    • The annotations describe how the tool behaves. ReadOnly = true tells the AI app that searching only reads data. OpenWorld = false says the tool only works with YEG & Tell’s own database, not an open-ended set of outside systems like the web. In the SDK, OpenWorld defaults to true, so it’s worth setting.

    And here’s the part of reserve_book that matters most for design:

    [McpServerTool(Name = "reserve_book", Title = "Reserve a book",
        ReadOnly = false, Destructive = false, OpenWorld = false)]
    [Description("Reserves a book for the signed-in member. Checks that " +
        "it's available first. Find the book ID with search_books.")]
    public static async Task<string> ReserveBook(
        LibraryDb db,
        ClaimsPrincipal user,
        [Description("The book ID returned by search_books.")]
        int bookId,
        CancellationToken ct = default)
    {
        // The member comes from the sign-in, never from the model.
        var memberId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
            ?? throw new McpException(
                "You need to be signed in to reserve a book.");
    
        // LibraryDb checks availability and creates the reservation (Part 3).
        return await db.ReserveAsync(bookId, memberId, ct);
    }

    The SDK passes the signed-in user in as a ClaimsPrincipal, so the model never sees or supplies a member ID. ReadOnly = false says the tool changes data. It’s the SDK default, but setting it makes the intent clear. Destructive defaults to true, so reserve_book sets it to false: it adds a reservation but doesn’t delete or overwrite anything. Because it isn’t read-only, the AI app can ask before reserving.

    Figure 5 shows what that looks like for a user in Copilot Cowork. The model finds the book with search_books without asking, then stops for approval before reserve_book runs, because reserve_book has ReadOnly = false. Cowork asks for approval whenever a tool isn’t marked read-only or is marked destructive, and uses the tool’s Title as the heading of the approval card. The approval card shows a single input, the book ID, because the member comes from the sign-in.

    Mockup of Copilot Cowork running search_books without asking, then showing an approval card for reserve_book because it is not read-only
    Figure 5: The YEG & Tell tools in Copilot Cowork (mockup).

    When the model should write the query

    A few weeks before writing this post, I was building a Fabric data agent. The agent doesn’t have a separate tool for each question. It has one general tool, which Microsoft calls the DAX generation tool, that writes a DAX query from the user’s question and the semantic model. Thinking back to it while writing this section, that sounds a lot like my first instinct, and like the Vague names, descriptions and inputs anti-pattern: the model has to work out what each table, column and measure means. So why does it work?

    Because the semantic model does the job that good tool design does. Microsoft says the DAX generation tool relies only on the semantic model’s metadata and its Prep for AI setup:

    • The semantic model’s own metadata, such as clear names for tables, columns and measures, their descriptions and synonyms, tells the agent what everything means. Microsoft’s example: names like TR_AMT or DIM_GEO_01 give the tool no context, while Total Revenue does.
    • Prep for AI adds three things on top of the model: an AI data schema that limits which tables, columns and measures the agent can see, AI instructions, and verified answers, which are approved examples that guide the query.

    And analytics questions, like “revenue by region last quarter, excluding returns”, are too open-ended to build a tool for each one.

    The anti-pattern is the bare version: a generic run_sql tool pointed straight at a transactional database, with cryptic column names, no descriptions, no limits and write access. For YEG & Tell, that means a split. Reserving a book stays a task tool. A question like “How many self-help books were borrowed last month?” could go to a read-only query tool that runs against a curated, well-described view of the database, using an account that can only read.

    When you grow

    Search and fetch. ChatGPT’s deep research and company knowledge features expect two read-only tools: search, which takes a query and returns results with an ID, title and URL, and fetch, which returns the full content for one ID. Our search_books and get_book_details already follow the same idea, and we’ll come back to them in Part 7.

    Many servers, many tools. When an AI app is connected to many MCP servers, the tool definitions alone can fill a big part of the context window. Anthropic’s answer is code execution with MCP: the agent finds only the tools it needs and calls them from code, filtering results before they reach the model. In their example, token use dropped from about 150,000 to about 2,000, at the cost of running agent-written code in a secure sandbox. OpenAI’s tool search does something similar by loading tools only when they’re needed. YEG & Tell is far too small to need either.

    How to design resources

    Resources are read-only content the AI app can attach as context. The model doesn’t call them; the app or the user picks them, often from a menu. Each resource has a URI. A direct resource has a fixed URI, like library://policies/borrowing. A resource template has parameters, like library://programs/{programId}, so one definition covers every program.

    • Use resources for reference content that doesn’t depend on what the user asks: policies, program descriptions, the list of genres.
    • Use a tool when the content depends on the request. If it needs search criteria, like finding books, it’s a tool.
    • Give each resource a name, title, description and MIME type, so the app can show it in a menu and the model knows what it’s reading.
    • Keep URIs readable. library://programs/summer-reading tells you what it is.
    • Don’t rely on resources alone. Support varies between AI apps, and at the time of writing, Microsoft’s Cowork plugin documentation covers tools only. And where they are supported, the app decides how they’re used: in Claude, for example, the user attaches them from a menu. If the content matters, also offer it through a tool. For YEG & Tell, that would mean adding a get_policy tool to the four above.
    [McpServerResourceType]
    public static class LibraryResources
    {
        [McpServerResource(UriTemplate = "library://policies/borrowing",
            Name = "borrowing_policy", Title = "Borrowing policy",
            MimeType = "text/markdown")]
        [Description("Loan periods, borrowing limits and late fees.")]
        public static Task<string> BorrowingPolicy(
            LibraryDb db, CancellationToken ct) =>
            db.GetPolicyAsync("borrowing", ct);
    
        [McpServerResource(UriTemplate = "library://programs/{programId}",
            Name = "library_program", Title = "Library program",
            MimeType = "text/markdown")]
        [Description("Details of a library program, such as summer-reading.")]
        public static Task<string> LibraryProgram(
            LibraryDb db, string programId, CancellationToken ct) =>
            db.GetProgramAsync(programId, ct);
    }

    The first is a direct resource. The second is a template: the SDK fills programId from the URI the app asks for.

    Figure 6 shows the borrowing policy in use in Claude, which supports resources. The user attaches it from a menu, and the model answers from it without calling a tool.

    Mockup of Claude: the user attaches the borrowing policy resource from the plus menu and Claude answers the late fee question from it
    Figure 6: Attaching a resource in Claude (mockup).

    How to design prompts

    Prompts are templates the user starts on purpose, from a menu or as a slash command, depending on the app. They package a task users repeat, and they show the model how to use your tools for it.

    • Build prompts for repeatable tasks, not one-offs.
    • Keep arguments few and clear, and describe each one. Apps build a form from them: Claude, for example, labels each field with the argument’s name and uses its description as the hint, so level reads better than readingLevel. Prompt arguments are always passed as text, so you can’t use an enum like you can for a tool input; list the allowed values in the description instead, as level does.
    • Name the tools the prompt should use, so the model follows the path you intended.
    • Don’t count on prompts to fix weak tools. The user picks a prompt now and then; the model reads your tool descriptions every time.
    • Treat them as a bonus. Like resources, support varies, and Cowork’s plugin documentation covers tools only.
    [McpServerPromptType]
    public static class LibraryPrompts
    {
        [McpServerPrompt(Name = "recommend_books", Title = "Recommend books")]
        [Description("Suggests available books in a genre for a reading level.")]
        public static string RecommendBooks(
            [Description("A genre, such as self-help.")]
            string genre,
            [Description("Reading level: children, teen or adult.")]
            string level) =>
            $"Use search_books to find available {genre} books. " +
            $"Pick five suited to {level} readers and explain " +
            "each pick in one sentence. Then offer to reserve one " +
            "with reserve_book.";
    }

    Figure 7 shows recommend_books in Claude. The user picks it, fills in the two arguments, and Claude attaches the filled-in text to the message. Once it’s sent, the model follows it to search_books.

    Mockup of Claude: the user picks the recommend_books prompt, fills in genre and level, and Claude attaches the filled-in prompt to the message
    Figure 7: Starting a prompt in Claude (mockup).

    We now have three classes: LibraryTools, LibraryResources and LibraryPrompts. The SDK finds them through their [McpServerToolType], [McpServerResourceType] and [McpServerPromptType] attributes, so registering all three takes a few lines when we set up the server in Part 3:

    builder.Services.AddMcpServer()
        .WithHttpTransport()
        .WithToolsFromAssembly()
        .WithResourcesFromAssembly()
        .WithPromptsFromAssembly();

    Test your design

    Before building anything else, try your design with realistic questions and read what the model does with them: which tool it picks, what it sends and what comes back. Small changes to names and descriptions often make a big difference. For YEG & Tell, I’d start with these:

    AskWhat should happen
    “Do you have anything by Malcolm Gladwell?”search_books with the text “gladwell”
    “Any self-help books I can borrow now?”search_books with the genre “self-help” and the status Available
    “Reserve Dopamine Detox for me.”search_books to find the ID, then reserve_book, with approval asked in apps that use the annotations, such as Cowork
    “Show me self-hlep books.”An error listing the valid genres, then a retry with “self-help”
    “What’s the late fee?” (with the borrowing policy attached)An answer from the attached policy, with no tool call

    When the model picks the wrong tool or sends the wrong input, the fix is usually a clearer name or description. In Part 3, we’ll run these against the real server.

    Design checklist

    Here’s every anti-pattern from this post in one place, with what to do instead.

    AvoidDo instead
    One tool per API endpoint or tableTools built around tasks, like search_books
    Overlapping toolsOne tool with optional filters, and fewer than 20 tools
    Chained simple stepsOne tool that does the whole task, like reserve_book
    Asking for what your code already knowsTake it from the sign-in or the app
    Vague names, descriptions and inputsClear descriptions, unambiguous names and enums
    Dumping raw dataOnly the fields the model needs, capped, with a total
    Unhelpful errorsErrors that say what went wrong and how to fix it
    Not saying which tools change dataTool annotations: ReadOnly, Destructive and OpenWorld
    Relying on resources and prompts aloneOffer anything important through a tool too

    What’s next

    In Part 3, we build the YEG & Tell MCP server in C# on Azure SQL and test it. In Part 4, we’ll deploy it to Azure and add sign-in with Microsoft Entra ID. After that, we’ll connect it to three AI apps, each with its own plugin: Copilot Cowork in Part 5, Claude in Part 6 and ChatGPT in Part 7. You can follow the whole series on the MCP series page.

    Get the next part by email

    New posts in the MCP series and on AI, agents and Power Platform, straight to your inbox. No spam, unsubscribe anytime.

    Sources


    A note on how this post was written: the experience, examples and voice are mine. The writing was enhanced with the help of AI.

  • MCP, Part 1: What it is and how it works

    MCP, Part 1: What it is and how it works

    Not too long ago, I started building my first Microsoft Copilot Cowork plugin, and I found that the first prerequisite is that the system I want Cowork to connect to must have an MCP server on top of it. So, that took me down the rabbit hole to understand what MCP is and how to build an MCP server.

    In this series, we’ll start with an intro to MCP and MCP servers, then we’ll get into how to design an MCP server, build and test one, deploy it to Azure, and end with connecting it to Copilot Cowork, Claude and ChatGPT with a plugin for each.

    Throughout the series, I’ll use one simple example: the YEG & Tell Library, a fictional library with an SQL database of books. And since MCP is still evolving, everything here reflects the current version of the specification, 2026-07-28.

    In this post

    What is MCP?

    MCP (Model Context Protocol) is a universal connector between AI apps and other systems. It lets an AI app like Claude, ChatGPT or Copilot look things up and get things done in those systems, without every AI app needing its own custom integration.

    On its own, an AI model only knows what it learned in training and what you type into it. It can’t read your email or check whether a book is available at the library. MCP gives the AI app a standard way to plug into those systems.

    A popular way to describe MCP is USB-C for AI. Think of your system as a device with one standard USB-C port: any accessory with a USB-C plug can connect to it, instead of the device needing a different port for each accessory. Your system’s MCP server is that USB-C port, and the AI apps are the accessories that plug into it. For example, Microsoft’s Work IQ MCP server gives AI apps access to your Outlook emails, and both a Copilot Studio agent and GitHub Copilot CLI can connect to it and work with the same inbox.

    Who invented it, when and why?

    MCP came out of Anthropic, the company behind Claude. Two Anthropic engineers, David Soria Parra and Justin Spahr-Summers, created it, and Anthropic released it as an open standard in November 2024.

    The problem they were solving was simple. AI assistants were smart, but cut off from the files, databases and business apps where people’s actual work lives. Every connection between an AI app and a system was a custom build, repeated for every combination of app and system. MCP turned that into a build-once standard.

    Because it was open, it caught on fast. Through 2025, OpenAI, Google and Microsoft added MCP support to their products, which is why it works today in ChatGPT, Gemini, VS Code and Microsoft 365 Copilot. In December 2025, Anthropic donated MCP to the Agentic AI Foundation under the Linux Foundation, so it’s now a neutral, industry-wide standard rather than something one company owns.

    Why do AI apps need MCP servers? Can’t they just connect directly to the required system’s API?

    This was my first question. For Copilot Studio agents, I’m used to using a Power Automate flow to connect to the system’s API, get the data, then pass it to the agent to reason on it.

    The short answer: they can, but there’s a catch.

    Say you have 5 AI apps that need to connect to your system:

    • Without an MCP server, you build the connection logic 5 times, once for each AI app, including how each one signs in. And every time your system changes, say a new API endpoint is added, you update it 5 times.
    • With an MCP server, you build the server once, and all 5 apps connect to it, as long as they’re MCP-capable. Each app needs a one-time setup, but that’s configuration, not code.
    Without MCP, five AI apps each need their own integration to your system; with MCP, all five connect to one MCP server
    Figure 1: Five integrations to build and maintain, or one MCP server.

    On top of saving you from building the same thing 5 times, an MCP server gives you:

    • A model-friendly menu. APIs are built for developers, with dozens of endpoints and big payloads. An MCP server offers a short list of clearly described actions, like search_books(genre), that the model can actually use well.
    • One place for security. Sign-in, permissions, and which operations are allowed at all, all handled in the server.

    And what if your system doesn’t have an API at all, like a database or files on someone’s computer? There’s nothing for the AI app to call. An MCP server solves that too: it runs where the data lives and becomes the way in. A server inside your network can query an on-prem database directly (cloud apps like Copilot Cowork would reach it through a secure HTTPS endpoint), and a local MCP server can read files on your computer. YEG & Tell’s book database is this case too: it’s an Azure SQL database with no API, just tables, and the MCP server turns each request into a safe query.

    To be fair, if only one AI app will ever use your system, connecting directly to the API can be simpler. More on that at the end.

    What is the architecture of MCP?

    Once I got past the “why”, the next step was understanding the moving parts. There are three:

    1. MCP host: the AI app you work in, such as VS Code or Copilot Cowork.
    2. MCP client: a component built into the host that keeps a connection to one MCP server. The host runs one client for each server it connects to.
    3. MCP server: a program that sits on top of your system and exposes what it can do to the host, through the client. The Microsoft Learn MCP server is a good example.

    One client per server: configuration, not code

    When I first read “the host creates one client per server”, I wondered if the host’s developers had to write a client for every server. That didn’t make sense to me, and it turns out it’s not the case.

    The host’s developers write the client code once. When you add a server, for example by adding a plugin in Copilot Cowork or a settings entry in VS Code, the host starts a new copy of its built-in client and points it at that server. It’s like browser tabs: one browser, a separate tab for each website.

    So the only part you build for your system is the server. Let’s look at that next.

    What is an MCP server?

    An MCP server is a program that sits on top of a system and exposes some of what that system can do to AI apps, in a standard way. The system can have an API, or it can be a database, like YEG & Tell’s SQL database of books. The server decides what the AI app is allowed to do, and translates each request into an API call or a query.

    Servers come in two flavours:

    • Local: runs on the same computer as the AI app, which launches it and talks to it directly. Handy for things like reading files on your machine, but only AI apps that run on your computer, like VS Code, can use it.
    • Remote: runs on a web server, and AI apps connect to it over the internet, usually after you sign in. This is how business systems are typically exposed, and it’s the only kind Copilot Cowork supports.

    The Microsoft Learn MCP server is a remote one. It’s free, needs no sign-in, and lives at https://learn.microsoft.com/api/mcp.

    What are the core features of an MCP server?

    An MCP server can offer three things: tools, resources and prompts. Here’s a summary of each one.

    FeatureWhat it isWho decides when it’s usedYEG & Tell example
    ToolsActions the model can callThe modelsearch_books, reserve_book
    ResourcesRead-only content the server already hasThe AI app or the userPDF describing the summer reading program
    PromptsReady-made instruction templatesThe user“Recommend books” template

    Tools

    Tools are actions the model can call. Each tool has a name, a description and a list of inputs. The model reads the descriptions and decides on its own which tool to call, so a clear description matters a lot. For actions that change data, like reserve_book, the server can mark the tool as one that changes data. Some AI apps, like Copilot Cowork, then ask you to approve the call first. Not all do: when I tested in VS Code for Part 3, the Codex agent reserved a book without asking, and the Claude agent asked even before a simple search.

    Resources

    Resources are read-only content the server already has, like the PDF describing YEG & Tell’s summer reading program. I got this one wrong at first: I thought the user attaches the PDF. Actually, the server offers it, the AI app lists it in a menu, and you pick it (or the app adds it automatically). Either way, you never upload anything.

    Prompts

    Prompts are ready-made templates you choose to run, often as slash commands. For example, a recommend_books prompt asks you for a genre and a reading level, then hands the model well-written instructions so you don’t have to write them yourself.

    In practice, tools do most of the work. Virtually every MCP-capable AI app supports them, while support for resources and prompts is patchy. That’s why many servers expose tools only, for example a get_program_info tool instead of a program PDF resource.

    How do the MCP client and server talk to each other?

    MCP splits this into two layers: what the messages say, and how they travel.

    What the messages say: JSON-RPC

    RPC stands for Remote Procedure Call, a way for one program to call a function that runs in another program as if it were local. JSON-RPC is a version of that where every message is written in JSON. A message is one of three kinds:

    • Request: asks the other side to run something, and has an id.
    • Response: the answer to a request, carrying the same id.
    • Notification: a heads-up with no id and no reply expected, like “my list of tools changed”.

    Here’s a simplified call to the YEG & Tell server:

    // Client -> Server (request)
    { "jsonrpc": "2.0", "id": 1, "method": "tools/call",
      "params": { "name": "search_books", "arguments": { "genre": "self-help" } } }
    
    // Server -> Client (response)
    { "jsonrpc": "2.0", "id": 1,
      "result": { "content": [ { "type": "text", "text": "[{\"title\": \"Dopamine Detox\", ...}]" } ] } }

    The same pattern covers everything else: tools/list, resources/list and prompts/list to see what a server offers, and resources/read and prompts/get to use them. A client can also start with server/discover to find out which features and protocol versions the server supports.

    If you read older MCP material, you’ll see every connection start with an initialize handshake. Since the 2026-07-28 version, MCP is stateless: each request carries everything the server needs. Many apps still use the older versions, and the SDKs handle both, so you rarely have to think about it.

    How the messages travel: stdio and Streamable HTTP

    • stdio for local servers: the AI app starts the server on the same computer, and they exchange messages directly, with no network involved.
    • Streamable HTTP for remote servers: messages go over HTTP (HTTPS in production), and MCP recommends OAuth for signing users in.

    Putting it all together

    MCP architecture: a host running one MCP client per server, connected to Microsoft Learn, Library and File system MCP servers, with numbered steps for one request
    Figure 2: The host runs one client per server; the numbers follow a single request.

    Here’s what happens when someone asks the AI app, “Do you have any self-help books available?” The numbers match the orange markers in the diagram:

    1. The model looks at the tools from all connected servers and picks search_books from the Library server.
    2. The host hands the call to the client connected to the Library server.
    3. The client sends a tools/call request over Streamable HTTP.
    4. The Library server queries the book database and returns the results.
    5. The results travel back through the client to the model, which writes the answer.

    When to build an MCP server?

    Coming back to where I started: when should you build one?

    Build an MCP server when more than one AI app needs to use your system, and you want to build and maintain that connection once, with one place to control who can do what.

    You also need one when the AI app you’re connecting can only reach outside systems through MCP. That was my case: Copilot Cowork couldn’t connect to my system through its API, so the plugin I was building needed an MCP server.

    Before you start building, ask yourself:

    • Will only one AI app ever use the system? Then a direct integration in that app might be simpler, as long as the app supports one. Copilot Cowork, for example, doesn’t at the time of writing this post.
    • Does an MCP server for the system already exist? Many vendors now publish their own. If so, use it instead of building yours.
    • Is the platform’s own connector enough? In Copilot Studio, for example, a Power Platform custom connector may already do the job.

    What’s next

    In the next post, we’ll look at how to design an MCP server’s tools, resources and prompts. In Part 3, we build and test the YEG & Tell MCP server. In Part 4, we’ll deploy it to Azure and add sign-in, and in Parts 5 to 7, we’ll connect it to Copilot Cowork, Claude and ChatGPT, each with its own plugin. You can follow the whole series on the MCP series page.

    Get the next part by email

    New posts in the MCP series and on AI, agents and Power Platform, straight to your inbox. No spam, unsubscribe anytime.

    Sources and further reading


    A note on how this post was written: the experience, examples and voice are mine. The writing was enhanced with the help of AI.