Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 9 of 10

Exposing the Tool Through an MCP Server

Estimated time: about 18 min.

The problem this module solves

Right now, your getOrder tool only exists inside your agent: it's declared in agent.fsx and passed to the model in ChatOptions.Tools. If another program — a coding assistant in your editor, another agent, a tool from another team — wanted to use it, that wiring would have to be copied into every program, each with its own way of describing the tool.

The Model Context Protocol (MCP) standardizes this meeting point between AI applications and tools. This module exposes the same getOrder, without changing a single line of orders.fsx, through an MCP server written in F#. No need for the model here.

What MCP standardizes

An MCP server offers tools; an MCP client (built into an AI application) discovers and calls them. The messages are JSON-RPC, and for a local server the simplest transport is stdio: the client starts the server as a subprocess and talks to it through its standard input and output. Two messages are all you need here:

  • tools/list: "what tools do you offer?" — the server answers with each tool's name, description, and JSON schema for its parameters;
  • tools/call: "call this tool with these arguments" — the server executes it and returns the result.

Compare with what you've done so far:

Tool in the agent (modules 5–8) Tool behind an MCP server
Who uses it? only your agent any MCP client
Where is the tool description? in your code, in Microsoft.Extensions.AI format published by the server, in the protocol's format
Who executes the function? your process the server's process
To change the tool modify every application modify the server only

The official MCP C# SDK (ModelContextProtocol on NuGet) is maintained in the modelcontextprotocol/csharp-sdk repository. Its documentation shows servers in C#; no official example is written in F#. This module's scripts were tested on 2026-10-27 with ModelContextProtocol 2.3.0.

One rule before writing: stdout belongs to the protocol

Since the client reads the server's standard output as JSON-RPC, the MCP specification is explicit: the server must not write anything to stdout that isn't a valid MCP message; it may, however, write its logs to stderr. A debugging printfn, or a log sent to the console, would break this rule. That's why orders.fsx hasn't printed anything since module 3: the server is going to load it. The server's logs will be sent to stderr, the error output, which the protocol doesn't use.

Exercise — write the server

Create mcp-server.fsx. The .NET generic host (Host.CreateApplicationBuilder) is the same as in a modern C# project: it's provided. The part that's yours is the tool declaration:

// mcp-server.fsx — module 09: expose getOrder to any MCP client.
// Tested on 2026-10-27: ModelContextProtocol 2.3.0, Microsoft.Extensions.Hosting 10.0.14.
#r "nuget: ModelContextProtocol, 2.3.0"
#r "nuget: Microsoft.Extensions.Hosting, 10.0.14"
#load "orders.fsx"

open System
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.Logging
open ModelContextProtocol.Server

// The F# function from module 04, unchanged, declared as an MCP tool.
let orderTool =
    // Your turn: McpServerTool.Create(delegate, McpServerToolCreateOptions(...))

// PROVIDED — the .NET generic host, as in a C# project.
let builder = Host.CreateApplicationBuilder()

// stdout is reserved for the protocol: all logs go to stderr.
builder.Logging.AddConsole(fun options ->
    options.LogToStandardErrorThreshold <- LogLevel.Trace)
|> ignore

builder.Services.AddMcpServer().WithStdioServerTransport().WithTools([ orderTool ])
|> ignore

builder.Build().RunAsync().GetAwaiter().GetResult()

Complete orderTool:

  • the delegate is built as in module 5, from Orders.getOrder;
  • the options are written with the initialization syntax seen in module 2: McpServerToolCreateOptions(Name = "getOrder", Description = "..."). Reuse the agent's description: "Returns the status of an order from its number."

In the provided code, |> ignore discards the value returned by a call you don't need: F# warns you (FS0020) if you don't say so explicitly, where C# silently ignores it.

In the C# documentation, you'll often see another way to declare tools, with [McpServerToolType] and [McpServerTool] attributes on a class. It works in F# too, but the explicit declaration above avoids writing a class for a single function.

Solution

let orderTool =
    McpServerTool.Create(
        Func<int, string>(Orders.getOrder),
        McpServerToolCreateOptions(
            Name = "getOrder",
            Description = "Returns the status of an order from its number."
        )
    )

If you run dotnet fsi mcp-server.fsx directly, nothing is printed to standard output, and the server waits for messages: that's normal. Stop it with Ctrl+C. Verification goes through a client.

Exercise — verify with an MCP client

A verification client is provided. It starts your server itself (dotnet fsi mcp-server.fsx), requests the list of tools, then calls getOrder three times — once with an argument of the wrong type. Create mcp-client.fsx next to the server:

// mcp-client.fsx — module 09, PROVIDED: an MCP client for verification.
// It starts mcp-server.fsx, lists its tools, then calls getOrder three times.
#r "nuget: ModelContextProtocol, 2.3.0"

open System
open System.Collections.Generic
open ModelContextProtocol.Client
open ModelContextProtocol.Protocol

let serverPath = IO.Path.Combine(__SOURCE_DIRECTORY__, "mcp-server.fsx")

let transportOptions =
    StdioClientTransportOptions(
        Name = "orders",
        Command = "dotnet",
        Arguments = [| "fsi"; serverPath |]
    )

