Middleware Guide
What is McpMiddleware?
type McpMiddleware =
McpContext -> (McpContext -> Task<McpResponse>) -> Task<McpResponse>
A middleware is a function that receives:
McpContext-- the current request context withMethod(e.g.,"tools/call"), optionalParams(aJsonElement), and aCancellationToken.next-- a function to call the next handler (or middleware) in the chain.
It returns a Task<McpResponse> where McpResponse is either Success of JsonElement or McpResponseError of McpError.
The middleware can inspect or modify the context before calling next, inspect or modify the response after, or short-circuit by returning a response without calling next.
Writing a logging middleware
open System.Threading.Tasks
open FsMcp.Core
open FsMcp.Server
let loggingMiddleware (log: ResizeArray<string>) : McpMiddleware =
fun ctx next -> task {
log.Add $"[LOG] {ctx.Method} started"
let! response = next ctx
let status =
match response with
| Success _ -> "OK"
| McpResponseError _ -> "ERROR"
log.Add $"[LOG] {ctx.Method} completed: {status}"
return response
}
This middleware wraps every request with before/after logging. It always calls next -- it never short-circuits.
Writing an auth middleware
let authMiddleware (allowedMethods: Set<string>) : McpMiddleware =
fun ctx next ->
if allowedMethods.Contains ctx.Method then
next ctx
else
Task.FromResult(McpResponseError (TransportError $"Unauthorized: {ctx.Method}"))
This middleware short-circuits: if the method is not in the allowed set, it returns an error immediately without calling next.
Middleware.compose and Middleware.pipeline
Compose two middleware
Middleware.compose chains two middleware so the first runs before the second:
let combined = Middleware.compose (loggingMiddleware log) (authMiddleware allowed)
Execution order: logging wraps auth wraps the handler.
Compose a list into a pipeline
Middleware.pipeline reduces a list of middleware into a single middleware. An empty list returns a pass-through:
let pipeline = Middleware.pipeline [
loggingMiddleware log
authMiddleware (Set.ofList ["tools/call"; "tools/list"])
timingMiddleware timings
]
Execution flows left-to-right: logging -> auth -> timing -> handler -> timing -> auth -> logging.
ValidationMiddleware.create
The built-in validation middleware checks that tool call arguments include all required fields from the tool's JSON Schema before the handler runs. It only applies to tools/call requests against tools that have an InputSchema:
open FsMcp.Server
let server = mcpServer {
name "ValidatedServer"
version "1.0.0"
tool (TypedTool.define<CalcArgs> "add" "Add two numbers" (fun args -> task {
return Ok [ Content.text $"{args.a + args.b}" ]
}) |> unwrapResult)
useStdio
}
// Create validation middleware from the config
let validationMw = ValidationMiddleware.create server
If a required field is missing, the middleware returns McpResponseError (TransportError "Validation failed: Missing required fields: a, b") without ever calling the handler.
Telemetry.tracing() and MetricsCollector
Tracing middleware
Telemetry.tracing() creates a middleware that emits a System.Diagnostics.Activity (span) for each request. Compatible with OpenTelemetry, Application Insights, and any ActivityListener:
open FsMcp.Server
let tracingMw = Telemetry.tracing ()
Tags emitted: mcp.method, mcp.status ("ok" or "error"), mcp.duration_ms, and mcp.error (on exceptions).
MetricsCollector
MetricsCollector tracks request counts and average durations per method:
let collector = Telemetry.MetricsCollector()
// Use collector.Middleware in your pipeline
let metricsMw = collector.Middleware
// Later, query metrics:
let counts = collector.RequestCounts // Map<string, int>
let avgDurations = collector.AverageDurations // Map<string, float>
The collector keeps only the last 1000 durations per method to prevent memory leaks. You can customize this:
let collector = Telemetry.MetricsCollector(maxDurationsPerMethod = 500)
Combined tracing + metrics
let allTelemetry = Telemetry.all ()
This composes tracing() and a MetricsCollector into a single middleware.
Composing everything in mcpServer { }
open FsMcp.Core
open FsMcp.Server
type CalcArgs = { a: float; b: float }
let log = ResizeArray<string>()
let server = mcpServer {
name "FullServer"
version "1.0.0"
tool (TypedTool.define<CalcArgs> "add" "Add two numbers" (fun args -> task {
return Ok [ Content.text $"{args.a + args.b}" ]
}) |> unwrapResult)
middleware (Telemetry.tracing ())
middleware (fun ctx next -> task {
log.Add $"[LOG] {ctx.Method}"
return! next ctx
})
useStdio
}
// Add validation after building (needs the config to inspect schemas):
let validatedServer =
{ server with
Middleware = server.Middleware @ [ ValidationMiddleware.create server ] }
Middleware runs in the order they are added. Each middleware call appends to the list. At runtime the pipeline is: first middleware wraps second wraps third wraps the actual handler.