Intermediate

Model Context Protocol in Practice

model · context · protocol · ai · agents · orchestration · artificial · intelligence · generative · mcp · server · local · servers · oauth · tools · remote · resources · introducing · pro...

Table of Contents

Module 1: Getting Started with MCP

First Look: The Power of MCP in Action

Before writing any code, it helps to see what a finished integration looks like. Consider a desktop AI chat client with an MCP configuration file that adds a server such as an “Azure MCP Server.” After pasting in a small JSON configuration and restarting the client, the conversation’s tool list suddenly includes a whole set of new capabilities pulled from that server — in this example, one of them is Azure AI Search.

When the user asks the assistant to describe the contents of a specific search index, the client shows a “tool call” card containing the request and response for the tool the model chose to invoke (get_search_index, for example). Interestingly, calling that tool triggers a native sign-in dialog — not a browser popup, but an authentication prompt handled directly inside the desktop client. After signing in, the model receives real information about the search index and answers the question.

This single interaction raises the core questions that the rest of this material answers:

  1. What is that JSON configuration actually doing? Is it launching a Node.js package? A local server process?
  2. How does the client discover every tool exposed by that server?
  3. How does the model decide which tool to call and how does it infer the right parameters (like the search index name)?
  4. How does the authentication flow work behind the scenes?
  5. How can you build the same kind of server yourself, backed by your own company’s data and systems?

The practical goal of this material is to build exactly this kind of integration end-to-end: an MCP server that exposes tools, resources, and prompts backed by real enterprise infrastructure, then secure it with OAuth so it can be safely exposed as a remote server.

The running example throughout is a fictitious enterprise, Globomantics, whose employees find their existing HR Management (HRM) system complicated to use just to request time off. Leadership wants to experiment with AI tooling to make the self-service experience better. The plan is to build an MCP server that talks to Globomantics’ existing infrastructure hosted in a cloud provider:

  • An Azure Functions app exposing the HRM API surface (modeled loosely on a real HR platform’s API to keep it realistic, but implemented as a simplified mock API for the course).
  • Azure Blob Storage holding benefit/policy documents (PDFs) that the MCP server can expose as resources.
  • Azure AI Search for semantic search over those documents.
  • Microsoft Entra ID fronting the HRM API so the demo can exercise a real OAuth-based enterprise authentication flow.
flowchart LR
    subgraph Client["AI Client (Claude Desktop / VS Code Copilot)"]
        LLM["LLM"]
    end

    subgraph MCP["Globomantics MCP Server"]
        Tools["Tools"]
        Resources["Resources"]
        Prompts["Prompts"]
    end

    subgraph Backend["Enterprise Backend"]
        HRMAPI["HRM API (Azure Functions)"]
        Blob["Azure Blob Storage (Policy PDFs)"]
        Search["Azure AI Search"]
        Entra["Microsoft Entra ID"]
    end

    LLM <-->|JSON-RPC over stdio / HTTP| MCP
    Tools --> HRMAPI
    Resources --> Blob
    Tools --> Search
    MCP <-.OAuth.-> Entra
    HRMAPI <-.OAuth.-> Entra

For most of the material, the MCP server runs locally on the developer’s machine over the standard I/O (stdio) transport. Later, the same server is “lifted and shifted” into the cloud so it becomes a remote MCP server reachable by any authorized client — Claude Desktop, the MCP Inspector, and GitHub Copilot in Visual Studio Code are all used as example hosts/clients along the way. A recurring theme is that MCP is not just a wrapper around an existing API — designing a good MCP server means designing intentionally for how an LLM (and a human) will actually use it.

Configuring Your Local Development Environment

Building MCP servers touches multiple ecosystems, so the tooling spans both Node.js and .NET:

ToolWhy it’s needed
Node.js (LTS, 22+)Nearly all MCP tooling (Inspector, many reference servers, AI coding tools) is distributed as npm packages. AI tools such as Cursor, Claude Code, and VS Code all assume Node.js is available.
.NET 8 SDKLong-term support runtime used to build the example MCP server with the official C# SDK.
.NET 10 SDK (preview)Needed only for the dnx command — the .NET equivalent of npx — which can dynamically execute a NuGet package without a separate install step.
MCP Inspector (npx @modelcontextprotocol/inspector)Browser-based tool for testing and debugging an MCP server directly, independent of any AI client.
Visual Studio CodeUsed as the primary editor and as an MCP client (via GitHub Copilot’s agent mode) throughout the demos.

MCP itself is a specification, not a library, so it is deliberately technology-agnostic. Although most public examples default to JavaScript/TypeScript or Python, an MCP server is fundamentally a back-end service, and .NET/C# is a very common enterprise back-end choice — hence the decision to build the example server with the official MCP C# SDK. The underlying concepts (tools, resources, prompts, transports, authorization) all translate directly to other SDKs.

To validate the environment, each tool should print its version:

node -v      # should be 22.x or later
npm -v       # should be 10.x or later
npx -v
dotnet --list-sdks   # should include both 8.x and 10.x
dnx --help

If every one of these commands succeeds, the environment is ready. The demos are performed on Windows, but every tool used is cross-platform and works the same way on macOS and Linux.

Scaffolding Your First MCP Server

The first server is scaffolded from an empty directory using the standard .NET console template:

dotnet new console --name Globomantics.Mcp.Server
cd Globomantics.Mcp.Server
dotnet run

At this point it is just a “Hello World” console app — not yet an MCP server. Two NuGet packages turn it into one:

dotnet add package ModelContextProtocol --prerelease
dotnet add package Microsoft.Extensions.Hosting

Microsoft.Extensions.Hosting provides the generic .NET hosting API (Host.CreateEmptyApplicationBuilder), which lets you configure services through a builder pattern. The MCP SDK extends that builder with AddMcpServer(), which returns its own fluent configuration API. The very first thing to configure is the transport — for local development, the simplest option is the standard I/O (stdio) transport:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateEmptyApplicationBuilder(settings: null);

builder.Logging.AddConsole(consoleLogOptions =>
{
    // Configure all logs to go to stderr
    consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});

builder.Services.AddMcpServer()
    .WithStdioServerTransport();

var app = builder.Build();

await app.RunAsync();

This is the entire content of Program.cs for a bare, empty MCP server — around six lines of actual configuration. The corresponding project file only needs the executable output type and the two package references:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.9" />
    <PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
  </ItemGroup>

</Project>

Running dotnet run now prints the standard hosting startup messages instead of “Hello World” — the process is listening for requests. But what kind of requests? MCP messages are JSON-RPC 2.0 messages. A minimal request looks like this:

{"jsonrpc": "2.0", "id": 1, "method": "ping"}

Every JSON-RPC request needs:

  • jsonrpc — the protocol version, currently always "2.0".
  • id — a correlation identifier (string or number) unique within a session, used to match requests to responses.
  • method — the method name being invoked (defined by the MCP specification).
  • params — optional parameters for the method.

ping is the simplest method in the spec — clients use it to verify a server is alive. Because the server uses the stdio transport, it expects one JSON-RPC message per line on standard input. Typing the message above directly into the running process and pressing Enter produces a JSON-RPC response on standard output.

Typing raw JSON-RPC by hand does not scale, and it hides everything else the server might be capable of — this is where the MCP Inspector comes in.

Debugging with the MCP Inspector

The MCP Inspector is a browser-based tool, distributed as an npm package, purpose-built for testing and debugging MCP servers independent of any particular AI client. Since it needs to spawn the MCP server as a child process and communicate over stdio, it is launched with npx so it does not need to be installed into the project:

npx @modelcontextprotocol/inspector dotnet run

If the server lives in a different working directory, pass the project file explicitly:

npx @modelcontextprotocol/inspector dotnet run --project Globomantics.Mcp.Server/Globomantics.Mcp.Server.csproj

Running this command actually starts two processes: the Inspector’s own web server/proxy, and the MCP server itself as a spawned child. After confirming the package install, the Inspector prints a URL and opens a browser tab automatically.

sequenceDiagram
    participant Browser as Inspector UI (Browser)
    participant Proxy as Inspector Proxy
    participant Server as Globomantics MCP Server (stdio)

    Browser->>Proxy: Connect
    Proxy->>Server: spawn child process
    Proxy->>Server: initialize (JSON-RPC over stdio)
    Server-->>Proxy: capabilities response
    Proxy-->>Browser: render capabilities (Tools/Resources/Prompts tabs)
    Browser->>Proxy: ping (via UI button)
    Proxy->>Server: {"jsonrpc":"2.0","id":1,"method":"ping"}
    Server-->>Proxy: {"jsonrpc":"2.0","id":1,"result":{}}
    Proxy-->>Browser: display request/response pair

Before connecting, the left-hand sidebar shows the transport configuration: stdio (vs. Streamable HTTP or the deprecated SSE transport), the command and arguments to launch the server, optional environment variables, and authentication settings (anonymous by default). Clicking Connect launches the server and performs the MCP initialization handshake — conceptually similar to an HTTP handshake, but MCP defines its own request/response pair where the client asks for the server’s declared capabilities.

Once connected, a toolbar of tabs (Resources, Tools, Prompts, Ping) becomes available, driven entirely by what capabilities the server reports. On a brand-new server, only Ping is enabled (built into the SDK by default) — everything else is empty because no tools, resources, or prompts have been registered yet. Clicking the Ping button in the UI sends the exact same JSON-RPC ping message shown above, and the request/response pair appears in a History panel. The Inspector deliberately hides the id/jsonrpc boilerplate in this view and only shows the method name and parameters, even though the underlying correlation ID is what ties the message pair together.

A Server Notifications panel on the right stays empty at first. Switching back to the terminal running the Inspector reveals something that looks like an error — a stack trace mentioning the stdio transport module and “not valid JSON.” The cause: a stray Console.WriteLine call in the server. With the stdio transport, everything written to standard output must be a valid JSON-RPC message — plain log text corrupts the stream.

[!IMPORTANT] With the stdio transport, never call Console.WriteLine (or your language’s equivalent write-to-stdout call) for logging or debug output. Only JSON-RPC messages may go to stdout. Use Console.Error.WriteLine, or better, a structured logging abstraction configured to write to standard error.

Simply switching to Console.Error.WriteLine fixes the crash. But there’s a better long-term answer: use the framework’s built-in logging abstraction and redirect it to standard error globally, as already shown in the Program.cs snippet above (consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace). Once logging is redirected, any log call automatically appears in the Inspector’s Server Notifications panel as a notifications/message — the MCP specification’s mechanism for server-side logging, carrying a logger name, a severity level, and a structured (JSON-serializable) payload.

Finally, to avoid repeatedly typing the full npx invocation, it helps to wrap it in an npm project:

{
  "scripts": {
    "start": "dotnet run",
    "dev": "npx @modelcontextprotocol/inspector dotnet run"
  }
}

npm start runs the bare server (useful for connecting a real AI client), while npm run dev launches the Inspector with the server as its managed child process (useful while actively developing and debugging). At this point, a fully working — if empty — MCP server exists in only a handful of lines of code, along with a debugging workflow using the Inspector.

Module 2: Building MCP Servers

Why Build an MCP Server At All?

Given an existing API, it’s tempting to reach for a tool that auto-generates an MCP wrapper around it. That approach usually produces a poor experience. It’s worth zooming out first.

Software fundamentally exists to bridge the gap between data and answers, mediated by capturing and interpreting user intent. Over the decades, the way software captures intent has evolved — command lines, forms and menus, touch and gesture, and now natural language, environment, and (increasingly) behavior, inferred by LLMs. Each shift in “intent modality” changed how software should be designed. If a user’s need starts as a plain-language question, why route that question through several layers of UI abstraction between the question, the data, and the answer? MCP exists to let you collapse some of those layers and respond in the same modality the question arrived in.

For Globomantics, the actual employee questions look like:

  • “When is my next scheduled holiday?”
  • “Am I eligible for a sabbatical?”
  • “Can I take next Friday off?”

Common data interactions behind these questions:

DataCharacteristics
Office holiday calendarsCompany-wide, but vary by office location (e.g., United States vs. India).
Benefits enrollmentEvery worker has benefits, but tiers/plans vary by worker.
Time-off requestsEveryone has to go through the HRM system, which is cumbersome.

An MCP server can remove a lot of the context-switching employees currently deal with. The perennial software challenge remains: where does the data come from? For this course’s architecture, worker data comes from a REST API, HR policy documents are PDFs in Azure Blob Storage, and office calendars are hardcoded directly in the MCP server (to keep the demo focused). This blueprint — tools driven by a back-end API, resources backed by blob storage, calendars as static data — is what the rest of the module builds out.

flowchart TD
    Q["Employee question\n(natural language)"] --> MCP["Globomantics MCP Server"]
    MCP -->|tools| API["HRM REST API"]
    MCP -->|resources| Blob["Policy PDFs in Blob Storage"]
    MCP -->|resources| Cal["Hardcoded office calendars"]
    MCP --> LLM["LLM in AI client"]
    LLM --> A["Answer back to employee"]

Connecting Local MCP Servers to AI Clients

Testing tool discovery in the Inspector is one thing; testing the actual end-user experience requires wiring the server up to a real client and LLM.

Visual Studio Code stores MCP server configuration in a .vscode/mcp.json file:

{
    "servers": {
        "globomantics-mcp-server-local": {
            "command": "dotnet",
            "args": [
                "run",
                "--project Globomantics.Mcp.Server/Globomantics.Mcp.Server.csproj"
            ],
            "cwd": "${workspaceFolder}"
        }
    }
}

Two easy mistakes to watch for:

  • The top-level key is servers, not mcpServers (that name is used by other clients such as Claude Desktop).
  • If dotnet run is executed from a directory other than the project folder, you must pass --project pointing at the .csproj file, otherwise the client’s attempt to start the server fails immediately.

An env object can specify inline environment variables, or VS Code supports an envFile property pointing at a .env file — handy for sharing configuration with a team without leaking secrets into the JSON itself.

While actively developing, it’s best to leave the server stopped in the client and only start it on demand, because dotnet run locks files in the output directory and will block recompilation while the previous process is still running. Every time server code changes, the client-managed server process must be restarted to pick up the change.

Claude Desktop uses a per-user claude_desktop_config.json file (accessible via Developer settings → Edit Config) with a similar but distinct shape:

{
    "mcpServers": {
        "globomantics-mcp-server-local": {
            "command": "dotnet",
            "args": ["run", "--project", "/full/path/to/Globomantics.Mcp.Server.csproj"]
        }
    }
}

Note the mcpServers key (not servers), and that the project path must be absolute since this configuration file is not scoped to a particular workspace folder. After saving, Claude must be fully restarted to pick up the new server, at which point it appears in the MCP settings page and starts automatically. Per-server log files live alongside the configuration file and are the first place to check when troubleshooting a server that won’t start.

Exposing Resources

Resources let end users chat with their data. For Globomantics, two entity types become resources first: calendars and documents (HR policy PDFs).

Once resources are registered, the Inspector’s Resources tab activates because the server’s initialization response now includes a resources capability. Clicking List Resources issues a resources/list request; the server responds with a resource list. Every resource requires a URI (its primary identifier) and a programmatic name; title (human- and LLM-facing), description (LLM-facing only — a hint for the model, rarely shown to humans), and mimeType are all optional but recommended.

It’s typical to invent a custom URI scheme for your application, similar to how native apps register launcher schemes. Globomantics uses globomantics://hrm/<entity> style URIs:

[McpServerResourceType]
public static class CalendarResources
{
    public const string ResourceWorkCalendarUri = "globomantics://hrm/calendars/work";

    [McpServerResource(
        UriTemplate = ResourceWorkCalendarUri,
        Name = "work-calendars.json",
        Title = "Work Holiday Calendars",
        MimeType = "application/json")]
    [Description("Returns the holiday calendars for different work locations (United States and India).")]
    public static string WorkCalendarsResource()
    {
        var usCalendar = AnnualHolidayCalendar.CreateForYear(DateTime.Now.Year, WorkLocation.UnitedStates);
        var inCalendar = AnnualHolidayCalendar.CreateForYear(DateTime.Now.Year, WorkLocation.India);

        var workCalendarResource = new { US = usCalendar, IN = inCalendar };

        return JsonSerializer.Serialize(workCalendarResource, McpJsonUtilities.DefaultOptions);
    }
}

Registering resources (and later tools/prompts) is done fluently on AddMcpServer():

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

WithResourcesFromAssembly (and its Tools/Prompts counterparts) scans the compiled assembly for annotated classes/methods, so you don’t have to register every capability by hand one at a time — an alternative is to register each one explicitly via WithResources<T>()-style APIs if you want tighter control.

Metadata attributes (Title, Description, MimeType, etc.) are what the server reports in the resources/list response. The method body only executes when a client issues a resources/read request for that specific URI. Since calendars are hardcoded for the demo, the method simply builds fresh calendar objects for the current year and serializes them as JSON. A plain string return type is automatically treated by the C# SDK as text resource content.

In Claude Desktop, resources appear in a menu the end user can pick from and attach to their prompt manually (displayed by title, referenced internally by name). Once attached, the model can reference the calendar content directly — asking “when is the next scheduled holiday” resolves correctly, and switching the referenced location (US vs. India) changes the answer accordingly.

Real (non-hardcoded) data typically comes from an external API, exactly like the HR policy documents resource, which is backed by Azure Blob Storage through a small service abstraction:

[McpServerResourceType]
public static class DocumentResources
{
    public const string ResourceBenefitPlanDocumentsUri = "globomantics://hrm/documents";
    public const string ResourceBenefitPlanDocumentUri = "globomantics://hrm/documents/{documentId}";

    [McpServerResource(
        UriTemplate = ResourceBenefitPlanDocumentsUri,
        Name = "policy-documents.json",
        Title = "HR Benefit Plan and Policy Documents",
        MimeType = "application/json")]
    [Description("Provides a list of policy documents available to employees. Each policy document is a PDF file and may relate to a specific benefit plan that is available to the employee.")]
    public static async Task<IEnumerable<ResourceContents>> DocumentListResource(IHrmDocumentService documentService, CancellationToken cancellationToken)
    {
        var documentInfos = await documentService.GetBenefitPlanDocumentsAsync(cancellationToken);

        return documentInfos.Select(info => new TextResourceContents
        {
            Text = JsonSerializer.Serialize(info, McpJsonUtilities.DefaultOptions),
            MimeType = "application/json",
            Uri = ResourceBenefitPlanDocumentUri.Replace("{documentId}", info.DocumentId),
        });
    }
}

The resource method is async because it calls out to Azure Blob Storage; a CancellationToken parameter is idiomatic .NET for aborting async work and is injected automatically by the SDK. Unlike the single calendar resource, this one returns multiple content objects — one per document — each with its own URI encoding the PDF file name and a text payload of metadata (title, description, category). Critically, this list resource does not include the actual PDF bytes — only pointers (URIs) to specific documents. Since no specific document URIs are separately listed, asking an assistant a question like “what’s in my medical plan” fails: there is no resource yet that the model (or the human) can select to get the actual PDF content. That gap is solved with resource templates.

Resource Templates for Dynamic Data

Enumerating every possible resource up front breaks down once the number of items is large or dynamic — think a product catalog, or every employee in an org. A resource template parameterizes the URI so the model (or client) can fill in the blank.

In the Inspector, clicking List Templates issues a resources/templates/list request. The response shape mirrors resources/list, except each entry has a uriTemplate instead of a fixed uri. Selecting a template and filling in a parameter (e.g., a document ID like the dental plan PDF) triggers the same resources/read request as a normal resource, but now the server responds with a binary blob content type — the PDF Base64-encoded with an application/pdf MIME type.

[McpServerResource(
    UriTemplate = ResourceBenefitPlanDocumentUri,
    Name = "HR Benefit Plan and Policy Document by ID",
    MimeType = "application/pdf")]
[Description("Retrieves a specific HRM benefit plan document by its document ID (e.g. Globomantics-Plan.pdf)")]
public static async Task<ResourceContents> DocumentResourceById(string documentId, IHrmDocumentService documentService, CancellationToken cancellationToken)
{
    var downloadResult = await documentService.GetBenefitPlanDocumentContentAsync(documentId, cancellationToken);

    if (string.IsNullOrEmpty(downloadResult))
    {
        throw new McpProtocolException("Benefit plan document content is empty", McpErrorCode.InternalError);
    }

    return new BlobResourceContents
    {
        Blob = downloadResult,
        MimeType = "application/pdf",
        Uri = ResourceBenefitPlanDocumentUri.Replace("{documentId}", documentId),
    };
}

Declaring documentId as a placeholder inside the UriTemplate string is what signals to the C# SDK that this is a resource template — the SDK handles extracting the parameter from the requested URI and passing it into the method. The underlying blob service simply converts the downloaded binary data to Base64:

public async Task<string?> GetBenefitPlanDocumentContentAsync(string documentId, CancellationToken cancellationToken)
{
    var containerClient = blobServiceClient.GetBlobContainerClient(containerName);
    var blobClient = containerClient.GetBlobClient(documentId);

    var exists = await blobClient.ExistsAsync(cancellationToken);
    if (exists.Value == false) return null;

    var downloadResult = await blobClient.DownloadContentAsync(cancellationToken);
    return Convert.ToBase64String(downloadResult.Value.Content);
}

Two more resource templates follow the same pattern: retrieving an office calendar by year and location (a template with two parameters), and retrieving an employee’s planned time off by employee ID.

Client support for templates varies significantly. Claude Desktop, at the time of this course, only lists direct resources, not templates — so this fix does not actually help Claude on its own. VS Code’s MCP integration, however, does list resource templates through its Add Context → MCP Resources menu, and filling in a document ID (like the dental plan PDF) attaches it successfully.

ClientDirect resourcesResource templates
Claude Desktop❌ (not supported)
VS Code (Copilot agent mode)

Introducing Tools

With resources in place, the next capability is tools — the primary way an LLM can take action, as opposed to just reading data. In the Inspector, once tools are registered, the Tools tab activates, and tools/list returns the available set. A minimal example is an EchoTool:

public class EchoTool
{
    [McpServerTool, Description(
        """
        Echo back the provided message.

        - If the user provides a repeat count, the message is repeated that many times.
        - If they use exact quotes, then pass that as the message.
        - If there are multiple quotes, combine them into a single message.
        """)]
    public static string EchoMessage(
        [Description("The message to echo back")]
        string message,

        [Description("Number of times to repeat the message")]
        int repeat = 1)
    {
        for (var i = 0; i < repeat - 1; i++)
        {
            message += " " + message;
        }

        return $"You said: {message}";
    }
}

Each tool parameter is rendered in the Inspector’s UI based on the tool’s declared input schema, which follows the JSON Schema specification: a repeat parameter typed as a number rejects text input, message is marked required, and every parameter can carry an LLM-facing description. In runtime-introspectable languages (Python, C#, Go, Rust, PHP), the official SDKs typically generate this input schema automatically from the method signature and attributes. Languages without runtime type introspection (like plain JavaScript) require an explicit schema, commonly authored with a validation library such as zod in the TypeScript SDK:

server.registerTool("add", {
  inputSchema: { a: z.number(), b: z.number() }
}, async ({ a, b }) => { /* ... */ });

Calling a tool sends a tools/call request; the response contains an array of content blocks. A tool that returns a plain string automatically gets wrapped as a text content block by the C# SDK. Beyond text, other content block types include image, audio, resource link, and embedded resource — the latter two are explored later in this module.

In Claude, once a tool like Echo is available, simply asking naturally (“play Simon Says and repeat this back…”) triggers a confirmation prompt before the tool executes, then the result comes back — including sometimes over-eager interpretation of the request, since LLM tool-calling is inherently non-deterministic. This is exactly why the tool’s Description matters: it’s a lever for shaping how the model interprets and fills in parameters, and refining it is an iterative, prompt-engineering-flavored exercise.

A trivial echo tool doesn’t reveal the harder design questions that come from wiring a tool to a real downstream API — which is exactly what building the first “real” Globomantics tool, requesting time off, surfaces next.

Tools and Structured Content

The Request Time Off tool submits a real time-off request to the HRM API backend. In its first iteration, it takes two inputs — an employee ID and a request object — forwards them to the backend, and returns the response as structured content (an object with its own declared output schema, in addition to the input schema).

[McpServerTool(UseStructuredContent = true, Title = "Request Time Off")]
public async Task<TimeOffResponse> RequestTimeOff(string employeeId, TimeOffRequest request, CancellationToken cancellationToken)
{
    // single line delegating to the backend API
}

Inspecting the generated tools/list schema for this first version reveals real usability problems once you look closely at the request shape: an array of Days, each with a date, start time, end time, a dailyQuantity, and a timeOffType object carrying an opaque backend ID and name. None of this maps cleanly onto how a human — or a model acting on a human’s behalf — actually thinks about requesting time off:

  • How would an AI tool know what exact start/end times to fill in for a “day off”? Most people think in terms of half-days or full days, not literal clock times.
  • How would it know a valid timeOffType ID, when that’s an internal backend identifier with no meaning in the conversation?
  • What is a “daily quantity” supposed to represent to an LLM with no domain knowledge of the backend schema?

This is the practical failure mode of treating an MCP tool as a thin wrapper over an existing REST endpoint. Testing this in Claude confirms it: the assistant asks sensible clarifying questions based on the input schema (a personal day, a half day), but stumbles on the “time off type ID,” since the employee obviously doesn’t know an internal database ID. The model guesses anyway, the call fails, and — worse — the failure returns no usable error message. Checking server logs reveals an unhandled exception underneath: a 400 Bad Request from the backend, because the mock data store generates random unique IDs for absence types, so any guessed ID is essentially guaranteed to be wrong regardless of whether the rest of the payload was valid.

This is the MCP equivalent of handing a business user the keys to a raw API endpoint and saying “have fun.” It’s exactly the abstraction gap that traditional application UIs exist to bridge — and MCP tool design has to bridge it too.

General remedies applied in the revised tool:

  • Simplify tool parameters. Replace opaque backend IDs with a small, self-describing enum.
  • Support fuzzy/human-friendly units. Half day / full day instead of exact clock times.
  • Provide user-friendly abstractions that the tool itself translates into backend-specific values.
  • Give clear instructions in the tool description.
  • Wrap error messages with more context so the model (and, transitively, the user) understands what went wrong.
[McpServerToolType]
public class RequestTimeOffTool(IHrmAbsenceApi hrmAbsenceApi)
{
    [McpServerTool(UseStructuredContent = true, Title = "Request Time Off"),
    Description($"""
    Use this tool to request time off for an employee.
    Provide the employee ID, time off type, and the days requested.
    Acceptable time off types are: {nameof(TimeOffRequestType.Vacation)}, {nameof(TimeOffRequestType.PersonalHoliday)}, {nameof(TimeOffRequestType.SickDay)}, {nameof(TimeOffRequestType.MedicalOrFMLALeave)}, {nameof(TimeOffRequestType.PersonalLeaveOfAbsence)}, {nameof(TimeOffRequestType.Sabbatical)}.
    This method can only support one type of time off per request, but you can submit multiple requests with different types if needed.
    Days do not need to be consecutive but should not fall on weekends (Sat/Sun).
    You can prompt the employee to attach their work calendar or planned time off to check if any dates overlap with scheduled company holidays or existing time off and warn them.
    Remind the employee if they are requesting a Personal Holiday that there is a limit to how many they can take per year per company policy.
    Before calling, make sure to summarize the dates and requested time off type clearly.
    """)]
    public async Task<TimeOffResponse> RequestTimeOff(string employeeId, SimpleTimeOffRequest request, CancellationToken cancellationToken)
    {
        try
        {
            var eligibleAbsenceTypes = await hrmAbsenceApi.GetEligibleAbsenceTypesAsync(employeeId, "not_used", cancellationToken);
            var absenceRequest = BuildTimeOffRequestFromSimpleRequest(request, eligibleAbsenceTypes.AbsenceTypes);
            var response = await hrmAbsenceApi.RequestTimeOffAsync(employeeId, absenceRequest, cancellationToken);
            return response;
        }
        catch (RestEase.ApiException apiException)
        {
            if (apiException.StatusCode == System.Net.HttpStatusCode.NotFound)
            {
                throw new McpException(
                    "Failed to request time off. Did the employee provide the correct ID?", apiException);
            }

            throw new McpException(
                $"Failed to request time off. The response was: {apiException.Content}", apiException);
        }
        catch (Exception ex)
        {
            throw new McpException("An unexpected error occurred while requesting time off.", ex);
        }
    }
}

public enum TimeOffDayType { FullDay, HalfDayMorning, HalfDayAfternoon }

public record SimpleTimeOffRequest(
    SimpleTimeOffDay[] Days,
    TimeOffRequestType TimeOffType = TimeOffRequestType.Vacation);

public record SimpleTimeOffDay(DateTime Date, TimeOffDayType DayType);
public enum TimeOffRequestType
{
    Vacation, PersonalHoliday, SickDay, MedicalOrFMLALeave, PersonalLeaveOfAbsence, Sabbatical
}

public static class TimeOffRequestTypeExtensions
{
    public static string ToAbsenceTypeCode(this TimeOffRequestType requestType) => requestType switch
    {
        TimeOffRequestType.Vacation => "VACATION",
        TimeOffRequestType.PersonalHoliday => "FLEX_DAY",
        TimeOffRequestType.SickDay => "SICK_LEAVE",
        TimeOffRequestType.MedicalOrFMLALeave => "MEDICAL_LEAVE",
        TimeOffRequestType.PersonalLeaveOfAbsence => "LEAVE_OF_ABSENCE",
        TimeOffRequestType.Sabbatical => "X_00SABBATICAL",
        _ => throw new ArgumentOutOfRangeException(nameof(requestType), requestType,
            $"Could not determine the time off type. Acceptable values are: {string.Join(", ", Enum.GetNames<TimeOffRequestType>())}")
    };
}

Enums serialize as readable strings in the schema, which is much easier for a model to reason about than an opaque numeric ID. The tool internally maps the friendly enum to backend absence-type codes and half/full-day values to concrete start/end times.

Re-running the same conversation with the improved tool: Claude now only needs the employee ID, the time-off type, and whether it’s a half or full day — it no longer asks for an opaque type ID because the enum values are self-explanatory, and it proactively reminds the employee about policy limits on personal holidays (because that’s spelled out in the tool description). The request succeeds and returns a request ID. If the employee makes a typo in their ID, the LLM still infers overall intent correctly, submits the request, and this time gets back a descriptive error message — thanks to the McpException wrapping — instead of an opaque 404.

The MCP tools/call response schema supports an isError flag for exactly this purpose: if true, the content array should contain a human/LLM-readable error explanation. The C# SDK automatically wraps thrown exceptions into this shape.

Tools with Embedded Resources

A tool doesn’t have to return only one flavor of content. Consider: an employee asking about the next scheduled holiday still fails today unless they manually attach the calendar resource first, because the model has no proactive way to reach for it. Embedding resources directly inside a tool’s response lets the model pull in relevant context automatically, driven by the conversation rather than requiring a manual attachment step.

A new Get Time Off tool is introduced as a thin layer over the resources already built: it returns both the hardcoded office calendars and the employee’s own planned-time-off calendar (fetched from the backend), all embedded directly in the tool result.

// Returns an array of content blocks: a text summary plus two EmbeddedResourceBlock entries
return [
    new TextContentBlock { Text = "..." },
    new EmbeddedResourceBlock { Resource = new TextResourceContents { Uri = CalendarResources.ResourceWorkCalendarUri, MimeType = "application/json", Text = CalendarResources.WorkCalendarsResource() } },
    new EmbeddedResourceBlock { Resource = new TextResourceContents { Uri = employeeCalendarUri, MimeType = "application/json", Text = employeeCalendarResource } }
];

Because a ReadResourceResult and a tool’s content array share the same underlying content schema types (text or binary blob), a tool can simply reuse the exact resource methods built earlier rather than duplicating logic. Asking the same holiday question now works without requiring a manual resource attachment: the model is prompted for the employee ID, calls the tool, and the response contains both calendars needed to answer the question — plus a summary of the employee’s own planned time off around that date.

There’s a subtlety worth calling out: Globomantics is a global company, and asking “when is my next holiday” without more context risks the model silently assuming the wrong office location. Adding the employee’s actual location (fetched from the backend) as another text content block in the same response fixes this — the tool now correctly distinguishes a US-based employee from an India-based one.

Asking a follow-up like “am I eligible for a sabbatical” still fails at this point, though, because policy documents aren’t part of this tool’s response yet — which motivates the next enhancement, linked resources.

Tools with Linked Resources

The Get Time Off tool has quietly evolved — first it needed employee details, now it needs to answer benefit-policy questions too. This is a natural and expected part of iterating on an MCP server: requirements shift once real usage patterns emerge, just like any other kind of software. The tool is renamed to reflect its broadened purpose: Plan Time Off.

To answer eligibility questions (e.g., “sabbaticals require 7+ years of tenure,” per the policy document), two things are needed:

  1. Authoritative eligibility data — the backend API already encodes these business rules and can return the employee’s actual eligible absence types as a text content block.
  2. A pointer to the relevant policy document, using a resource link content block rather than embedding the entire document.
flowchart LR
    Tool["Plan Time Off tool call"] --> Text["Text: eligible absence types (from API)"]
    Tool --> Link["ResourceLink: globomantics://hrm/documents/{policyDoc}"]
    Link -.on demand.-> Client["Client reads resource separately"]

In the Inspector, a resource link in a tool result carries no embedded content — just a URI hint for the client to resolve later, if it chooses to.

Whether this actually improves the user experience depends entirely on client support. Claude Desktop, at the time of this course, does not support resource links — only embedded resources. Asking about sabbatical eligibility, Claude claims the employee is not eligible and even fabricates a justification (a “hire date” that was never actually returned in the tool result, and that changes on repeated attempts) — a textbook hallucination. Checking server logs confirms no resources/read request was ever sent; pressed further, the model admits it invented the explanation.

VS Code with GitHub Copilot in agent mode fares better in one respect (it does support resource links and can list MCP tools directly in its UI), but with the same underlying model it may still hallucinate on the first try. On a retry it succeeds, correctly reads the linked policy document, and even surfaces a detail not otherwise available (e.g., personal holidays not being subject to accrual limits, so an employee can still take up to two even after using all vacation hours). The backend API remains the actual source of truth and would reject an invalid submission regardless of what the model says — but the richer context materially improves the employee’s understanding of their real options.

ClientEmbedded resourcesResource links
Claude Desktop❌ (not supported — will hallucinate rather than fetch)
VS Code (Copilot agent mode)

Context Engineering and Token Economics

Context engineering means delivering the right information, at the right time, in the right format, to be consumed efficiently by an LLM. This isn’t a new class of problem — it rhymes with decades of designing around inconsistent browser capabilities on the web — but MCP is young enough that clients don’t yet implement the specification uniformly, so you must design defensively around that reality.

A first, low-effort mitigation for the Claude hallucination above: explicitly instruct the model in the tool description about what to do if policy context is missing (rather than silently guessing). This measurably reduces (but doesn’t eliminate) hallucinated answers.

A tempting next step — embedding the entire policy PDF directly in the tool response so clients without resource-link support still get the content — runs into two real problems:

  1. Client-side format support. Claude Desktop doesn’t correctly interpret an embedded PDF resource; it misidentifies it as an image.
  2. Context window / token cost. Even where it works, a raw Base64-encoded PDF is enormous. Using Anthropic’s token-counting API against a real time-off policy PDF:
import Anthropic from '@anthropic-ai/sdk';
import { readFileSync } from 'fs';

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const pdfBase64 = readFileSync('pdfs/Globomantics_Vacation_TimeOff_Policy.pdf', { encoding: 'base64' });

const response = await client.messages.countTokens({
  model: 'claude-sonnet-4-5',
  messages: [{
    role: 'user',
    content: [
      { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: pdfBase64 } },
      { type: 'text', text: 'Please summarize this document.' }
    ]
  }]
});

console.log(response);

Running this reports roughly 13,000 tokens for a single policy document — an expensive line item, since every token costs money for the end user (or their organization). Just as web development optimizes for page-load and time-to-interactive, MCP server design should optimize for token usage as a first-class concern.

Step 1 — convert to plain text. Extracting the PDF’s text (rather than shipping the raw binary) requires a small third-party PDF text-extraction library, but shrinks the response dramatically:

public async Task<string> GetBenefitPlanDocumentContentAsPlainTextAsync(string documentId, CancellationToken cancellationToken)
{
    var containerClient = blobServiceClient.GetBlobContainerClient(containerName);
    var blobClient = containerClient.GetBlobClient(documentId);

    var exists = await blobClient.ExistsAsync(cancellationToken);
    if (exists.Value == false) return "";

    var downloadResult = await blobClient.DownloadContentAsync(cancellationToken);
    using var stream = downloadResult.Value.Content.ToStream();
    using PdfDocument document = PdfDocument.Open(stream);

    var pdfText = new StringBuilder();
    foreach (Page page in document.GetPages())
    {
        var words = page.GetWords(NearestNeighbourWordExtractor.Instance);
        foreach (var word in words)
        {
            pdfText.Append(word.Text);
            pdfText.Append(' ');
        }
    }

    return pdfText.ToString();
}

Re-measuring token usage on the extracted plain text shows roughly a 10x reduction versus the Base64-encoded PDF — and with the plain-text version in the tool response, Claude correctly identifies the employee as eligible for a sabbatical without hallucinating.

Step 2 — go further with semantic (vector) search. Employees might ask about any benefit policy, not just time off — parental leave, then follow-up questions about newborn coverage, dental, vision, retirement, and so on. Embedding every policy document (even as plain text) doesn’t scale. The better approach: Azure AI Search with a vector index built over all the policy documents, queried semantically for just the passage relevant to the current question.

private async IAsyncEnumerable<ContentBlock> ProvideRelevantPlanExcerptsAsync(string queryText, int k, [EnumeratorCancellation] CancellationToken cancellationToken)
{
    var searchOptions = new SearchOptions
    {
        Size = k,
        Select = { "title", "chunk_id", "chunk" },
        IncludeTotalCount = true,
        VectorSearch = new()
        {
            Queries = { new VectorizableTextQuery(text: queryText) { KNearestNeighborsCount = k, Fields = { "text_vector" }, Exhaustive = false } }
        }
    };

    var searchResults = await searchClient.SearchAsync<SearchDocument>(null, searchOptions, cancellationToken: cancellationToken);

    if (searchResults.HasValue)
    {
        await foreach (var result in searchResults.Value.GetResultsAsync())
        {
            yield return new TextContentBlock { Text = $"Document ID: {result.Document["title"]}\n---\n{result.Document["chunk"]}" };
        }
    }
}

A new, dedicated Ask About Policy tool exposes this capability directly, so employees can ask any benefit question at any point in the conversation, not only within the time-off planning flow:

[McpServerToolType]
public class AskAboutPolicyTool(SearchClient searchClient)
{
    [McpServerTool(Title = "Ask About Policy"), Description(
        """
        Help Globomantics employees answer questions about benefits policies such as medical, dental, vision, and retirement plans.
        The Plan Time Off tool is better for answering questions about sabbaticals, leave, and vacation.
        This tool returns relevant excerpts from the benefit policy documents to inform your response.
        If the tool result is not supported or is missing specific policy information, NEVER try to assume and inform the user that you cannot provide an answer,
        and that they may need to contact HR for further assistance.
        """)]
    public async Task<IEnumerable<ContentBlock>> AskAboutPolicy(
        [Description("The relevant policy type the user is asking about")] PolicyQuestionType policyQuestionType,
        CancellationToken cancellationToken)
    {
        var contentBlocks = new List<ContentBlock> { new TextContentBlock { Text = "Here are some relevant time off policy document excerpts to help answer:" } };

        var question = policyQuestionType switch
        {
            PolicyQuestionType.VacationOrHolidays => "vacation or holidays",
            PolicyQuestionType.SickLeave => "sick leave",
            PolicyQuestionType.FamilyLeave => "FMLA",
            PolicyQuestionType.PersonalLeaveOfAbsence => "personal leave of absence",
            PolicyQuestionType.Sabbatical => "sabbatical",
            PolicyQuestionType.MedicalBenefits => "medical benefits",
            PolicyQuestionType.VisionBenefits => "vision benefits",
            PolicyQuestionType.DentalBenefits => "dental benefits",
            PolicyQuestionType.RetirementPlans => "retirement plans",
            _ => throw new ArgumentOutOfRangeException(nameof(policyQuestionType), policyQuestionType, null)
        };

        await foreach (var excerptBlock in ProvideRelevantPlanExcerptsAsync(question, 1, cancellationToken))
        {
            contentBlocks.Add(excerptBlock);
        }

        return contentBlocks;
    }
}

With vector search in place, the tool only returns the excerpt relevant to the specific question asked (e.g., only the sabbatical section, not the whole time-off policy), bringing typical responses down to under 1,000 tokens — a huge reduction versus the original full-document approach, while simultaneously improving answer relevance.

ApproachApprox. tokensRelevanceClient compatibility
Embed full PDF (Base64)~13,000Low (whole document)Poor (some clients mis-render PDFs)
Embed plain text extract~1,300 (10x smaller)Medium (whole document)Good
Vector search excerpt< 1,000High (targeted passage)Good

The underlying principle: context engineering is about relevant information, not all information.

Introducing Prompts

Constantly re-explaining the same request to an AI tool gets old fast — and end users of a distributed MCP server shouldn’t need to be prompt engineers to discover what it can do. Prompts solve this by letting a server author pre-package reusable prompt templates.

The first prompt simply invokes the Plan Time Off tool to suggest good days to take off:

[McpServerPromptType]
public static class TimeOffPrompts
{
    [McpServerPrompt(Title = "Suggest Time Off Work", Name = "Suggest Time Off Work")]
    [Description("Uses the planning tool to find ideal dates for time off work, taking into account company holidays and weekends.")]
    public static string SuggestTimeOffPrompt(
        [Description("Your employee ID (e.g. '4562')")]
        int employeeId)
    {
        return $"Using the Globomantics time off planning tool, please suggest some good dates for my next vacation, such as 3- or 4-day weekends. My employee ID is {employeeId}.";
    }
}

Prompts sit conceptually between tools and resources: like tools, they accept arguments; like resources, they are user- or application-initiated, not model-initiated. In Claude, prompts appear alongside resources in the context menu — selecting one attaches it like a resource before the user sends it off. In VS Code, prompts surface as slash commands: selecting one pops up a small form to fill in arguments (like the employee ID), then inserts the resulting text directly into the prompt input box — arguably a better UX since the user can still edit before sending.

In the tools/list/prompts/list schema, prompt arguments have a much more limited shape than tool parameters — there is no declared type, every argument is effectively a string. This has a real gotcha: optional numeric-looking arguments (like a “year”) still arrive as an empty string rather than null when omitted by the client, and an empty string doesn’t automatically convert to null in C#. The safe pattern is to always treat prompt arguments as raw strings and perform your own validation/parsing, rather than relying on automatic type coercion for optional parameters.

Prompt Roles and Multi-part Messages

Beyond a single string, a prompt can return an array of prompt messages, each with a role (assistant vs. user) and a content block — text, image, audio, or embedded resource (prompts do not support resource links, unlike tools).

The next prompt automatically attaches the relevant office calendar and pre-fills the employee’s location:

[McpServerPromptType]
public static class CalendarPrompts
{
    [McpServerPrompt(Title = "Next Scheduled Work Holiday", Name = "Next Scheduled Work Holiday")]
    [Description("Finds the next scheduled work holiday for where you work.")]
    public static IEnumerable<PromptMessage> GetNextScheduledHoliday(
        [Description($"Your work location: {nameof(WorkLocation.UnitedStates)} or {nameof(WorkLocation.India)}")]
        WorkLocation employeeLocation,

        [Description("The work year to use for the calendar. If not provided, the current year is used.")]
        string? workYear = null)
    {
        var year = string.IsNullOrWhiteSpace(workYear) ? DateTime.Now.Year : int.Parse(workYear);

        yield return new PromptMessage
        {
            Role = Role.Assistant,
            Content = new TextContentBlock { Text = "You are an expert HR assistant helping employees understand the office work schedule. Attached is the employee's location holiday calendar. Use this information to answer questions about scheduled holidays." }
        };

        yield return new PromptMessage
        {
            Role = Role.Assistant,
            Content = new EmbeddedResourceBlock
            {
                Resource = new TextResourceContents
                {
                    MimeType = "application/json",
                    Uri = CalendarResources.ResourceWorkByLocationCalendarUri.Replace("{year}", year.ToString()).Replace("{location}", employeeLocation.ToString()),
                    Text = CalendarResources.WorkCalendarByLocationResource(year, employeeLocation)
                },
            }
        };

        yield return new PromptMessage
        {
            Role = Role.User,
            Content = new TextContentBlock { Text = "When is my next scheduled work holiday?" }
        };
    }
}

The Role field is a hint to the client about who a piece of text is meant for (assistant instructions vs. the end user’s own words); the specification itself does not dictate what a client must do with that hint. In practice, Claude simply attaches the entire message array as-is, while VS Code goes further, using the role metadata to format the conversation with a proper assistant preface followed by the user’s message — potentially easier for the model to interpret correctly, though never guaranteed.

sequenceDiagram
    participant User
    participant Client as MCP Client (VS Code/Claude)
    participant Server as MCP Server
    participant LLM

    User->>Client: Selects "Next Scheduled Work Holiday" prompt
    Client->>Server: prompts/get (workLocation, workYear)
    Server-->>Client: [assistant: instructions, assistant: embedded calendar resource, user: question]
    Client->>LLM: Send formatted conversation
    LLM-->>User: Answer using embedded calendar

Think of prompts as user-invoked tools: they accept arguments, can call the same resource-producing code as everywhere else, and hand the model everything it needs to answer or take further action — a powerful capability that builds directly on top of resources and tools.

Distributing Local MCP Servers

Local development configuration (a raw dotnet run command pointed at a source checkout) doesn’t work once you want other people to use your server — they won’t have the same project path, or even the source code, on their machine. Distribution options vary by ecosystem:

Distribution methodRequires on end-user machineTypical ecosystem
npm package (npx)Node.jsJavaScript/TypeScript servers
Docker imageDocker or Podman runtimeAny language
NuGet package (dnx).NET 10 SDK (for dnx).NET/C# servers
Standalone self-contained executableNothing (all dependencies bundled)Any language, manual distribution

A single MCP server doesn’t have to be packaged using the same technology it was written in — a real-world example is a well-known Azure MCP Server written in .NET/C# but published simultaneously via npm, Docker, NuGet, and even as a VS Code extension, to reach the broadest possible audience.

Publishing via NuGet requires an .mcp/server.json manifest describing the package for the MCP registry:

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
  "description": "A sample MCP server demonstrating the HRM integration built in this course",
  "name": "io.github.example/globomantics-mcp-in-practice",
  "version": "0.1.0-beta",
  "packages": [
    {
      "registryType": "nuget",
      "identifier": "Globomantics.Mcp.DemoServer",
      "version": "0.1.0-beta",
      "transport": { "type": "stdio" },
      "environmentVariables": [
        { "name": "AZURE_TENANT_ID", "type": "userPrompt", "isRequired": true },
        { "name": "HRM_API_AAD_CLIENT_ID", "type": "userPrompt", "isRequired": true },
        { "name": "HRM_API_ENDPOINT", "type": "userPrompt", "isRequired": true },
        { "name": "MCP_SERVER_AAD_CLIENT_ID", "type": "userPrompt", "isRequired": true },
        { "name": "MCP_SERVER_AAD_CLIENT_SECRET", "type": "userPrompt", "isRequired": true, "isSecret": true }
      ]
    }
  ],
  "repository": { "url": "https://github.com/example/globomantics-mcp-in-practice", "source": "github" }
}

The project file needs matching NuGet packaging metadata:

<PropertyGroup>
  <PackAsTool>true</PackAsTool>
  <PackageType>McpServer</PackageType>
  <PublishSelfContained>true</PublishSelfContained>
  <PackageReadmeFile>README.md</PackageReadmeFile>
  <PackageId>Globomantics.Mcp.DemoServer</PackageId>
  <PackageVersion>0.1.0-beta</PackageVersion>
  <PackageTags>AI; MCP; server; stdio</PackageTags>
</PropertyGroup>

<ItemGroup>
  <None Include=".mcp\server.json" Pack="true" PackagePath="/.mcp/" />
  <None Include="../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

Running dotnet pack produces a set of .nupkg files, one per supported runtime identifier (Windows x64/ARM64, macOS ARM64, Linux x64/ARM64/musl). Publishing them to a registry makes the server publicly (or privately) discoverable, just like an npm-distributed server. Once published, a client’s mcp.json entry no longer needs a local file path — instead, the MCP schema’s inputs/environment-variable prompts let VS Code (or another client) interactively collect required configuration values (tenant ID, client IDs/secrets, etc.) the first time the server starts, storing them locally without needing to hand-edit the configuration file.

For distribution scenarios where end users shouldn’t need any platform SDK installed at all, a self-contained, standalone executable build (dotnet publish with PublishSingleFile/SelfContained) removes even the .NET runtime dependency — at the cost of needing to distribute the binary manually (e.g., via an internal software catalog or a download page), rather than through a package registry.

This module built out a complete local MCP server — resources, resource templates, tools (with structured content, embedded resources, and linked resources), context-engineered responses, and prompts — and covered how to package it for distribution. One glaring gap remains, though: every handler so far accepts an employeeId parameter supplied by the model, meaning the server will happily fetch or mutate any employee’s confidential data on request — a serious security hole that the next module addresses by adding real user authentication.

Module 3: Remote MCP Servers

Local vs. Remote MCP Servers

Everything built so far runs on the user’s own machine, over a standard I/O transport — and that leaves a fundamental problem: there’s no way to verify who the user actually is. Standard I/O has no built-in authentication method, so the server accepts anonymous connections and, in turn, has to call back-end APIs using a fixed system identity rather than the actual signed-in employee. Custom authentication could be bolted on top of standard I/O, but the MCP specification instead builds in support for OAuth-based authorization flows, which enables proper single sign-on and delegated authentication. Using OAuth requires switching the transport from standard I/O to streamable HTTP first.

A common misconception is that streamable HTTP automatically implies a remotely hosted server. It doesn’t — a local MCP server can perfectly well use streamable HTTP over a localhost URL in the client configuration. The real distinguishing factor between “local” and “remote” is where the process runs, not which transport it uses:

AspectLocal MCP serverRemote MCP server
Runs onThe user’s own machineA separate/hosted machine
TransportStandard I/O or streamable HTTPStreamable HTTP only
Access to local resources (files, etc.)YesNo
Client configurationNeeds environment variables / a way to get the server binary onto the machineSimple — just a URL, no local install
Deployment / web hosting requiredNo (beyond what your back-end APIs already need)Yes — a deployment model and a web host that supports streamable HTTP
LatencyVery low (in-process / localhost)Network round-trip
Can be protected with OAuthYesYes

There is no universally “best” hosting model — it’s a tradeoff, much like choosing between a web app, a mobile app, or a desktop app. For the running HRM example, there’s no need to touch the employee’s local file system, so hosting the server remotely makes sense, and that’s the direction the rest of the module takes.

flowchart TD
    A{Does the server need local machine access?} -->|Yes, e.g. files, local tools| B[Local MCP Server]
    A -->|No, purely calls back-end APIs| C[Remote MCP Server]
    B --> D[Standard I/O or Streamable HTTP]
    C --> E[Streamable HTTP only]
    D --> F[Distribute via npm/Docker/NuGet/self-contained binary]
    E --> G[Deploy to a web host / cloud provider]

Switching to the Streamable HTTP Transport

Deploying remotely first requires turning the console application into a web application. Any web framework that integrates with the MCP specification can host a server over HTTP — Express for a TypeScript server, or FastMCP for Python, for example. The .NET SDK integrates natively with ASP.NET Core, the standard way to build .NET web apps today. The migration from a standard I/O console app to an HTTP-hosted one involves a handful of concrete changes:

  1. Add the MCP ASP.NET Core hosting package to the project.
  2. Change the project file to use the Web SDK instead of the default console SDK, so .NET treats it as a web application.
  3. Replace the empty application builder with WebApplication.CreateBuilder, which also wires in user-secrets and environment-variable configuration by default.
  4. Remove the standard-output redirection that standard I/O required.
  5. Replace the standard I/O server transport registration with .WithHttpTransport().
  6. Call MapMcp() to expose the MCP endpoint(s). By default the server listens at the root path, but a dedicated path such as /mcp can be specified — useful if other website routes need to live alongside the MCP endpoint.
  7. Add a health-check endpoint. This is useful both for confirming the server is alive during development and for automated availability monitoring later.
var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

app.MapMcp("/mcp");
app.MapGet("/healthz", () => Results.Ok("healthy"));

app.Run();

The MCP Inspector’s package.json configuration also changes: instead of passing a command to launch (as with standard I/O), it points at a config file describing the streamable HTTP endpoint:

{
  "mcpServers": {
    "globomantics-hrm": {
      "type": "streamable-http",
      "url": "http://localhost:5000/mcp"
    }
  }
}

ASP.NET Core applications default to port 5000. Running npm start (wrapping dotnet run) starts the web server; keeping that running and opening a separate terminal to run npm run dev starts the Inspector pointed at the new config. Everything works exactly as before — list tools, call tools, browse resources — except now, because it’s a real HTTP server, the browser’s own developer tools can be used to inspect the raw JSON-RPC requests and responses flowing between the Inspector and the server, instead of only being able to see the standard I/O stream.

sequenceDiagram
    participant Inspector as MCP Inspector (Browser)
    participant Server as MCP Server (ASP.NET Core)

    Inspector->>Server: POST /mcp (JSON-RPC request)
    Server-->>Inspector: JSON-RPC response
    Note over Inspector,Server: Visible directly in browser DevTools Network tab

Choosing a Hosting Model

Once the server is a real web application, it can be hosted anywhere from a bare-metal VM to a fully managed Platform-as-a-Service or Function-as-a-Service offering. Azure Functions supports MCP server hosting and is used for the demo in this module, but the same general considerations apply to any serverless/FaaS platform.

By default, a streamable HTTP MCP server is stateful: when a client connects, the server keeps an open connection so it can stream data back to the client even without an explicit request — conceptually similar to how standard I/O is itself a persistent, stateful stream. Looking at the official streamable HTTP sequence, a server has two operating modes:

  • Stateless mode — the client sends a request, the server sends back a single response, and that’s it.
  • Stateful mode — a server-sent-events (SSE) stream stays open, allowing the server to emit notifications or other messages related to the request before eventually sending the final response. This unlocks more advanced protocol features such as elicitations, but it is technically optional.

Serverless runtimes such as Azure Functions, AWS Lambda, and Google Cloud Run are, by design, stateless — they are meant to spin up quickly, handle a single request, and spin back down — so they don’t (yet) support the stateful streamable HTTP mode. Hosting an MCP server on one of these platforms typically requires explicitly switching the transport to stateless mode to remain compatible. One notable exception is Cloudflare Workers: its built-in MCP agent support is backed by Durable Objects with SQL-backed state, allowing it to support stateful MCP servers — but only for JavaScript/TypeScript-based servers.

Hosting optionStateful HTTP supportNotes
Bare-metal / VMYesFull control, most operational overhead
Azure FunctionsNo (stateless mode required)Managed, serverless, official “Bring Your Own MCP Server” support
AWS Lambda / Google Cloud RunNo (stateless mode required)Serverless by design
Cloudflare Workers (with Durable Objects)YesJavaScript/TypeScript only
sequenceDiagram
    participant Client
    participant Server

    rect rgb(230,230,250)
    Note over Client,Server: Stateless mode
    Client->>Server: Request
    Server-->>Client: Single response
    end

    rect rgb(220,240,220)
    Note over Client,Server: Stateful mode
    Client->>Server: Request
    Server-->>Client: SSE stream opens
    Server-->>Client: Notification / progress message(s)
    Server-->>Client: Final response
    end

Deploying the MCP Server to Azure Functions

The demo follows a published pattern for bringing an existing MCP server to Azure Functions (a preview feature at the time of recording). A key benefit of this deployment model is that no server code has to change — the official MCP C# SDK works as-is on Azure Functions, though some additional infrastructure configuration is required.

An AI coding agent is used to automate that configuration: a prepared prompt (placed in a .github/prompts folder so it can be invoked from VS Code’s Chat “Run Prompt” command) walks a coding agent through preparing an existing MCP server for Azure Functions deployment. Running the prompt makes the agent apply the necessary project changes and summarizes what it did — a good example of an AI coding agent removing tedious, mechanical migration work.

Once the project is prepared, deployment uses the Azure Developer CLI (azd) — not to be confused with the plain Azure CLI (az), which manages existing resources directly rather than provisioning whole environments. Running azd up:

azd up

reads a set of Azure Bicep files (an Infrastructure-as-Code language, conceptually similar to Terraform or Pulumi but Azure-specific) and provisions all of the Azure infrastructure needed to host the function app, prompting only for an environment name and a data-center location. After provisioning completes, azd prints the new endpoint.

flowchart LR
    A[Existing local MCP server] --> B[AI coding agent applies Azure Functions prompt]
    B --> C[Bicep files updated]
    C --> D[azd up]
    D --> E[Azure Functions app provisioned + deployed]
    E --> F[/api/healthz responds 200/]
    E --> G[MCP endpoint responds HTTP 405 to plain GET — expected, not JSON-RPC]

Verification steps for the freshly deployed endpoint:

  1. Open the base URL in a browser — an HTTP 405 response is actually the expected result, since a plain browser GET request isn’t a JSON-RPC call.
  2. Hit the /api/healthz endpoint to confirm the app is alive.
  3. Point the MCP Inspector at the new remote URL (added as another entry in mcp.json) to fully verify: list tools, browse resources, call prompts. When more than one server is configured, an Inspector npm script needs a --server flag to pick which one to connect to, so a dev script targets the local server and a prod script targets the deployed one.
{
  "scripts": {
    "dev": "mcp-inspector --config mcp.json --server local",
    "prod": "mcp-inspector --config mcp.json --server prod"
  }
}

