Skip to content

Feature Request: JSON-RPC Support for Interactive Process Communication #18

@StevenTCramer

Description

@StevenTCramer

Feature Request: JSON-RPC Support for Interactive Process Communication

Problem Statement

When working with processes that use JSON-RPC for communication (particularly Model Context Protocol (MCP) servers), the current approach requires verbose Process.Start() code with manual stream management, timeout handling, and request/response correlation. This defeats Amuru's core mission of eliminating process execution verbosity.

Current Pain Points

Here's a real example from testing an MCP server (like the one in TimeWarp.Nuru.Mcp):

// Current verbose approach - 200+ lines for basic JSON-RPC communication
ProcessStartInfo psi = new()
{
    FileName = "dotnet",
    Arguments = $"run --project \"{mcpPath}\" -c Release",
    RedirectStandardInput = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    UseShellExecute = false,
    CreateNoWindow = true
};

using var process = Process.Start(psi);
if (process is null)
{
    WriteLine("❌ Failed to start MCP server");
    return 1;
}

try
{
    // Manual delay for startup
    await Task.Delay(2000);
    
    // Send JSON-RPC request
    string initRequest = """
    {"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"1.0.0","clientInfo":{"name":"test-client","version":"1.0.0"}},"id":1}
    """;
    
    await process.StandardInput.WriteLineAsync(initRequest);
    await process.StandardInput.FlushAsync();
    
    // Read response with manual timeout handling
    string? response = null;
    using (CancellationTokenSource cts = new(TimeSpan.FromSeconds(5)))
    {
        Task<string?> readTask = Task.Run(() => process.StandardOutput.ReadLineAsync());
        
        try
        {
            response = await readTask.WaitAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            WriteLine("⚠️ Timeout waiting for response");
        }
    }
    
    if (response is not null)
    {
        // Manual JSON parsing
        var json = JsonDocument.Parse(response);
        
        // Check for result or error
        if (json.RootElement.TryGetProperty("result", out JsonElement result))
        {
            // Handle success
        }
        else if (json.RootElement.TryGetProperty("error", out JsonElement error))
        {
            // Handle error
        }
    }
    
    // Send another request
    string listToolsRequest = """
    {"jsonrpc":"2.0","method":"tools/list","id":2}
    """;
    
    await process.StandardInput.WriteLineAsync(listToolsRequest);
    // ... more manual handling ...
}
finally
{
    // Manual cleanup
    process.Kill();
    await process.WaitForExitAsync();
}

The Problem at Scale

This pattern becomes especially problematic when:

  1. Testing MCP servers - Every test needs this boilerplate
  2. Building MCP clients - Applications that consume MCP tools need robust communication
  3. Integration testing - Multiple round-trips of JSON-RPC communication
  4. Language servers - LSP also uses JSON-RPC over stdio
  5. Any interactive process protocol - Many modern tools use JSON-RPC for IPC

Proposed Solution

Add first-class JSON-RPC support to Amuru that handles the entire lifecycle of interactive process communication:

// Proposed Amuru API
var client = await Shell.Builder("dotnet")
    .WithArguments("run", "--project", mcpPath, "-c", "Release")
    .AsJsonRpcClient()
    .StartAsync();

// Simple request/response
var initResponse = await client.SendRequestAsync("initialize", new {
    protocolVersion = "1.0.0",
    clientInfo = new { name = "test-client", version = "1.0.0" }
});

// List available tools
var toolsResponse = await client.SendRequestAsync("tools/list");

// Call a tool with parameters
var result = await client.SendRequestAsync("tools/call", new {
    name = "get_documentation",
    arguments = new { documentName = "Architecture" }
});

// Handle notifications (no response expected)
await client.SendNotificationAsync("initialized");

// Clean shutdown
await client.DisposeAsync();

Detailed API Design

public interface IJsonRpcClient : IAsyncDisposable
{
    // Send request and wait for response
    Task<JsonRpcResponse<TResult>> SendRequestAsync<TResult>(
        string method, 
        object? parameters = null,
        CancellationToken cancellationToken = default);
    
    // Send request with dynamic response
    Task<JsonRpcResponse> SendRequestAsync(
        string method, 
        object? parameters = null,
        CancellationToken cancellationToken = default);
    
    // Send notification (no response expected)
    Task SendNotificationAsync(
        string method, 
        object? parameters = null,
        CancellationToken cancellationToken = default);
    
    // Event for unsolicited notifications from server
    event EventHandler<JsonRpcNotification>? NotificationReceived;
    
    // Process health
    bool IsRunning { get; }
    Task<int> WaitForExitAsync(CancellationToken cancellationToken = default);
}

public class JsonRpcResponse
{
    public bool IsSuccess { get; }
    public JsonElement? Result { get; }
    public JsonRpcError? Error { get; }
    public int Id { get; }
}

