csharp-sdk/tests/ModelContextProtocol.TestServer/Program.cs
Pranav Senthilnathan 155f619441
Speed up CLI argument escaping tests (#1773)
Copilot-Session: 323a2062-9493-4be4-a710-f6c2b9e15a49
2026-07-29 14:46:35 -07:00

590 lines
No EOL
28 KiB
C#

using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Serilog;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
namespace ModelContextProtocol.TestServer;
internal static class Program
{
private static ILoggerFactory CreateLoggerFactory()
{
// Use serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Verbose() // Capture all log levels
.WriteTo.File(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "TestServer_.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
return LoggerFactory.Create(builder =>
{
builder.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.AddSerilog();
});
}
private static async Task Main(string[] args)
{
if (args.Contains("--echo-cli-arg-and-exit"))
{
Console.Error.WriteLine($"CLI_ARG:{JsonSerializer.Serialize(ParseCliArgument(args))}");
Console.Error.Flush();
return;
}
Log.Logger.Information("Starting server...");
string? cliArg = ParseCliArgument(args);
McpServerOptions options = new()
{
Capabilities = new ServerCapabilities(),
ServerInstructions = "This is a test server with only stub functionality",
};
ConfigureTools(options, cliArg);
ConfigureResources(options);
ConfigurePrompts(options);
ConfigureLogging(options);
ConfigureCompletions(options);
using var loggerFactory = CreateLoggerFactory();
await using var stdioTransport = new StdioServerTransport("TestServer", loggerFactory);
await using McpServer server = McpServer.Create(stdioTransport, options, loggerFactory);
Log.Logger.Information("Server running...");
// Run until process is stopped by the client (parent process)
_ = RunBackgroundLoop(server);
await server.RunAsync();
}
private static async Task RunBackgroundLoop(McpServer server, CancellationToken cancellationToken = default)
{
var loggingLevels = (LoggingLevel[])Enum.GetValues(typeof(LoggingLevel));
var random = new Random();
while (true)
{
await Task.Delay(1000, cancellationToken);
try
{
// Send random log messages every few seconds
if (_minimumLoggingLevel is not null)
{
var logLevel = loggingLevels[random.Next(loggingLevels.Length)];
await server.SendMessageAsync(new JsonRpcNotification
{
Method = NotificationMethods.LoggingMessageNotification,
Params = JsonSerializer.SerializeToNode(new LoggingMessageNotificationParams
{
Level = logLevel,
Data = JsonElement.Parse("\"Random log message\"")
})
}, cancellationToken);
}
// Snapshot the subscribed resources, rather than locking while sending notifications
foreach (var resource in _subscribedResources)
{
ResourceUpdatedNotificationParams notificationParams = new() { Uri = resource.Key };
await server.SendMessageAsync(new JsonRpcNotification
{
Method = NotificationMethods.ResourceUpdatedNotification,
Params = JsonSerializer.SerializeToNode(notificationParams),
}, cancellationToken);
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error sending log message");
break;
}
}
}
private static void ConfigureTools(McpServerOptions options, string? cliArg)
{
options.Handlers.ListToolsHandler = async (request, cancellationToken) =>
{
return new ListToolsResult
{
Tools =
[
new Tool
{
Name = "echo",
Description = "Echoes the input back to the client.",
InputSchema = JsonElement.Parse("""
{
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The input to echo back."
}
},
"required": ["message"]
}
"""),
},
new Tool
{
Name = "echoSessionId",
Description = "Echoes the session id back to the client.",
InputSchema = JsonElement.Parse("""
{
"type": "object"
}
"""),
},
new Tool
{
Name = "trigger-sampling-request",
Description = "Trigger a Request from the Server for LLM Sampling",
InputSchema = JsonElement.Parse("""
{
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The prompt to send to the LLM"
},
"maxTokens": {
"type": "number",
"description": "Maximum number of tokens to generate"
}
},
"required": ["prompt", "maxTokens"]
}
"""),
},
new Tool
{
Name = "crash",
Description = "Terminates the server process with a specified exit code.",
InputSchema = JsonElement.Parse("""
{
"type": "object",
"properties": {
"exitCode": {
"type": "number",
"description": "The exit code to terminate with"
}
},
"required": ["exitCode"]
}
"""),
}
]
};
};
options.Handlers.CallToolHandler = async (request, cancellationToken) =>
{
if (request.Params.Name == "echo")
{
if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message))
{
throw new McpProtocolException("Missing required argument 'message'", McpErrorCode.InvalidParams);
}
return new CallToolResult
{
Content = [new TextContentBlock { Text = $"Echo: {message}" }]
};
}
else if (request.Params.Name == "echoSessionId")
{
return new CallToolResult
{
Content = [new TextContentBlock { Text = request.Server.SessionId ?? string.Empty }]
};
}
else if (request.Params.Name == "trigger-sampling-request")
{
if (request.Params.Arguments is null ||
!request.Params.Arguments.TryGetValue("prompt", out var prompt) ||
!request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens))
{
throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams);
}
var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "trigger-sampling-request", Convert.ToInt32(maxTokens.GetRawText())),
cancellationToken: cancellationToken);
return new CallToolResult
{
Content = [new TextContentBlock { Text = $"LLM sampling result: {sampleResult.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text}" }]
};
}
else if (request.Params.Name == "echoCliArg")
{
return new CallToolResult
{
Content = [new TextContentBlock { Text = cliArg ?? "null" }]
};
}
else if (request.Params.Name == "crash")
{
if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("exitCode", out var exitCodeValue))
{
throw new McpProtocolException("Missing required argument 'exitCode'", McpErrorCode.InvalidParams);
}
int exitCode = Convert.ToInt32(exitCodeValue.GetRawText());
Console.Error.WriteLine($"Crashing with exit code {exitCode}");
Environment.Exit(exitCode);
throw new Exception("unreachable");
}
else
{
throw new McpProtocolException($"Unknown tool: {request.Params.Name}", McpErrorCode.InvalidParams);
}
};
}
private static void ConfigurePrompts(McpServerOptions options)
{
options.Handlers.ListPromptsHandler = async (request, cancellationToken) =>
{
return new ListPromptsResult
{
Prompts = [
new Prompt
{
Name = "simple-prompt",
Description = "A prompt without arguments"
},
new Prompt
{
Name = "args-prompt",
Description = "A prompt with arguments",
Arguments =
[
new PromptArgument
{
Name = "city",
Description = "Name of the city",
Required = true
},
new PromptArgument
{
Name = "state",
Description = "Name of the state",
Required = false
}
]
},
new Prompt
{
Name = "completable-prompt",
Description = "A prompt with completable arguments",
Arguments =
[
new PromptArgument
{
Name = "department",
Description = "Choose the department",
Required = true
},
new PromptArgument
{
Name = "name",
Description = "Choose a team member",
Required = true
}
]
}
]
};
};
options.Handlers.GetPromptHandler = async (request, cancellationToken) =>
{
List<PromptMessage> messages = [];
if (request.Params.Name == "simple-prompt")
{
messages.Add(new PromptMessage
{
Role = Role.User,
Content = new TextContentBlock { Text = "This is a simple prompt without arguments." },
});
}
else if (request.Params.Name == "args-prompt")
{
string city = request.Params.Arguments?["city"].ToString() ?? "unknown";
string state = request.Params.Arguments?["state"].ToString() ?? "";
string location = !string.IsNullOrEmpty(state) ? $"{city}, {state}" : city;
messages.Add(new PromptMessage
{
Role = Role.User,
Content = new TextContentBlock { Text = $"What's weather in {location}?" },
});
}
else if (request.Params.Name == "completable-prompt")
{
string department = request.Params.Arguments?["department"].ToString() ?? "unknown";
string name = request.Params.Arguments?["name"].ToString() ?? "unknown";
messages.Add(new PromptMessage
{
Role = Role.User,
Content = new TextContentBlock { Text = $"Please promote {name} to the head of the {department} team." },
});
}
else
{
throw new McpProtocolException($"Unknown prompt: {request.Params.Name}", McpErrorCode.InvalidParams);
}
return new GetPromptResult
{
Messages = messages
};
};
}
private static LoggingLevel? _minimumLoggingLevel = null;
private static void ConfigureLogging(McpServerOptions options)
{
options.Handlers.SetLoggingLevelHandler = async (request, cancellationToken) =>
{
_minimumLoggingLevel = request.Params.Level;
return new EmptyResult();
};
}
private static readonly ConcurrentDictionary<string, bool> _subscribedResources = new();
private static void ConfigureResources(McpServerOptions options)
{
var capabilities = options.Capabilities ??= new();
capabilities.Resources = new() { Subscribe = true };
List<Resource> resources = [];
List<ResourceContents> resourceContents = [];
for (int i = 0; i < 100; ++i)
{
string uri = $"test://static/resource/{i + 1}";
if (i % 2 == 0)
{
resources.Add(new Resource
{
Uri = uri,
Name = $"Resource {i + 1}",
MimeType = "text/plain"
});
resourceContents.Add(new TextResourceContents
{
Uri = uri,
MimeType = "text/plain",
Text = $"Resource {i + 1}: This is a plaintext resource"
});
}
else
{
var buffer = Encoding.UTF8.GetBytes($"Resource {i + 1}: This is a base64 blob");
resources.Add(new Resource
{
Uri = uri,
Name = $"Resource {i + 1}",
MimeType = "application/octet-stream"
});
resourceContents.Add(BlobResourceContents.FromBytes(buffer, uri, "application/octet-stream"));
}
}
const int pageSize = 10;
options.Handlers.ListResourceTemplatesHandler = async (request, cancellationToken) =>
{
return new ListResourceTemplatesResult
{
ResourceTemplates = [
new ResourceTemplate
{
UriTemplate = "test://dynamic/resource/{id}",
Name = "Dynamic Resource",
}
]
};
};
options.Handlers.ListResourcesHandler = async (request, cancellationToken) =>
{
int startIndex = 0;
if (request.Params.Cursor is not null)
{
try
{
var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Params.Cursor));
startIndex = Convert.ToInt32(startIndexAsString);
}
catch (Exception e)
{
throw new McpProtocolException($"Invalid cursor: '{request.Params.Cursor}'", e, McpErrorCode.InvalidParams);
}
}
int endIndex = Math.Min(startIndex + pageSize, resources.Count);
string? nextCursor = null;
if (endIndex < resources.Count)
{
nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString()));
}
return new ListResourcesResult
{
NextCursor = nextCursor,
Resources = resources.GetRange(startIndex, endIndex - startIndex)
};
};
options.Handlers.ReadResourceHandler = async (request, cancellationToken) =>
{
if (request.Params.Uri is null)
{
throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
}
if (request.Params.Uri.StartsWith("test://dynamic/resource/"))
{
var id = request.Params.Uri.Split('/').LastOrDefault();
if (string.IsNullOrEmpty(id))
{
throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
}
return new ReadResourceResult
{
Contents = [
new TextResourceContents
{
Uri = request.Params.Uri,
MimeType = "text/plain",
Text = $"Dynamic resource {id}: This is a plaintext resource"
}
]
};
}
ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri)
?? throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
return new ReadResourceResult
{
Contents = [contents]
};
};
options.Handlers.SubscribeToResourcesHandler = async (request, cancellationToken) =>
{
if (request?.Params.Uri is null)
{
throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
}
if (!request.Params.Uri.StartsWith("test://static/resource/")
&& !request.Params.Uri.StartsWith("test://dynamic/resource/"))
{
throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
}
_subscribedResources.TryAdd(request.Params.Uri, true);
return new EmptyResult();
};
options.Handlers.UnsubscribeFromResourcesHandler = async (request, cancellationToken) =>
{
if (request?.Params.Uri is null)
{
throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
}
if (!request.Params.Uri.StartsWith("test://static/resource/")
&& !request.Params.Uri.StartsWith("test://dynamic/resource/"))
{
throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
}
_subscribedResources.TryRemove(request.Params.Uri, out _);
return new EmptyResult();
};
}
private static void ConfigureCompletions(McpServerOptions options)
{
List<string> sampleResourceIds = ["1", "2", "3", "4", "5"];
Dictionary<string, List<string>> exampleCompletions = new()
{
{"department", ["Engineering", "Sales", "Marketing", "Support"]},
{"name", ["Alice", "Bob", "Charlie"]},
};
options.Handlers.CompleteHandler = async (request, cancellationToken) =>
{
string[]? values;
switch (request.Params.Ref)
{
case ResourceTemplateReference rtr:
var resourceId = rtr.Uri?.Split('/').LastOrDefault();
if (string.IsNullOrEmpty(resourceId))
return new CompleteResult { Completion = new() { Values = [] } };
// Filter resource IDs that start with the input value
values = sampleResourceIds.Where(id => id.StartsWith(request.Params.Argument.Value)).ToArray();
return new CompleteResult { Completion = new() { Values = values, HasMore = false, Total = values.Length } };
case PromptReference pr:
// Handle completion for prompt arguments
if (!exampleCompletions.TryGetValue(request.Params.Argument.Name, out var completions))
return new CompleteResult { Completion = new() { Values = [] } };
values = completions.Where(value => value.StartsWith(request.Params.Argument.Value)).ToArray();
return new CompleteResult { Completion = new() { Values = values, HasMore = false, Total = values.Length } };
default:
throw new McpProtocolException($"Unknown reference type: '{request.Params.Ref.Type}'", McpErrorCode.InvalidParams);
}
};
}
static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100)
{
return new CreateMessageRequestParams
{
Messages = [new SamplingMessage
{
Role = Role.User,
Content = [new TextContentBlock { Text = $"Resource {uri} context: {context}" }],
}],
SystemPrompt = "You are a helpful test server.",
MaxTokens = maxTokens,
Temperature = 0.7f,
IncludeContext = ContextInclusion.ThisServer
};
}
private static string? ParseCliArgument(string[] args)
{
foreach (var arg in args)
{
if (arg.StartsWith("--cli-arg="))
{
return arg["--cli-arg=".Length..];
}
}
return null;
}
const string MCP_TINY_IMAGE =
"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";
}