Overhaul McpClientFactory/McpServerFactory (#87)

Handlers are now specified before connecting the client/server. Otherwise, race conditions exist. Handlers move into being a part of the McpClientOptions/McpServerOptions, so that they're provided to the factories. Where relevant, the handlers are specified as part of the capability descriptors, so that they go hand in hand.

The factories are no longer stateful. Instead of allocating a factory and then calling a create method on it, you just call a static factory method.
This commit is contained in:
Stephen Toub 2025-03-16 12:08:52 -04:00 • committed by GitHub
commit 08b0a04dab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -34,24 +34,17 @@ internal static class Program
ServerInfo = new Implementation() { Name = "TestServer", Version = "1.0.0" },
Capabilities = new ServerCapabilities()
{
Tools = new(),
Resources = new(),
Prompts = new(),
Tools = ConfigureTools(),
Resources = ConfigureResources(),
Prompts = ConfigurePrompts(),
},
ProtocolVersion = "2024-11-05",
ServerInstructions = "This is a test server with only stub functionality",
GetCompletionHandler = ConfigureCompletion(),
};
var loggerFactory = CreateLoggerFactory();
McpServerFactory factory = new(new StdioServerTransport("TestServer", loggerFactory), options, loggerFactory);
IMcpServer server = factory.CreateServer();
Log.Logger.Information("Server object created, registering handlers.");
ConfigureTools(server);
ConfigureResources(server);
ConfigurePrompts(server);
ConfigureCompletion(server);
await using IMcpServer server = McpServerFactory.Create(new StdioServerTransport("TestServer", loggerFactory), options, loggerFactory);
Log.Logger.Information("Server initialized.");
@ -60,184 +53,187 @@ internal static class Program
Log.Logger.Information("Server started.");
// Run until process is stopped by the client (parent process)
while (true) // NOSONAR
{
await Task.Delay(1000);
}
await Task.Delay(Timeout.Infinite);
}
private static void ConfigureTools(IMcpServer server)
private static ToolsCapability ConfigureTools()
{
server.SetListToolsHandler((request, cancellationToken) =>
return new()
{
return Task.FromResult(new ListToolsResult()
ListToolsHandler = (request, cancellationToken) =>
{
Tools =
[
new Tool()
{
Name = "echo",
Description = "Echoes the input back to the client.",
InputSchema = new JsonSchema()
return Task.FromResult(new ListToolsResult()
{
Tools =
[
new Tool()
{
Type = "object",
Properties = new Dictionary<string, JsonSchemaProperty>()
Name = "echo",
Description = "Echoes the input back to the client.",
InputSchema = new JsonSchema()
{
["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." }
}
},
},
new Tool()
{
Name = "sampleLLM",
Description = "Samples from an LLM using MCP's sampling feature.",
InputSchema = new JsonSchema()
{
Type = "object",
Properties = new Dictionary<string, JsonSchemaProperty>()
{
["prompt"] = new JsonSchemaProperty() { Type = "string", Description = "The prompt to send to the LLM" },
["maxTokens"] = new JsonSchemaProperty() { Type = "number", Description = "Maximum number of tokens to generate" }
}
},
}
]
});
});
server.SetCallToolHandler(async (request, cancellationToken) =>
{
if (request.Params?.Name == "echo")
{
if (request.Params?.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message))
{
throw new McpServerException("Missing required argument 'message'");
}
return new CallToolResponse()
{
Content = [new Content() { Text = "Echo: " + message?.ToString(), Type = "text" }]
};
}
else if (request.Params?.Name == "sampleLLM")
{
if (request.Params?.Arguments is null ||
!request.Params.Arguments.TryGetValue("prompt", out var prompt) ||
!request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens))
{
throw new McpServerException("Missing required arguments 'prompt' and 'maxTokens'");
}
var sampleResult = await server.RequestSamplingAsync(CreateRequestSamplingParams(prompt?.ToString() ?? "", "sampleLLM", Convert.ToInt32(maxTokens?.ToString())),
cancellationToken);
return new CallToolResponse()
{
Content = [new Content() { Text = $"LLM sampling result: {sampleResult.Content.Text}", Type = "text" }]
};
}
else
{
throw new McpServerException($"Unknown tool: {request.Params?.Name}");
}
});
}
private static void ConfigurePrompts(IMcpServer server)
{
server.SetListPromptsHandler((request, cancellationToken) =>
{
return Task.FromResult(new ListPromptsResult()
{
Prompts = [
new Prompt()
{
Name = "simple_prompt",
Description = "A prompt without arguments"
},
new Prompt()
{
Name = "complex_prompt",
Description = "A prompt with arguments",
Arguments =
[
new PromptArgument()
{
Name = "temperature",
Description = "Temperature setting",
Required = true
Type = "object",
Properties = new Dictionary<string, JsonSchemaProperty>()
{
["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." }
}
},
new PromptArgument()
},
new Tool()
{
Name = "sampleLLM",
Description = "Samples from an LLM using MCP's sampling feature.",
InputSchema = new JsonSchema()
{
Name = "style",
Description = "Output style",
Required = false
}
]
}
]
});
});
Type = "object",
Properties = new Dictionary<string, JsonSchemaProperty>()
{
["prompt"] = new JsonSchemaProperty() { Type = "string", Description = "The prompt to send to the LLM" },
["maxTokens"] = new JsonSchemaProperty() { Type = "number", Description = "Maximum number of tokens to generate" }
}
},
}
]
});
},
server.SetGetPromptHandler((request, cancellationToken) =>
{
List<PromptMessage> messages = new();
if (request.Params?.Name == "simple_prompt")
CallToolHandler = async (request, cancellationToken) =>
{
messages.Add(new PromptMessage()
if (request.Params?.Name == "echo")
{
Role = Role.User,
Content = new Content()
if (request.Params?.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message))
{
Type = "text",
Text = "This is a simple prompt without arguments."
throw new McpServerException("Missing required argument 'message'");
}
});
}
else if (request.Params?.Name == "complex_prompt")
{
string temperature = request.Params.Arguments?["temperature"]?.ToString() ?? "unknown";
string style = request.Params.Arguments?["style"]?.ToString() ?? "unknown";
messages.Add(new PromptMessage()
return new CallToolResponse()
{
Content = [new Content() { Text = "Echo: " + message?.ToString(), Type = "text" }]
};
}
else if (request.Params?.Name == "sampleLLM")
{
Role = Role.User,
Content = new Content()
if (request.Params?.Arguments is null ||
!request.Params.Arguments.TryGetValue("prompt", out var prompt) ||
!request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens))
{
Type = "text",
Text = $"This is a complex prompt with arguments: temperature={temperature}, style={style}"
throw new McpServerException("Missing required arguments 'prompt' and 'maxTokens'");
}
});
messages.Add(new PromptMessage()
{
Role = Role.Assistant,
Content = new Content()
{
Type = "text",
Text = "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?"
}
});
messages.Add(new PromptMessage()
{
Role = Role.User,
Content = new Content()
{
Type = "image",
Data = MCP_TINY_IMAGE,
MimeType = "image/png"
}
});
}
else
{
throw new McpServerException($"Unknown prompt: {request.Params?.Name}");
}
var sampleResult = await request.Server.RequestSamplingAsync(CreateRequestSamplingParams(prompt?.ToString() ?? "", "sampleLLM", Convert.ToInt32(maxTokens?.ToString())),
cancellationToken);
return Task.FromResult(new GetPromptResult()
{
Messages = messages
});
});
return new CallToolResponse()
{
Content = [new Content() { Text = $"LLM sampling result: {sampleResult.Content.Text}", Type = "text" }]
};
}
else
{
throw new McpServerException($"Unknown tool: {request.Params?.Name}");
}
}
};
}
private static void ConfigureResources(IMcpServer server)
private static PromptsCapability ConfigurePrompts()
{
return new()
{
ListPromptsHandler = (request, cancellationToken) =>
{
return Task.FromResult(new ListPromptsResult()
{
Prompts = [
new Prompt()
{
Name = "simple_prompt",
Description = "A prompt without arguments"
},
new Prompt()
{
Name = "complex_prompt",
Description = "A prompt with arguments",
Arguments =
[
new PromptArgument()
{
Name = "temperature",
Description = "Temperature setting",
Required = true
},
new PromptArgument()
{
Name = "style",
Description = "Output style",
Required = false
}
]
}
]
});
},
GetPromptHandler = (request, cancellationToken) =>
{
List<PromptMessage> messages = [];
if (request.Params?.Name == "simple_prompt")
{
messages.Add(new PromptMessage()
{
Role = Role.User,
Content = new Content()
{
Type = "text",
Text = "This is a simple prompt without arguments."
}
});
}
else if (request.Params?.Name == "complex_prompt")
{
string temperature = request.Params.Arguments?["temperature"]?.ToString() ?? "unknown";
string style = request.Params.Arguments?["style"]?.ToString() ?? "unknown";
messages.Add(new PromptMessage()
{
Role = Role.User,
Content = new Content()
{
Type = "text",
Text = $"This is a complex prompt with arguments: temperature={temperature}, style={style}"
}
});
messages.Add(new PromptMessage()
{
Role = Role.Assistant,
Content = new Content()
{
Type = "text",
Text = "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?"
}
});
messages.Add(new PromptMessage()
{
Role = Role.User,
Content = new Content()
{
Type = "image",
Data = MCP_TINY_IMAGE,
MimeType = "image/png"
}
});
}
else
{
throw new McpServerException($"Unknown prompt: {request.Params?.Name}");
}
return Task.FromResult(new GetPromptResult()
{
Messages = messages
});
}
};
}
private static ResourcesCapability ConfigureResources()
{
List<Resource> resources = [];
List<ResourceContents> resourceContents = [];
@ -279,54 +275,56 @@ internal static class Program
const int pageSize = 10;
server.SetListResourcesHandler((request, cancellationToken) =>
return new()
{
int startIndex = 0;
request ??= new(server, new());
if (request.Params?.Cursor is not null)
ListResourcesHandler = (request, cancellationToken) =>
{
try
int startIndex = 0;
if (request.Params?.Cursor is not null)
{
var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Params.Cursor));
startIndex = Convert.ToInt32(startIndexAsString);
try
{
var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Params.Cursor));
startIndex = Convert.ToInt32(startIndexAsString);
}
catch
{
throw new McpServerException("Invalid cursor");
}
}
catch
int endIndex = Math.Min(startIndex + pageSize, resources.Count);
string? nextCursor = null;
if (endIndex < resources.Count)
{
throw new McpServerException("Invalid cursor");
nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString()));
}
return Task.FromResult(new ListResourcesResult()
{
NextCursor = nextCursor,
Resources = resources.GetRange(startIndex, endIndex - startIndex)
});
},
ReadResourceHandler = (request, cancellationToken) =>
{
if (request.Params?.Uri is null)
{
throw new McpServerException("Missing required argument 'uri'");
}
ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri)
?? throw new McpServerException("Resource not found");
return Task.FromResult(new ReadResourceResult()
{
Contents = [contents]
});
}
int endIndex = Math.Min(startIndex + pageSize, resources.Count);
string? nextCursor = null;
if (endIndex < resources.Count)
{
nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString()));
}
return Task.FromResult(new ListResourcesResult()
{
NextCursor = nextCursor,
Resources = resources.GetRange(startIndex, endIndex - startIndex)
});
});
server.SetReadResourceHandler((request, cancellationToken) =>
{
if (request.Params?.Uri is null)
{
throw new McpServerException("Missing required argument 'uri'");
}
ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri)
?? throw new McpServerException("Resource not found");
return Task.FromResult(new ReadResourceResult()
{
Contents = [contents]
});
});
};
}
private static void ConfigureCompletion(IMcpServer server)
private static Func<RequestContext<CompleteRequestParams>, CancellationToken, Task<CompleteResult>> ConfigureCompletion()
{
List<string> sampleResourceIds = ["1", "2", "3", "4", "5"];
Dictionary<string, List<string>> exampleCompletions = new()
@ -335,7 +333,7 @@ internal static class Program
{"temperature", ["0", "0.5", "0.7", "1.0"]},
};
server.SetGetCompletionHandler((request, cancellationToken) =>
return (request, cancellationToken) =>
{
if (request.Params?.Ref?.Type == "ref/resource")
{
@ -344,7 +342,7 @@ internal static class Program
return Task.FromResult(new CompleteResult() { Completion = new() { Values = [] } });
// Filter resource IDs that start with the input value
var values = sampleResourceIds.Where(id => id.StartsWith(request.Params.Argument.Value)).ToArray();
var values = sampleResourceIds.Where(id => id.StartsWith(request.Params!.Argument.Value)).ToArray();
return Task.FromResult(new CompleteResult() { Completion = new() { Values = values, HasMore = false, Total = values.Length } });
}
@ -360,7 +358,7 @@ internal static class Program
}
throw new McpServerException($"Unknown reference type: {request.Params?.Ref.Type}");
});
};
}
static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100)