mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
feat: add typed MCP mux registration
This commit is contained in:
parent
55ef36d93f
commit
799df9d507
100
mcp/server20260728/mux.go
Normal file
100
mcp/server20260728/mux.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package mcpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mcptypes "github.com/therootcompany/golib/mcp/types20260728"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolExecution is a decoded tool invocation. Middleware may inspect the
|
||||||
|
// request headers and context, then call next to run the typed handler.
|
||||||
|
type ToolExecution func(context.Context, Headers) (mcptypes.CallToolResult, error)
|
||||||
|
|
||||||
|
// ToolMiddleware wraps decoded tool execution. It is suitable for middleware
|
||||||
|
// such as authentication that does not need the tool's argument type.
|
||||||
|
type ToolMiddleware func(ToolExecution) ToolExecution
|
||||||
|
|
||||||
|
// Registration is implemented by package-provided registration values such as
|
||||||
|
// ToolHandler[T]. Its method is intentionally private to keep registration
|
||||||
|
// behavior under package control.
|
||||||
|
type Registration interface {
|
||||||
|
register(*MCPServer, []ToolMiddleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolHandler pairs a tool definition with a validated Go argument type and
|
||||||
|
// typed handler. Use ToolHandler[T].With for middleware that needs T.
|
||||||
|
type ToolHandler[T mcptypes.Validatable] struct {
|
||||||
|
Tool mcptypes.Tool
|
||||||
|
Handle TypedToolHandler[T]
|
||||||
|
Middleware []TypedToolMiddleware[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
// With returns a copy with typed middleware appended.
|
||||||
|
func (h ToolHandler[T]) With(middleware ...TypedToolMiddleware[T]) ToolHandler[T] {
|
||||||
|
h.Middleware = append(append([]TypedToolMiddleware[T](nil), h.Middleware...), middleware...)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h ToolHandler[T]) toolName() string { return h.Tool.Name }
|
||||||
|
|
||||||
|
func (h ToolHandler[T]) register(server *MCPServer, global []ToolMiddleware) {
|
||||||
|
typed := h.Handle
|
||||||
|
for i := len(h.Middleware) - 1; i >= 0; i-- {
|
||||||
|
typed = h.Middleware[i](typed)
|
||||||
|
}
|
||||||
|
server.RegisterTool(h.Tool, func(ctx context.Context, request CallToolRequest) (mcptypes.CallToolResult, error) {
|
||||||
|
args, err := mcptypes.DecodeValidated[T](request.Params.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return mcptypes.CallToolResult{}, fmt.Errorf("decode %s arguments: %w", h.Tool.Name, err)
|
||||||
|
}
|
||||||
|
execution := ToolExecution(func(ctx context.Context, headers Headers) (mcptypes.CallToolResult, error) {
|
||||||
|
return typed(ctx, headers, args)
|
||||||
|
})
|
||||||
|
for i := len(global) - 1; i >= 0; i-- {
|
||||||
|
execution = global[i](execution)
|
||||||
|
}
|
||||||
|
return execution(ctx, request.Headers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mux groups registrations on an MCPServer and creates middleware scopes.
|
||||||
|
type Mux struct {
|
||||||
|
server *MCPServer
|
||||||
|
middleware []ToolMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMux(server *MCPServer) *Mux { return &Mux{server: server} }
|
||||||
|
|
||||||
|
// With returns a new mux with additional middleware appended.
|
||||||
|
func (m *Mux) With(middleware ...ToolMiddleware) *Mux {
|
||||||
|
combined := make([]ToolMiddleware, 0, len(m.middleware)+len(middleware))
|
||||||
|
combined = append(combined, m.middleware...)
|
||||||
|
combined = append(combined, middleware...)
|
||||||
|
return &Mux{server: m.server, middleware: combined}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a complete typed tool handler. The method string is a
|
||||||
|
// wiring label and must be tools/<tool name>, matching the name on the wire.
|
||||||
|
func (m *Mux) Register(method string, handler Registration) {
|
||||||
|
if !strings.HasPrefix(method, "tools/") {
|
||||||
|
panic("mcp: tool registered without tools/ prefix: " + method)
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(method, "tools/")
|
||||||
|
if tool, ok := handler.(interface{ toolName() string }); ok && tool.toolName() != name {
|
||||||
|
panic(fmt.Sprintf("mcp: tool name mismatch: Register(%q) but Tool.Name is %q", method, tool.toolName()))
|
||||||
|
}
|
||||||
|
handler.register(m.server, m.middleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTool registers a raw handler for protocol-level use. Typed tools
|
||||||
|
// should use Register with ToolHandler[T].
|
||||||
|
func (m *Mux) RegisterTool(definition mcptypes.Tool, handler ToolHandlerFunc) {
|
||||||
|
m.server.RegisterTool(definition, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterResource registers a resource read handler.
|
||||||
|
func (m *Mux) RegisterResource(definition mcptypes.Resource, handler ResourceHandlerFunc) {
|
||||||
|
m.server.RegisterResource(definition, handler)
|
||||||
|
}
|
||||||
@ -27,7 +27,7 @@ func TestServeHTTPUsesHeadersBeforeTypedDecode(t *testing.T) {
|
|||||||
var got Headers
|
var got Headers
|
||||||
var gotArgs echoArgs
|
var gotArgs echoArgs
|
||||||
srv := &MCPServer{Name: "test", Version: "1"}
|
srv := &MCPServer{Name: "test", Version: "1"}
|
||||||
RegisterTypedTool(srv, mcptypes.Tool{Name: "echo", InputSchema: json.RawMessage(`{"type":"object"}`)}, func(_ context.Context, headers Headers, args echoArgs) (mcptypes.CallToolResult, error) {
|
RegisterTypedTool(srv, mcptypes.Tool{Name: "echo", InputSchema: mcptypes.ToolInputSchema{Type: "object"}}, func(_ context.Context, headers Headers, args echoArgs) (mcptypes.CallToolResult, error) {
|
||||||
got = headers
|
got = headers
|
||||||
gotArgs = args
|
gotArgs = args
|
||||||
return mcptypes.CallToolResult{ResultType: "complete", Content: []mcptypes.Content{{Type: "text", Text: args.Message}}}, nil
|
return mcptypes.CallToolResult{ResultType: "complete", Content: []mcptypes.Content{{Type: "text", Text: args.Message}}}, nil
|
||||||
@ -49,27 +49,27 @@ func TestServeHTTPUsesHeadersBeforeTypedDecode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTypedMiddlewareReceivesValidatedArguments(t *testing.T) {
|
func TestMuxWithRunsMiddlewareAfterTypedValidation(t *testing.T) {
|
||||||
srv := &MCPServer{}
|
srv := &MCPServer{}
|
||||||
called := false
|
called := false
|
||||||
middleware := func(next TypedToolHandler[echoArgs]) TypedToolHandler[echoArgs] {
|
auth := func(next ToolExecution) ToolExecution {
|
||||||
return func(ctx context.Context, headers Headers, args echoArgs) (mcptypes.CallToolResult, error) {
|
return func(ctx context.Context, headers Headers) (mcptypes.CallToolResult, error) {
|
||||||
if args.Message != "hello" {
|
|
||||||
t.Fatalf("middleware args = %#v", args)
|
|
||||||
}
|
|
||||||
called = true
|
called = true
|
||||||
return next(ctx, headers, args)
|
return next(ctx, headers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RegisterTypedToolWithMiddleware(srv, mcptypes.Tool{Name: "echo"}, func(context.Context, Headers, echoArgs) (mcptypes.CallToolResult, error) {
|
NewMux(srv).With(auth).Register("tools/echo", ToolHandler[echoArgs]{
|
||||||
return mcptypes.NewToolResultText("ok"), nil
|
Tool: mcptypes.Tool{Name: "echo"},
|
||||||
}, middleware)
|
Handle: func(context.Context, Headers, echoArgs) (mcptypes.CallToolResult, error) {
|
||||||
|
return mcptypes.NewToolResultText("ok"), nil
|
||||||
|
},
|
||||||
|
})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}`))
|
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}`))
|
||||||
req.Header.Set("Mcp-Method", "tools/call")
|
req.Header.Set("Mcp-Method", "tools/call")
|
||||||
req.Header.Set("Mcp-Name", "echo")
|
req.Header.Set("Mcp-Name", "echo")
|
||||||
srv.ServeHTTP(httptest.NewRecorder(), req)
|
srv.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
if !called {
|
if !called {
|
||||||
t.Fatal("typed middleware was not called")
|
t.Fatal("middleware was not called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -330,10 +330,29 @@ type Tool struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
InputSchema json.RawMessage `json:"inputSchema"`
|
InputSchema ToolInputSchema `json:"inputSchema"`
|
||||||
OutputSchema json.RawMessage `json:"outputSchema,omitempty"`
|
OutputSchema *ToolInputSchema `json:"outputSchema,omitempty"`
|
||||||
Annotations *ToolAnnotations `json:"annotations,omitempty"`
|
Annotations *ToolAnnotations `json:"annotations,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToolInputSchema describes the JSON shape advertised to MCP clients. It is
|
||||||
|
// documentation for clients; DecodeValidated and the Go input type enforce
|
||||||
|
// server-side correctness.
|
||||||
|
type ToolInputSchema struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Properties map[string]Property `json:"properties,omitempty"`
|
||||||
|
Required []string `json:"required,omitempty"`
|
||||||
|
Items *ToolInputSchema `json:"items,omitempty"`
|
||||||
|
AdditionalProperties *bool `json:"additionalProperties,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Property struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Enum []string `json:"enum,omitempty"`
|
||||||
|
Items *ToolInputSchema `json:"items,omitempty"`
|
||||||
|
}
|
||||||
type ToolAnnotations struct {
|
type ToolAnnotations struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
|
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user