let transport = StdioClientTransport(transportOptions)

let client = McpClient.CreateAsync(transport).GetAwaiter().GetResult()

for tool in client.ListToolsAsync().AsTask().GetAwaiter().GetResult() do
    printfn "Tool: %s — %s" tool.Name tool.Description
    printfn "Schema: %s" (tool.JsonSchema.GetRawText())

let call (argument: obj) =
    let arguments = Dictionary<string, obj>(dict [ ("orderId", argument) ])
    let pending = client.CallToolAsync("getOrder", arguments)
    let result = pending.AsTask().GetAwaiter().GetResult()

    let text =
        result.Content
        |> Seq.choose (fun block ->
            match block with
            | :? TextContentBlock as t -> Some t.Text
            | _ -> None)
        |> String.concat ""

    let isError = (result.IsError = Nullable true)
    printfn "getOrder(%A) -> IsError=%b : %s" argument isError text

call (box 4512)
call (box 9999)
call (box "abc")

client.DisposeAsync().AsTask().GetAwaiter().GetResult()

Two elements of the client are new: box converts a value to obj, like C#'s implicit boxing; __SOURCE_DIRECTORY__ is the folder of the current script, which makes it possible to find the server whatever folder you run the client from.

Before running it, predict the last line: what will the server return for getOrder("abc"), when getOrder expects an integer?

dotnet fsi mcp-client.fsx

The first run downloads the server's and the client's packages; so that the client doesn't have to wait for that download, first run the server alone once (dotnet fsi mcp-server.fsx, then Ctrl+C). After that, connecting takes about a second (observed on the test machine).

Expected output

Tool: getOrder — Returns the status of an order from its number.
Schema: {"type":"object","properties":{"orderId":{"type":"integer"}},"required":["orderId"]}
getOrder(4512) -> IsError=false : Order 4512: shipped, tracking TRK-88
getOrder(9999) -> IsError=false : Order 9999 not found
getOrder("abc") -> IsError=true : An error occurred invoking 'getOrder'.

All of this output is exact: no model is involved. Three observations:

  • the results are exactly the texts from your module 4: it's the same F# function running, in the server's process;
  • the published schema is identical to the one you saw in module 5: the MCP SDK infers it from the same int -> string signature;
  • for "abc", the server doesn't crash: the protocol provides a result flagged as an error (IsError=true) that the client can handle. It's the MCP version of your BadArgumentType.

If your output differs

  • The client hangs or reports a connection error: check that mcp-server.fsx is in the same folder as the client, and that the server alone starts without a compilation error (dotnet fsi mcp-server.fsx, then Ctrl+C).
  • You added a printfn to the server or to orders.fsx, and everything still works: during one test, the C# SDK client ignored a stray line of text on stdout. Don't count on this tolerance: the specification forbids it, and another MCP client may fail. Remove the line.

Exercise — explain it in two sentences

A colleague asks you: "Your agent was already calling getOrder. What does the MCP server change?" Answer them in writing, in two sentences at most, before reading on.

A possible answer

"In the agent, the tool is declared only for that agent, in its own code. Behind an MCP server, it's described and called according to a standard protocol (tools/list, tools/call), so any MCP client — an assistant, an editor, another agent — can discover and use it without copying anything."

Your answer is right if it contains both ideas: a standard protocol, and reuse by other clients without modifying the tool.

Going further (optional)

  • Another client. If Node.js 22.19 or later is installed, the official MCP Inspector tool can also check your server, from the command line: npx @modelcontextprotocol/inspector@2.12.0 --cli dotnet fsi mcp-server.fsx --method tools/list. This version was tested while preparing the course, on a working server equivalent to the one in this module.
  • Plugging MCP into your agent. The tools returned by ListToolsAsync are Microsoft.Extensions.AI AIFunction objects: they can be passed to ChatOptions.Tools just like your orderTool. During preparation (French version of the scripts), qwen3.5:4b requested getOrder this way through the MCP server and got Order 7781: processing. Then remember module 8: the text returned by an MCP server you didn't write is untrusted data, and a prompt injection surface.

What you just did

You exposed an existing F# function, without modifying it, through an MCP server built with the official C# SDK, in a simple script. You verified it from an MCP client: the tool appears in the list with its name, description, and schema, and its calls return the expected results, including a clean error for an invalid argument. You know what MCP brings compared to declaring tools in the agent: standardized description and calling, and therefore a tool written once and usable by any compatible client.

In the last module, you step back to look at the three types you met along the way — discriminated union, Option, Result — to know how to choose between them outside this example, and you check that switching models touches only one file.

Check your understanding

Three AI applications on your team (an assistant in the editor, an internal chatbot, an overnight agent) need to check stock. What does an MCP server exposing `getStock` bring, compared to declaring the tool in each application?

To debug, you add `printfn "getOrder called"` inside `getOrder`, loaded by your MCP server over the stdio transport. Why is this a mistake, even if the verification client still seems to work?

From an MCP client, you call `getOrder` on your server with `orderId` = 3020, then with `orderId` = "three". What do you get?

Want to hear about the next modules?

The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.