The Inspector connects successfully and can list tools/resources/prompts, but calling a tool that reads the benefit-policy documents fails — because the environment variables the server needs to reach the HRM API (as a system identity) were never configured on the deployed function app. Fixing that would technically make the demo “work,” but doing so would be a serious mistake: the server is still completely anonymous. There is no authentication in front of it at all, so wiring up working credentials on a publicly reachable endpoint would let anyone on the internet read the fictitious company’s employee data.

Instead, the right move at this stage is to tear the whole environment back down:

azd down

If a server is intentionally meant to be public, deployment is done at this point. But to build a genuinely protected MCP server that acts on behalf of the authenticated employee (rather than a shared system identity), authentication has to be added first — which is exactly what the next module covers.

Module 4: Securing MCP

Why MCP Needs OAuth

Securing an MCP server isn’t fundamentally different from securing a traditional web app or desktop application — but the constraints are a little different. Traditional web apps typically authenticate users with a username/password pair, then track the session with a cookie or token. With AI tools, however, the user should never have to type a password that the underlying model could potentially see, so a passwordless flow is required. A simpler option — passing a static API key through an HTTP header — works for something like Azure Functions by default, but requires manually managing and mapping tokens to users.

Instead, the MCP specification builds in support for OAuth 2.1. OAuth already supports the kinds of scenarios enterprises need — federated identity, impersonation, single sign-on — making it a much better fit than a hand-rolled token scheme. This module focuses narrowly on how OAuth is used within the MCP specification itself — configuring an OAuth provider such as Microsoft Entra ID from scratch is a large topic in its own right and is not covered in depth here.

The approach uses the built-in authentication/authorization middleware in the MCP C# SDK, wired up against Microsoft Entra ID and tested through VS Code — no extra API proxies, no custom token storage. These are the fundamentals that apply regardless of which SDK or OAuth provider is used, and — fair warning — the flow essentially never works on the first try. Getting it working requires methodically testing and debugging each step of the handshake in turn.

flowchart TD
    A[Passwordless requirement: model must never see a password] --> B{Authentication approach}
    B -->|Static API key in header| C[Simple, but manual token/user mapping]
    B -->|OAuth 2.1| D[Federated identity, SSO, delegation - MCP spec native]
    D --> E[MCP C# SDK auth middleware + Microsoft Entra ID]

Building a Protected MCP Server

The first server-side requirement: when a request arrives without a token, the server must reply with an HTTP 401 response that includes a WWW-Authenticate header pointing the client at a Protected Resource Metadata (PRM) document. In ASP.NET Core, this is configured in the program entry point:

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultChallengeScheme = "Bearer";
    })
    .AddJwtBearer("Bearer", options =>
    {
        // Configures the WWW-Authenticate header format expected by MCP clients
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapMcp("/mcp").RequireAuthorization();

Note that AddAuthentication only configures authentication — the middleware still has to be added to the pipeline with UseAuthentication, and, separately, UseAuthorization needs to be added too (authentication and authorization are two distinct concerns). Finally, the MCP endpoint mapping itself needs RequireAuthorization() so it’s actually protected.

To test this locally under conditions close to the eventual cloud-hosted environment, the Azure Functions Core Tools are used:

func start

which serves the app at http://localhost:7071. A raw HTTP request against the MCP endpoint (using curl, Postman, or similar) — sending the very first kind of message introduced earlier in the course, a ping JSON-RPC request, but this time with no token — confirms the server correctly returns 401 Unauthorized along with a WWW-Authenticate header.

Looking closely at that header reveals a subtlety: the resource metadata URL points at 127.0.0.1 with a seemingly random port, rather than localhost:7071. This is a side effect of how Azure Functions actually works: your application code runs inside an isolated worker process/container fronted by the Functions host runtime, which proxies external requests to your app via a single internal binding. By default your app only ever sees that internal host, not the original external request URL — the same class of problem shows up behind any reverse proxy or load balancer, not just Azure Functions. The standard fix is to have the proxy forward the original request details via X-Forwarded-Host / X-Forwarded-Proto headers (which Azure Functions already does) and to configure ASP.NET Core’s forwarded headers middleware to trust and apply them:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
});

var app = builder.Build();
app.UseForwardedHeaders();

After restarting, the resource metadata URL correctly matches the externally visible server URL, clearing the way to actually implement the PRM document itself.

OAuth Step 1: Protected Resource Metadata

Per the OAuth 2.1 specification, an MCP client extracts the resource metadata URL from the WWW-Authenticate header and expects to find a Protected Resource Metadata JSON document at a well-known path (/.well-known/oauth-protected-resource). This document tells the client where the OAuth authorization server lives, so it can drive the user through a browser-based login flow.

In the C# SDK, this is configured through an AddMcp callback where MCP-specific authentication options — including the absolute URL of the metadata document itself — are set:

var mcpServerUrl = Environment.GetEnvironmentVariable("MCP_SERVER_EXTERNAL_URL")
    ?? (builder.Environment.IsDevelopment() ? "http://localhost:7071" : throw new InvalidOperationException());

builder.Services.AddMcpServer()
    .WithHttpTransport()
    .AddMcp(options =>
    {
        options.ResourceMetadata = new ProtectedResourceMetadata
        {
            Resource = new Uri($"{mcpServerUrl}/mcp"),
            AuthorizationServers = { new Uri("https://login.microsoftonline.com/{tenant-id}/v2.0") }
        };
    });

In development, the URL intentionally uses plain HTTP rather than HTTPS, because the local server is running behind a self-signed certificate that MCP clients wouldn’t trust; production traffic uses a properly signed SSL certificate.

Testing this step doesn’t require crafting raw HTTP requests — the Inspector has a dedicated Open Auth Settings screen that walks through the entire OAuth flow end to end, including a Network tab so every JSON-RPC and OAuth-related HTTP call can be observed directly in the browser’s developer tools. Clicking Continue to run metadata discovery, however, surfaces a wall of failed (red) network requests: because the MCP server runs on a different port than the Inspector web app itself, the browser’s Cross-Origin Resource Sharing (CORS) policy blocks the calls by default. If a server doesn’t explicitly define a CORS policy, browsers treat that as “deny all cross-origin requests” — exactly what’s happening here. The fix is to add an explicit CORS policy that allows the Inspector’s origin, including the MCP-specific request headers and (since authentication is involved) credentials:

builder.Services.AddCors(options =>
{
    options.AddPolicy("McpInspector", policy =>
    {
        policy.WithOrigins("http://localhost:6274") // Inspector dev server origin
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();
    });
});

// ...

app.UseCors("McpInspector");

Under Azure Functions this CORS policy only needs to be applied in development — once deployed, CORS is instead managed at the infrastructure level. With the policy in place, retrying the metadata-discovery step succeeds, and the client now knows where to send the user to log in.

OAuth Step 2: Client Registration

Armed with the authorization-server metadata, the client next needs to build a login URL — the client registration step. Retrying at this point in the demo fails again, this time because Microsoft Entra ID doesn’t support Dynamic Client Registration (DCR), which is the mechanism the Inspector tries by default.

The MCP specification allows for two different registration approaches:

Registration modelHow it worksTypical use
StaticA fixed, pre-registered client ID is configured out of band, once, for each known client (dev/staging/prod web app, native iOS/Android app, etc.)Small, known set of clients
Dynamic (DCR)Clients register themselves programmatically at connect time, without prior coordinationLarge/unknown set of clients — the norm for AI tools and autonomous agents, where every AI client or custom MCP client may need to authenticate on a user’s behalf without ever being registered in advance

Supporting DCR requires the OAuth provider to implement extra registration infrastructure, and even though DCR has been part of the OAuth specification for years, not every provider implements it — Microsoft Entra ID is one that doesn’t. Since DCR isn’t strictly required by the MCP spec, a static client ID (representing the MCP server’s own app registration in Entra ID) works fine for demonstration purposes and can be entered directly in the Inspector’s authentication settings sidebar. Retrying the client-registration step with that static client ID succeeds and prints the registered client ID back.

OAuth Step 3: The Authorization Code and Token Exchange

The next step drives the actual authorization code flow — the familiar login/consent experience end users go through, resulting in the client receiving an authorization code. (One caveat from the original walkthrough: the MCP Inspector’s OAuth implementation needed a manual patch to work correctly against Entra ID specifically — a reminder that, in practice, specifications and real-world provider behavior don’t always line up perfectly. Other MCP-compatible authorization servers should not require this kind of workaround.)

The flow, as exercised through the Inspector:

  1. Click Continue to prepare the login URL, then open it — this launches the identity provider’s real login page in a new tab, where the demo signs in as a mock employee (not an administrator account).
  2. After granting consent, the provider returns an authorization code, which is copied and pasted back into the Inspector’s OAuth flow screen.
  3. Clicking Continue submits the authorization code; clicking Continue again exchanges it for an access token.
  4. The returned access token is a JWT (JSON Web Token), Base64-encoded and Bearer-formatted. From this point forward, the client attaches it to every request via an Authorization: Bearer <token> header.
sequenceDiagram
    participant User
    participant Inspector as MCP Client (Inspector)
    participant IdP as Microsoft Entra ID
    participant Server as MCP Server

    Inspector->>IdP: Redirect user to login/consent page
    User->>IdP: Authenticate + grant consent
    IdP-->>Inspector: Authorization code
    Inspector->>IdP: Exchange code for access token
    IdP-->>Inspector: JWT access token (Bearer)
    Inspector->>Server: Request + Authorization: Bearer <JWT>
    Server->>Server: Validate issuer (iss) + audience (aud)
    Server-->>Inspector: Authorized response

The JWT itself can be inspected and decoded using any JWT-debugging tool that understands Microsoft-issued tokens, to confirm the iss (issuer) claim matches Microsoft Entra ID and the aud (audience) claim matches the MCP server. On the server side, JWT Bearer validation is configured as follows:

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.Authority = "https://login.microsoftonline.com/{tenant-id}/v2.0";
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = "https://login.microsoftonline.com/{tenant-id}/v2.0",
        ValidateAudience = true,
        ValidAudience = "{mcp-server-client-id}"
    };
});

With this in place, clicking Connect in the Inspector succeeds — the end-to-end OAuth handshake now works. From then on, a Quick OAuth Flow shortcut replays all of the above steps automatically to fetch a fresh token, and a Clear OAuth State option resets everything to start over.

Accessing the Authenticated User in Tool Code

With the handshake working, the remaining question is how tool/resource/prompt handler code actually gets at the authenticated user. As with any ASP.NET Core middleware pipeline, the validated Bearer token is attached to the request context, and from there it can be read inside a handler. In TypeScript SDKs this typically looks like reading authInfo.extra off the request; in the C# SDK, the request context exposes a standard ASP.NET Core ClaimsPrincipal via a User property, from which claims like the user’s name or email can be read directly:

[McpServerTool, Description("Requests time off for the authenticated employee.")]
public async Task<CallToolResult> RequestTimeOff(
    RequestContext<CallToolRequestParams> context,
    TimeOffRequest request)
{
    var claimsPrincipal = context.Server.RequestContext.User;
    var employeeEmail = claimsPrincipal.FindFirstValue(ClaimTypes.Email);

    // ...
}

That ClaimsPrincipal, however, only represents the identity as known to Microsoft Entra ID — it still has to be mapped to a corresponding employee record in the back-end HRM system. The architecture used here relies on an On-Behalf-Of (OBO) flow (sometimes called impersonation or delegation): the incoming client access token is exchanged for a new, delegated access token that is then forwarded to the back-end HRM API, allowing that API to see and authorize the request as the actual signed-in employee rather than the MCP server’s own service identity.

var oboToken = await tokenAcquisition.GetAccessTokenForUserAsync(
    scopes: new[] { "api://hrm-api/.default" },
    user: context.Server.RequestContext.User);

var employee = await hrmApiClient.GetCurrentEmployeeAsync(oboToken);
sequenceDiagram
    participant Client as MCP Client
    participant Server as MCP Server
    participant Entra as Microsoft Entra ID
    participant HRM as HRM API

    Client->>Server: Request + Bearer token (user identity)
    Server->>Entra: On-Behalf-Of token exchange
    Entra-->>Server: Delegated access token (still represents the user)
    Server->>HRM: Call API with delegated token
    HRM-->>Server: Employee-scoped data
    Server-->>Client: Tool result

Every handler that previously needed an employeeId parameter supplied by the model can now instead call the back-end API directly and receive the currently authenticated employee’s own data — closing the security hole introduced back in Module 2. The Plan Time Off tool, tested this way, now correctly returns the actual signed-in Globomantics employee’s own details, rather than whichever employee ID the model happened to choose.

End-to-End Walkthrough: The Everything Demo

With authentication wired up end to end, the final demo redeploys the fully secured server. The Azure Bicep configuration is updated to also provision the Azure AI Search connection, azd up is run again, and the required environment variables are set on the deployed function app. VS Code’s mcp.json is updated to point at the new remote endpoint — VS Code has built-in support for Microsoft Entra ID, so no manual client ID configuration is required there. Clicking Start on the connection prompts a real login, using the same mock employee account as before.

With Copilot in agent mode, the walkthrough exercises the complete integration:

  1. Invoking the Next Scheduled Work Holiday prompt no longer needs to ask for a work location — the server now knows the employee’s location and automatically attaches the correct calendar resource, based on the authenticated identity rather than a manually supplied parameter.
  2. Invoking the Plan Time Off tool likewise no longer requires an employee ID — the agent already knows who’s asking, and suggests optimal days off (for example, combining a company holiday with surrounding weekends into a longer block).
  3. Submitting the suggested days actually creates a real time-off request through the Request Time Off tool, previewing the selected dates before submission.
  4. Asking a policy question (for example, about parental-leave planning) correctly triggers the Ask About Policy tool with the right question-type parameter inferred from natural language, and returns a relevant policy answer — with the caveat that tuning exactly how much relevant policy detail is surfaced is an ongoing design/engineering exercise, not something that gets fully solved by adding AI on top.
flowchart TD
    A[User: "What's my next scheduled holiday?"] --> B[Prompt: Next Scheduled Work Holiday]
    B --> C[Server auto-attaches calendar resource for authenticated employee]
    C --> D[User: "Plan time off around it"]
    D --> E[Tool: Plan Time Off - uses authenticated employee context]
    E --> F[Agent suggests optimal date range]
    F --> G[Tool: Request Time Off - submits real request]
    G --> H[User asks a policy question]
    H --> I[Tool: Ask About Policy - semantic search over benefit docs]

The remaining important caveat from this walkthrough: not every AI client supports authenticated remote MCP servers the same way. Some desktop AI clients only added support for remote, authenticated MCP servers through separate “connector” features, and can be entirely incompatible with a server secured the way this one was — a reminder that testing across the actual client ecosystem you intend to support matters as much as testing against the specification itself.

Summary

This material walked through building a complete, production-oriented MCP server from nothing, using a realistic enterprise scenario (an internal HR self-service assistant) as the throughline:

  • Getting started — scaffolding a local MCP server project, wiring up a development environment across multiple language ecosystems, and using the MCP Inspector as the primary debugging tool for any MCP server regardless of implementation language.
  • Building out the server — layering resources (static and templated), tools (with structured content, embedded resources, and linked resources), and prompts, while continuously refining based on how AI clients actually behave rather than only what the specification says.
  • Context engineering — treating “what information reaches the model, in what format, at what time” as a first-class design problem, not an afterthought, since AI clients disagree on how aggressively they’ll pull in linked or embedded context on their own.
  • Distribution — packaging a local server (npm, Docker, NuGet, or a self-contained executable) so it can actually reach other developers and users, not just run from a source checkout.
  • Going remote — understanding that “local vs. remote” is about where the process runs, not which transport is used; switching from standard I/O to streamable HTTP; and choosing a stateful or stateless hosting model depending on the target platform.
  • Securing the server — implementing the full OAuth 2.1 handshake required by the MCP specification: protected resource metadata, client registration (static vs. dynamic), the authorization code/token exchange, and finally using the authenticated identity (via an On-Behalf-Of flow) to call back-end services as the real user instead of a shared system identity.

A few themes recur throughout and are worth internalizing beyond the specific demo:

  • There is no single “right” way to host an MCP server. Local servers are genuinely appealing for developer-facing tools because of how easy they are to distribute across platforms; remote servers make sense when there’s no need to touch local resources and centralized hosting/updates matter more.
  • Securing an MCP server isn’t magic. Most of the OAuth plumbing is identical to securing any other back-end web API — the MCP-specific pieces are mainly the protected-resource-metadata discovery step and how clients like the Inspector negotiate registration and tokens.
  • Context is king. Large language models perform best when the right information is delivered in the right format at the right time — that discipline (context engineering) has no shortcut and has to be designed for deliberately, tool by tool.
  • MCP is a new kind of front end, not just another API style — it’s built around contextual, agentic user experiences, and the interaction design choices (what to expose as a resource vs. a tool, when to embed vs. link a resource, how much context a prompt should pull in) matter as much as the underlying protocol mechanics.

Quick-reference: Local vs. Remote MCP Server Decision Table

QuestionIf “yes”If “no”
Does the server need access to the user’s local file system or local tools?Host locallyEither works
Do you need to support many different, unknown AI clients without manual setup?Remote with OAuth (dynamic or static registration)Local, simpler distribution
Is your hosting platform serverless/FaaS (Azure Functions, Lambda, Cloud Run)?Use stateless streamable HTTP modeStateful mode is available if the platform supports it (e.g., Cloudflare Workers)
Do you need advanced features like elicitation or sampling that depend on stateful HTTP?Choose a host that supports stateful streamable HTTPStateless mode is sufficient
Will the server ever act on behalf of a specific authenticated user (not just a shared system identity)?Implement OAuth 2.1 + On-Behalf-Of token exchangeAn anonymous/system-identity server may be acceptable, but only for non-sensitive, intentionally public data

Final Checklist for Building and Shipping an MCP Server

  • Scaffold the server project and verify it works against the MCP Inspector before adding any AI client.
  • Design resources, tools, and prompts around real user workflows, not just a literal mapping of existing back-end API endpoints.
  • Test how different AI clients actually consume embedded/linked resources — don’t assume every client behaves the same way.
  • Package and distribute the server through at least one appropriate channel (npm, Docker, NuGet, or a self-contained binary) if it needs to reach other users.
  • Decide deliberately between local and remote hosting based on local-resource needs, client compatibility, and operational requirements.
  • If going remote, switch to streamable HTTP and choose stateful or stateless mode based on the target hosting platform’s capabilities.
  • Protect any server that touches real user or company data with OAuth 2.1 — never ship an anonymous, system-identity-only server against production data.
  • Implement the full protected-resource-metadata → client-registration → authorization-code → token-validation chain, and test each step individually with a tool like the MCP Inspector’s OAuth flow screen.
  • Use an On-Behalf-Of (or equivalent delegation) flow so back-end APIs see the real authenticated user, not a shared system identity.
  • Validate the finished, secured server against every AI client you intend to support — authenticated remote server support varies significantly between clients.

Search Terms

model · context · protocol · ai · agents · orchestration · artificial · intelligence · generative · mcp · server · local · servers · oauth · tools · remote · resources · introducing · protected · resource · token

Interested in this course?

Contact us to book it or get a custom training plan for your team.