Blazing fast C# MCP Server tool unit testing with strong typing, thanks to some help from lambda expressions #1201
mitchcapper
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Pre-submission Checklist
What would you like to share?
First the magic. Say I have a MCP server tool like:
I can then use it in my unit test client with:
await RunServerItem(() => FailureReporterTool.UpdateFailureReports(LocalTestBase.TargetAppConfig, framework, ["Test 3", "Test 5"], all_failing, trx_path));To be clear The expression there is simply passing the actual server side function along with the args I want to call it with. The magic is that it is able to make that call over an local, stdio, and http client without changing that line at all.
It does require adding the server reference assembly with your tools to the unit test client project but it is a fairly low bar that would happen if you were using constant values from it anyway.
The linked source POC of how it works is at https://github.com/mitchcapper/___MCP_SAMPLE_UT . At the top of MCPServer/Program.cs you set the TestMode enum to Direct, Stdio, or Webserver depending on your transport preference. The http/stdio options it automatically handles full serialization/session negotiation. The psuedo logic is:
RunServerItem<T>(Expression<Func<Task<T>>> expression)if we are doing direct execution it just compile and invokes the expression you passed it, similar to if you just called the server function directly. If not it passes the expression to the next.RunServerItem<T>(LambdaExpression expression, LambdaExpression expression2 = null)- It callsGetFunctionDetailsfirstGetFunctionDetails(LambdaExpression expression)- This uses a mix of reflection of the target methodinfo (to get parameter names and custom attributes to know which are over the wire parameters) and breaking down the entire expression and evaluating each segment to get the actual parameter value we want to pass in. It might be possible to simplify the arguments a bit to a more generalizedExpression.Lambdabut I use similar code elsewhere so do type each arg. Any parameters that do not have a DescriptionAttribute are added to a skip list so that we don't send them on the network request. Finally it callsGetMCPInfo(MethodInfo method)I won't go into this too much, it was my original server metadata extractor but is largely not needed any more, it gets all its data from the MCP attributes found on the server itself. It returns a FunctionDetails object with all the data it collected to our RunServerItem caller (each args description, default value, type).RunServerItem now continues and builds up the key value pairs for the remote call and calls
RunToolRunTool<RETURN_TYPE>(MCPTestMetaInfo tool, params kvp[] args)doesn't need to do anything heavy mostly it just calls MCPClient.Call (normal client call). I left my metadata validation code in there but as we now generate all the params isn't really a requirement. RunTool also handles the rather generic 'an error occurred invoking' errors the MCPServer throws when you don't pass the right arguments. I do an Assert.Fail saying hey these are the arguments expected (from a quick tool lookup), this was mostly to assist AI that was writing unit tests to figure out what args they were doing wrong. It finally takes our structured content converting it from a System.Text.Json object to a Newtonsoft one, just due to personal preference.RunServerItem then converts it to the users generic type and returns it. This gives us a strongly typed result we can easily validate.
Note expression2 arg on RunServerItem is superfluous, it allows us to use the single RunServerItem function overloaded without the compiler being unable to choose between the Func<Task<T and the LambdaExpression (as the optional parameter de-prioritizes it).
Technically the MCPTestMetadataExtractor is not required it was the initial metadata I used back when I was still manually passing key value string/object pairs to make sure I didn't have any typos in the keys. I left it as you could use it for doing additional validation / schema inspection to make sure it matches as expected.
The primary minor limitation is you cannot specify options parameters by name out of order (will throw an error). This can be worked around by not taking a
Expression<Func<Task<T>>>to the first RunServerItem and instead just a LambdaExpression but it makes direct execution a bit harder as you have to rebuild it from the expression (doable but i was lazy).Now this was just written quickly for myself and not a real library. There are many things here that might not work with your code as is (ie if you rename a parameter using an Attribute) but as I show how already to extract the custom attribute this should be simple enough to add.
For me this code allowed the 100+ unit tests I have to run not only seamlessly over the network or by direct server function call but was also far faster for myself (or AI) to generate and validate the tests as everything was strongly typed there were no magic strings being used.
Hopefully it might be useful to someone else. Expressions are a fantastic way to do debug diagnostics as they can provide rich error messages with a ton of context, and in examples like this do additional validation without having to hard code validation inline.
The sample also uses TUnit matrixing to run all 3 server connection types over the two unit tests (so 6x tests) at the same time.
Relevant Links
https://github.com/mitchcapper/___MCP_SAMPLE_UT
Beta Was this translation helpful? Give feedback.
All reactions