public class JsonRpcResponse<T> : JsonRpcResponse
{
    public new T? Result { get; }
}

public class JsonRpcError
{
    public int Code { get; }
    public string Message { get; }
    public JsonElement? Data { get; }
}

Builder Extensions

public static class ShellBuilderJsonRpcExtensions
{
    public static JsonRpcClientBuilder AsJsonRpcClient(this RunBuilder builder)
    {
        return new JsonRpcClientBuilder(builder);
    }
}

public class JsonRpcClientBuilder
{
    public JsonRpcClientBuilder WithTimeout(TimeSpan timeout);
    public JsonRpcClientBuilder WithStartupDelay(TimeSpan delay);
    public JsonRpcClientBuilder WithEncoding(Encoding encoding);
    public JsonRpcClientBuilder WithJsonSerializerOptions(JsonSerializerOptions options);
    public JsonRpcClientBuilder WithRequestIdGenerator(Func<int> generator);
    
    public Task<IJsonRpcClient> StartAsync(CancellationToken cancellationToken = default);
}

Implementation Considerations

1. Request/Response Correlation

  • Automatic ID generation and correlation
  • Support for concurrent requests
  • Proper request queuing

2. Stream Management

  • Handle newline-delimited JSON (most common for stdio)
  • Support for Content-Length headers (LSP style)
  • Buffering for partial messages

3. Error Handling

  • Process crash detection
  • Timeout handling per request
  • Malformed JSON handling
  • Connection state management

4. Lifecycle Management

  • Graceful shutdown protocols
  • Force kill as fallback
  • Resource cleanup

5. Testing Support

// Should also provide testing utilities
var mockClient = new MockJsonRpcClient();
mockClient.SetupRequest("initialize").ReturnsJson(new { 
    capabilities = new { tools = true }
});

Real-World Use Cases

MCP Server Testing

var mcp = await Shell.Builder("mcp-server")
    .AsJsonRpcClient()
    .StartAsync();

var init = await mcp.SendRequestAsync("initialize", new {
    protocolVersion = "1.0.0"
});

var tools = await mcp.SendRequestAsync("tools/list");
foreach (var tool in tools.Result.GetProperty("tools").EnumerateArray())
{
    var name = tool.GetProperty("name").GetString();
    var result = await mcp.SendRequestAsync("tools/call", new {
        name = name,
        arguments = new { }
    });
}

Language Server Protocol

var lsp = await Shell.Builder("typescript-language-server")
    .WithArguments("--stdio")
    .AsJsonRpcClient()
    .StartAsync();

await lsp.SendRequestAsync("initialize", new {
    processId = Process.GetCurrentProcess().Id,
    rootUri = "file:///workspace",
    capabilities = new { }
});

await lsp.SendNotificationAsync("initialized");

var symbols = await lsp.SendRequestAsync("textDocument/documentSymbol", new {
    textDocument = new { uri = "file:///workspace/main.ts" }
});

Custom Protocol Testing

var service = await Shell.Builder("./my-service")
    .AsJsonRpcClient()
    .WithTimeout(TimeSpan.FromSeconds(30))
    .StartAsync();

// Service-specific protocol
var auth = await service.SendRequestAsync("auth.login", new {
    username = "test",
    password = "secret"
});

var token = auth.Result.GetProperty("token").GetString();

var data = await service.SendRequestAsync("data.query", new {
    token = token,
    query = "SELECT * FROM users"
});

Benefits

  1. Reduces boilerplate by 90% - From 200+ lines to 10-20 lines
  2. Type safety - Generic response types with proper deserialization
  3. Robust error handling - Built-in timeout, retry, and error handling
  4. Testability - Easy to mock for unit tests
  5. Reusability - Common pattern for many modern tools
  6. Consistency - Follows Amuru's existing patterns

Alternative Considerations

Streaming Responses

Some JSON-RPC servers send streaming responses. Consider support for:

await foreach (var chunk in client.StreamRequestAsync("generate", new { prompt = "..." }))
{
    Console.Write(chunk.Result);
}

Batch Requests

JSON-RPC supports batch requests:

var responses = await client.SendBatchAsync(
    new JsonRpcRequest("method1", new { }),
    new JsonRpcRequest("method2", new { }),
    new JsonRpcRequest("method3", new { })
);

Conclusion

Adding JSON-RPC support to Amuru would significantly reduce verbosity when working with interactive processes, particularly MCP servers which are becoming increasingly important in the AI tooling ecosystem. This feature would make Amuru the go-to solution for process communication in .NET, extending its value beyond simple command execution to complex interactive protocols.

The implementation would follow Amuru's existing patterns and philosophy while providing a robust, type-safe, and testable API for JSON-RPC communication.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions