Skip to content

Add mcptest package for in-process MCP testing #149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 28, 2025
Merged

Conversation

octo
Copy link
Contributor

@octo octo commented Apr 14, 2025

This PR introduces a new mcptest package that simplifies end-to-end testing of MCP implementations without spawning external processes. The package provides utilities for creating an in-process MCP server and connecting it to a client within the same test, making functional tests more reliable and easier to write.

Key features of mcptest

  • Connect clients to in-process servers without having to build an executable first
  • Test full request/response cycles in a controlled environment
  • Simplified setup for testing tool implementations

Example usage

👉 Look at mcptest/mcptest_test.go for an unabridged example

func TestMyTool(t *testing.T) {
    // Create a server with your tool
    srv := mcptest.NewServer(t, server.ServerTool{
        Tool: myCustomTool,
        Handler: myToolHandler,
    })
    defer srv.Close()

    // Get a client connected to the server
    client := srv.Client()
    
    // Initialize the client
    var initReq mcp.InitializeRequest
    initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
    _, err := client.Initialize(context.Background(), initReq)
    if err != nil {
        t.Fatal(err)
    }
    
    // Test your tool by calling it and verifying results
    // ...
}

Implementation notes

  • Added a new NewStdioMCPClientWithIO function that allows creating an StdioMCPClient using provided io.Reader and io.Writer interfaces instead of spawning a subprocess
  • Implemented a new testing package with robust resource management
  • Included a test demonstrating the package's usage

This PR significantly improves testability for MCP implementations and should make writing functional tests much more straightforward.

Summary by CodeRabbit

  • New Features

    • Introduced utilities for testing MCP servers, including the ability to start, stop, and interact with test servers and clients.
    • Added a new transport option that allows simulated client-server communication using in-memory streams, enabling testing without launching subprocesses.
  • Tests

    • Added comprehensive tests for server tool behavior, including request handling and response validation.

Copy link
Contributor

coderabbitai bot commented Apr 14, 2025

Walkthrough

This update introduces a new package for testing MCP servers, providing a Server struct and associated utilities to simulate client-server interactions using in-memory pipes and log buffers. A corresponding test file validates the server tool functionality with a sample handler and utility functions for response processing. Additionally, enhancements are made to the Stdio transport, allowing it to be constructed from existing I/O streams without starting a subprocess, and refactoring subprocess management for improved flexibility and testability.

Changes

File(s) Change Summary
mcptest/mcptest.go Added a new package implementing a Server struct and helper functions/methods for creating, starting, managing, and testing MCP servers using in-memory pipes and log buffers. Exposes APIs for tool management, client access, and server lifecycle control.
mcptest/mcptest_test.go Added a new test file with a comprehensive test function for the MCP test server, including a sample "hello" tool handler and utility for formatting and checking responses.
client/transport/stdio.go Added NewIO constructor for creating a Stdio transport from existing streams without launching a subprocess. Refactored subprocess management into a new spawnCommand method. Updated Start and Close methods to accommodate these changes and improve flexibility.

📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fa96194 and 3d3ee83.

📒 Files selected for processing (3)
  • client/transport/stdio.go (3 hunks)
  • mcptest/mcptest.go (1 hunks)
  • mcptest/mcptest_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • client/transport/stdio.go
  • mcptest/mcptest.go
  • mcptest/mcptest_test.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
mcptest/mcptest_test.go (1)

69-85: Helper function could handle more content types.

The current implementation only handles TextContent and returns an error for other content types. While this works for the current test, it might be limiting for future tests that need to handle different content types.

Consider making this function more flexible by handling other content types or providing a way to customize content handling based on the test's needs.

func resultToString(result *mcp.CallToolResult) (string, error) {
	var b strings.Builder

	for _, content := range result.Content {
-		text, ok := content.(mcp.TextContent)
-		if !ok {
-			return "", fmt.Errorf("unsupported content type: %T", content)
-		}
-		b.WriteString(text.Text)
+		switch c := content.(type) {
+		case mcp.TextContent:
+			b.WriteString(c.Text)
+		case mcp.ImageContent:
+			b.WriteString("[Image Content]")
+		default:
+			return "", fmt.Errorf("unsupported content type: %T", content)
+		}
	}

	if result.IsError {
		return "", fmt.Errorf("%s", b.String())
	}

	return b.String(), nil
}
mcptest/mcptest.go (1)

77-97: Consider adding synchronization for server readiness.

The Start method launches the server in a goroutine but doesn't provide a way to know when the server is actually ready to process requests. While it works for simple tests, more complex scenarios might need to ensure the server is fully initialized.

Consider adding a mechanism, such as a ready channel, to signal when the server is fully initialized and ready to accept requests:

func (s *Server) Start() {
+	ready := make(chan struct{})
	// Start the MCP server in a goroutine
	go func() {
		mcpServer := server.NewMCPServer(s.name, "1.0.0")

		mcpServer.AddTools(s.tools...)

		logger := log.New(&s.logBuffer, "", 0)

		stdioServer := server.NewStdioServer(mcpServer)
		stdioServer.SetErrorLogger(logger)

+		// Signal that we're ready to accept connections
+		close(ready)

		if err := stdioServer.Listen(s.ctx, s.serverReader, s.serverWriter); err != nil {
			logger.Println("StdioServer.Listen failed:", err)
		}
	}()

	s.client = client.NewStdioMCPClientWithIO(s.clientReader, s.clientWriter, io.NopCloser(&s.logBuffer))
+	// Wait for the server to be ready
+	<-ready
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8cdb6c6 and 4e13197.

📒 Files selected for processing (3)
  • client/stdio.go (3 hunks)
  • mcptest/mcptest.go (1 hunks)
  • mcptest/mcptest_test.go (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (3)
mcptest/mcptest.go (5)
server/server.go (2)
  • ServerTool (48-51)
  • ToolHandlerFunc (42-42)
client/stdio.go (2)
  • StdioMCPClient (22-35)
  • NewStdioMCPClientWithIO (40-59)
mcp/tools.go (1)
  • Tool (69-78)
server/stdio.go (1)
  • NewStdioServer (82-91)
client/client.go (1)
  • MCPClient (13-111)
client/stdio.go (1)
client/types.go (1)
  • RPCResponse (5-8)
mcptest/mcptest_test.go (4)
mcptest/mcptest.go (1)
  • NewServer (35-41)
server/server.go (1)
  • ServerTool (48-51)
mcp/types.go (3)
  • InitializeRequest (266-275)
  • Params (104-104)
  • Content (660-662)
mcp/utils.go (1)
  • NewToolResultText (208-217)
🔇 Additional comments (9)
client/stdio.go (3)

37-59: Well-implemented function for in-process client testing.

This new function allows creating a StdioMCPClient using existing I/O streams instead of launching a subprocess, which is perfect for testing purposes. The implementation correctly initializes all necessary fields and sets up the response reading goroutine.


95-95: Good refactoring of client creation.

Using the newly created NewStdioMCPClientWithIO function here simplifies the code and reduces duplication. This change aligns well with the DRY principle.


109-111: Properly handles null command case.

This addition correctly handles the case where the client was created with NewStdioMCPClientWithIO (where cmd is nil), preventing potential nil pointer dereferences.

mcptest/mcptest_test.go (2)

14-57: Comprehensive test for the new MCP server testing framework.

The test effectively demonstrates the usage of the mcptest package by creating a server with a custom tool, initializing a client connection, and validating the tool's response. This serves as both a functional test and documentation example.


59-67: Well-implemented handler with appropriate default value.

The handler function correctly extracts the name parameter from the request arguments and provides a sensible default value of "World" when the name is missing or not a string.

mcptest/mcptest.go (4)

16-32: Well-structured Server type with all necessary components.

The Server struct encapsulates all the components needed for testing MCP servers, including context management, I/O pipes, and client handling. This design makes it easy to use in tests.


34-41: Convenient helper function for standard test setup.

This function provides a clean API for the common case of creating and starting a server with specified tools, making tests more concise and readable.


43-62: Good separation of server creation and starting.

The NewUnstartedServer function allows adding tools before starting the server, providing flexibility for more complex test setups. The TODO comment about using t.Context() in Go 1.24 shows good forward thinking.


99-110: Thorough cleanup in Close method.

The Close method properly handles cleanup of resources by closing the client and canceling the context. It also checks if they are nil before attempting to close them, preventing potential nil pointer dereferences.

This function allows creating a `*transport.Stdio` using provided
`io.Reader` and `io.Writer`. This allows creating an MCP client to a
server running in the same process, which significanly simplifies
testing.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4e13197 and 785136a.

📒 Files selected for processing (3)
  • client/transport/stdio.go (3 hunks)
  • mcptest/mcptest.go (1 hunks)
  • mcptest/mcptest_test.go (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • mcptest/mcptest_test.go
🧰 Additional context used
🧬 Code Graph Analysis (1)
client/transport/stdio.go (2)
client/transport/interface.go (1)
  • JSONRPCResponse (36-45)
testdata/mockstdio_server.go (1)
  • JSONRPCResponse (18-26)
🪛 golangci-lint (1.64.8)
mcptest/mcptest.go

39-39: Error return value of server.Start is not checked

(errcheck)

🔇 Additional comments (9)
client/transport/stdio.go (4)

36-48: Well-designed constructor for testing scenarios!

This new NewIO constructor follows good dependency injection practices by accepting existing I/O streams instead of spawning a subprocess. This approach makes testing more reliable and controlled by eliminating process management complexity.


71-84: Good refactoring to separate process management from transport initialization.

The refactored Start method now properly delegates subprocess handling to startProc while handling the response reading goroutine separately. This separation of concerns improves maintainability and allows the transport to work without a subprocess when needed.


86-124: Clean extraction of subprocess logic.

The new startProc method nicely encapsulates the subprocess spawning logic, with an appropriate early return when no command is specified. This supports the goal of enabling in-process testing without external processes.


136-142: Properly handles nil subprocess case.

The modified Close method now correctly checks if c.cmd is nil before calling Wait(), which is necessary when using the transport with in-memory I/O streams via NewIO.

mcptest/mcptest.go (5)

17-33: Well-designed Server struct with complete resource management.

The Server struct effectively encapsulates all components needed for in-process MCP testing: context management, I/O pipes, logging, and transport interface. This comprehensive design supports isolated testing environments.


44-63: Good separation between server creation and startup.

The NewUnstartedServer function provides flexibility by allowing tools to be added before starting the server. The comment about using t.Context() in Go 1.24 shows good forward-thinking for future improvements.


78-100: Clean server startup implementation with proper resource management.

The Start method effectively:

  1. Launches the server asynchronously in a goroutine
  2. Sets up logging to an internal buffer
  3. Establishes transport over pipes using the new transport.NewIO constructor

This implementation properly integrates with the transport layer changes and maintains error reporting through the logger.


102-114: Thorough cleanup with nil checks.

The Close method properly releases all resources by closing the transport and canceling the context. The nil checks ensure safe cleanup even if the server wasn't fully initialized or was already closed.


115-118: Simple client accessor simplifies test writing.

This method provides a convenient way to obtain a connected client without having to manage transport setup manually, making test code more concise and focusing on the actual test logic rather than connection setup.

@octo
Copy link
Contributor Author

octo commented Apr 21, 2025

Rebased on main and adapted to use client/transport instead.

The new `mcptest` package provides functions for setting up an in-process MCP
server and an MCP client connected to it. This allows testing MCP
implementations end-to-end without spawning any processes.

The `mcptest` package itself has a unit test that demonstrates how to use the
package.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
mcptest/mcptest.go (3)

54-55: Consider using t.Cleanup() until Go 1.24

While waiting for Go 1.24's t.Context(), you could leverage t.Cleanup() to automatically close the server when the test completes:

 // Use t.Context() once we switch to go >= 1.24
 ctx := context.TODO()
+
+// Register cleanup to ensure proper resource management
+t.Cleanup(func() {
+    if server.cancel != nil {
+        server.cancel()
+    }
+})

This ensures resources are released even if a test panics or forgets to call Close().


85-98: Consider a mechanism to communicate server startup errors

The goroutine logs errors from stdioServer.Listen() but has no way to communicate these errors back to the caller. This could make debugging test failures difficult since crucial errors might only appear in logs.

Consider using a channel to propagate errors back to the caller:

 func (s *Server) Start() error {
+    errChan := make(chan error, 1)
     // Start the MCP server in a goroutine
     go func() {
         mcpServer := server.NewMCPServer(s.name, "1.0.0")
 
         mcpServer.AddTools(s.tools...)
 
         logger := log.New(&s.logBuffer, "", 0)
 
         stdioServer := server.NewStdioServer(mcpServer)
         stdioServer.SetErrorLogger(logger)
 
         if err := stdioServer.Listen(s.ctx, s.serverReader, s.serverWriter); err != nil {
             logger.Println("StdioServer.Listen failed:", err)
+            select {
+            case errChan <- err:
+            default:
+            }
         }
     }()
 
     s.transport = transport.NewIO(s.clientReader, s.clientWriter, io.NopCloser(&s.logBuffer))
 
-    return s.transport.Start(s.ctx)
+    if err := s.transport.Start(s.ctx); err != nil {
+        return err
+    }
+    
+    // Check if server setup failed
+    select {
+    case err := <-errChan:
+        return err
+    default:
+        return nil
+    }
 }

118-121: Access to server logs could be beneficial

The Server struct captures logs in logBuffer, but there's no method to access these logs. Consider adding a Logs() method to retrieve server logs for debugging test failures:

// Client returns an MCP client connected to the server.
func (s *Server) Client() client.MCPClient {
	return client.NewClient(s.transport)
}

+// Logs returns the captured server logs as a string.
+func (s *Server) Logs() string {
+	return s.logBuffer.String()
+}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 785136a and fa96194.

📒 Files selected for processing (2)
  • mcptest/mcptest.go (1 hunks)
  • mcptest/mcptest_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • mcptest/mcptest_test.go
🧰 Additional context used
🧬 Code Graph Analysis (1)
mcptest/mcptest.go (7)
server/server.go (2)
  • ServerTool (48-51)
  • ToolHandlerFunc (42-42)
client/transport/interface.go (1)
  • Interface (11-27)
mcp/tools.go (1)
  • Tool (69-80)
server/stdio.go (1)
  • NewStdioServer (82-91)
client/transport/stdio.go (1)
  • NewIO (39-48)
client/client.go (2)
  • Client (16-24)
  • NewClient (34-38)
client/interface.go (1)
  • MCPClient (11-109)
🔇 Additional comments (2)
mcptest/mcptest.go (2)

36-45: Fixed error handling in NewServer

The function now correctly checks and returns errors from server.Start(), addressing the issue flagged in the previous review. This is a good improvement that prevents silent failures during test setup.


1-122: Overall implementation looks solid

The mcptest package successfully implements in-process MCP testing without spawning external processes. It provides a clean API for creating servers, adding tools, and connecting clients. The use of paired pipes for client-server communication is an elegant approach.

The implementation aligns well with the PR objectives of simplifying testing of MCP implementations and enabling full request/response cycle testing in a controlled environment.

octo added 3 commits April 22, 2025 11:20
This allows to omit the initialization code in tests, making them less verbose.
@octo
Copy link
Contributor Author

octo commented Apr 28, 2025

@ezynda3 Could you help me understand how to move this PR forward? Thank you!

@ezynda3 ezynda3 merged commit 5a5781d into mark3labs:main Apr 28, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants