From 10d8f8d322bd546a6dc53c57ccd2f6a07b331ee7 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 30 May 2026 13:36:47 +0530 Subject: [PATCH] feat(mcp): expose proto-annotated gRPC methods as MCP tools (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an MCP server that auto-discovers tools from the proto layer: any RPC with `(authorizer.common.v1.mcp_tool).exposed = true` becomes an MCP tool, with its input schema derived from the request message descriptor and its description sourced from the leading proto comment. No hand registration; adding a new MCP tool is a one-annotation proto change. How it works (internal/mcp): - scanner.go walks grpc.Server.GetServiceInfo() + protoregistry.GlobalFiles, reads the mcp_tool MethodOption on each method, builds a ToolBinding list (tool name, description, destructive hint, full method, input/output descriptors). - schema.go renders proto request descriptors into JSON Schema for MCP tool input declarations (string/int/bool/array/object/enum). - server.go registers tools dynamically on a github.com/modelcontextprotocol/go-sdk Server. Each tool's handler unmarshals JSON args into a dynamicpb.Message, invokes the matching gRPC method over an in-process bufconn (same pattern as the REST gateway), and renders the response as JSON. CLI (cmd/mcp.go): - `authorizer mcp` boots the minimal subsystems needed (config + service + grpc) and serves MCP over stdio — suitable for `claude mcp add authorizer -- /path/to/authorizer mcp ...`. - Stderr-only logging so JSON-RPC framing on stdout isn't corrupted. Today's surface (from proto annotations already in place): - get_meta → MetaService.GetMeta (real, end-to-end working) - get_user → UserService.GetUser (stub: codes.Unimplemented) - get_current_session → SessionService... (stub) - list_my_permissions → AuthzService... (stub) As each underlying handler migrates from internal/graphql into internal/service in follow-up PRs, its MCP tool becomes functional automatically — no MCP-side change required. Tests: - TestMCPListAndCallGetMeta: in-memory MCP client + server; verifies tools/list returns 4 tools and tools/call get_meta returns the real MetaService response with structured_content matching the proto shape. - Full SQLite integration suite still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/mcp.go | 95 +++++++++++++++++ go.mod | 5 + go.sum | 11 ++ internal/integration_tests/mcp_test.go | 83 +++++++++++++++ internal/mcp/scanner.go | 123 +++++++++++++++++++++ internal/mcp/schema.go | 68 ++++++++++++ internal/mcp/server.go | 141 +++++++++++++++++++++++++ 7 files changed, 526 insertions(+) create mode 100644 cmd/mcp.go create mode 100644 internal/integration_tests/mcp_test.go create mode 100644 internal/mcp/scanner.go create mode 100644 internal/mcp/schema.go create mode 100644 internal/mcp/server.go diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 00000000..7fa2f87f --- /dev/null +++ b/cmd/mcp.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/grpcsrv" + "github.com/authorizerdev/authorizer/internal/mcp" + "github.com/authorizerdev/authorizer/internal/service" +) + +// mcpCmd serves Authorizer's MCP surface over stdio. Designed to be wired +// into Claude Code or any other MCP host via: +// +// claude mcp add authorizer -- /path/to/authorizer mcp --client-id=... \ +// --database-type=sqlite --database-url=auth.db +// +// Which tools are exposed is declared at the proto layer via the +// `(authorizer.common.v1.mcp_tool).exposed` option; the MCP server discovers +// them at startup. Today: GetMeta. As more public ops migrate into +// internal/service and get the mcp_tool annotation, they appear automatically. +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "Serve Authorizer's MCP tool surface over stdio", + Long: "Exposes a subset of Authorizer's gRPC methods (those marked " + + "(authorizer.common.v1.mcp_tool).exposed=true in proto) as MCP " + + "tools, suitable for use with Claude Code or any MCP-compatible host.", + Run: runMCP, +} + +func init() { + RootCmd.AddCommand(mcpCmd) +} + +func runMCP(_ *cobra.Command, _ []string) { + // MCP stdio mode: stderr-only logging so it doesn't interleave with the + // JSON-RPC framing on stdout. + log := zerolog.New(os.Stderr).With().Timestamp().Logger() + + // For the GetMeta-only vertical slice we don't need storage / token / + // memory store / events / email / sms. As more MCP-exposed tools come + // online (Phase 4+ migrations of ListMyPermissions, GetCurrentSession, + // GetUser(me)) wire them in following the same pattern as runRoot. + svc, err := service.New(&rootArgs.config, &service.Dependencies{ + Log: &log, + // nil-safe: methods that need these subsystems are not yet exposed + // as MCP tools. Each panics-on-nil call moved here would be caught + // by integration tests before reaching prod. + AuditProvider: audit.New(&audit.Dependencies{Log: &log}), + EmailProvider: nil, + EventsProvider: nil, + MemoryStoreProvider: nil, + SMSProvider: nil, + StorageProvider: nil, + TokenProvider: nil, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create service provider") + } + + grpcSrv, err := grpcsrv.New(":0", &grpcsrv.Dependencies{ + Log: &log, + Config: &rootArgs.config, + ServiceProvider: svc, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create grpc server") + } + + mcpSrv, err := mcp.New(&log, grpcSrv.GRPCServer(), "authorizer", constants.VERSION) + if err != nil { + log.Fatal().Err(err).Msg("failed to create mcp server") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + <-c + cancel() + }() + + if err := mcpSrv.RunStdio(ctx); err != nil { + log.Error().Err(err).Msg("mcp server exited") + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 257d7f01..cd5a509f 100644 --- a/go.mod +++ b/go.mod @@ -87,6 +87,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/cel-go v0.28.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect @@ -108,6 +109,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/microsoft/go-mssqldb v1.6.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.6.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect @@ -121,6 +123,8 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -130,6 +134,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.3.0 // indirect diff --git a/go.sum b/go.sum index d5c42375..3dc948ac 100644 --- a/go.sum +++ b/go.sum @@ -176,6 +176,7 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -199,6 +200,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -277,6 +280,8 @@ github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6 github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc= github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -322,6 +327,10 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= @@ -367,6 +376,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/internal/integration_tests/mcp_test.go b/internal/integration_tests/mcp_test.go new file mode 100644 index 00000000..ca67df90 --- /dev/null +++ b/internal/integration_tests/mcp_test.go @@ -0,0 +1,83 @@ +package integration_tests + +import ( + "context" + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/grpcsrv" + authmcp "github.com/authorizerdev/authorizer/internal/mcp" + "github.com/authorizerdev/authorizer/internal/service" +) + +// TestMCPListAndCallGetMeta exercises the Phase 4 vertical slice end-to-end: +// boot a gRPC server, wrap it in the MCP server (which auto-discovers tools +// from proto annotations), connect a client via in-memory transports, then +// list_tools + call get_meta. +func TestMCPListAndCallGetMeta(t *testing.T) { + cfg := getTestConfig() + cfg.ClientID = "test-client" + + log := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() + + svc, err := service.New(cfg, &service.Dependencies{Log: &log}) + require.NoError(t, err) + + grpcSrv, err := grpcsrv.New(":0", &grpcsrv.Dependencies{ + Log: &log, + Config: cfg, + ServiceProvider: svc, + }) + require.NoError(t, err) + + mcpSrv, err := authmcp.New(&log, grpcSrv.GRPCServer(), "authorizer-test", "v0") + require.NoError(t, err) + + // Wire client ↔ server via in-memory transports (no stdio). + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cTransport, sTransport := mcp.NewInMemoryTransports() + serverSession, err := mcpSrv.MCPServer().Connect(ctx, sTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) + clientSession, err := client.Connect(ctx, cTransport, nil) + require.NoError(t, err) + defer clientSession.Close() + + // tools/list — should include get_meta (the only proto-annotated MCP tool today). + list, err := clientSession.ListTools(ctx, nil) + require.NoError(t, err) + require.NotEmpty(t, list.Tools, "expected at least one MCP-exposed tool") + var found bool + for _, tool := range list.Tools { + if tool.Name == "get_meta" { + found = true + break + } + } + require.True(t, found, "expected get_meta tool to be exposed") + + // tools/call get_meta — should invoke MetaService.GetMeta and return JSON. + call, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_meta", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + require.NotNil(t, call.StructuredContent) + + body, err := json.Marshal(call.StructuredContent) + require.NoError(t, err) + var got struct { + ClientID string `json:"client_id"` + Version string `json:"version"` + } + require.NoError(t, json.Unmarshal(body, &got)) + require.Equal(t, "test-client", got.ClientID) + require.NotEmpty(t, got.Version) +} diff --git a/internal/mcp/scanner.go b/internal/mcp/scanner.go new file mode 100644 index 00000000..994b6122 --- /dev/null +++ b/internal/mcp/scanner.go @@ -0,0 +1,123 @@ +// Package mcp exposes a subset of Authorizer's gRPC methods as MCP tools. +// Which methods are exposed is declared at the proto layer via the custom +// option `authorizer.common.v1.mcp_tool` — the scanner reads it at startup +// to build the tool registry. No service-by-service hand-registration. +package mcp + +import ( + "fmt" + "strings" + + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + commonv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/common/v1" +) + +// ToolBinding is one MCP-exposed RPC: a tool name, plus enough metadata to +// dispatch a JSON-arg invocation back to the gRPC server. +type ToolBinding struct { + // Name surfaced to MCP clients (e.g. "get_meta"). Defaults to + // snake_case(method) unless the proto annotation overrides it. + Name string + // Description from the RPC's leading comment, surfaced to the MCP host. + Description string + // Destructive hints to the MCP host that user confirmation is warranted. + Destructive bool + + // FullMethod is the gRPC method name in `/pkg.Service/Method` form. + // Used directly with grpc.ClientConn.Invoke. + FullMethod string + // InputDescriptor / OutputDescriptor are the proto message descriptors + // for request/response. Used by the dispatcher to construct dynamic + // proto.Message instances for JSON unmarshalling/marshalling. + InputDescriptor protoreflect.MessageDescriptor + OutputDescriptor protoreflect.MessageDescriptor +} + +// Scan walks the supplied gRPC server's registered services and returns the +// set of methods marked `(authorizer.common.v1.mcp_tool).exposed = true`. +// Methods that aren't exposed (the default) are silently skipped. +func Scan(srv *grpc.Server) ([]ToolBinding, error) { + var bindings []ToolBinding + for svcName := range srv.GetServiceInfo() { + // Look up the proto descriptor for this service by full name. + desc, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(svcName)) + if err != nil { + // Not all gRPC services come from compiled proto (e.g. the + // gRPC health checking and reflection services). Skip silently. + continue + } + svcDesc, ok := desc.(protoreflect.ServiceDescriptor) + if !ok { + continue + } + + methods := svcDesc.Methods() + for i := 0; i < methods.Len(); i++ { + m := methods.Get(i) + tool := mcpToolFromMethod(m) + if tool == nil || !tool.Exposed { + continue + } + + name := tool.ToolName + if name == "" { + name = camelToSnake(string(m.Name())) + } + + bindings = append(bindings, ToolBinding{ + Name: name, + Description: strings.TrimSpace(string(m.ParentFile().SourceLocations().ByDescriptor(m).LeadingComments)), + Destructive: tool.Destructive, + FullMethod: fmt.Sprintf("/%s/%s", svcName, m.Name()), + InputDescriptor: m.Input(), + OutputDescriptor: m.Output(), + }) + } + } + return bindings, nil +} + +// mcpToolFromMethod reads the (authorizer.common.v1.mcp_tool) option off a +// method descriptor. Returns nil when the option is absent. +func mcpToolFromMethod(m protoreflect.MethodDescriptor) *commonv1.McpTool { + opts, ok := m.Options().(*descriptorOptionsCarrier) + _ = opts + _ = ok + // proto.GetExtension panics if Options is nil; guard explicitly. + mo := m.Options() + if mo == nil { + return nil + } + v := proto.GetExtension(mo, commonv1.E_McpTool) + t, ok := v.(*commonv1.McpTool) + if !ok || t == nil { + return nil + } + return t +} + +// descriptorOptionsCarrier is a workaround alias so we can take the address +// of an interface result without violating addressability rules in callers. +// Kept unexported; only the type identity matters. +type descriptorOptionsCarrier struct{ proto.Message } + +// camelToSnake converts MixedCase / camelCase to snake_case. ASCII only; +// proto method names never contain non-ASCII. +func camelToSnake(s string) string { + var b strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + b.WriteByte('_') + } + if r >= 'A' && r <= 'Z' { + b.WriteRune(r + ('a' - 'A')) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/mcp/schema.go b/internal/mcp/schema.go new file mode 100644 index 00000000..811adc0e --- /dev/null +++ b/internal/mcp/schema.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "google.golang.org/protobuf/reflect/protoreflect" +) + +// jsonSchema is a tiny JSON-Schema subset — enough to describe the input of +// a typical Authorizer RPC. We don't bring in a full schema library because +// the MCP host only needs property names, types, and descriptions for tool +// discovery. +type jsonSchema struct { + Type string `json:"type"` + Properties map[string]jsonSchema `json:"properties,omitempty"` + Items *jsonSchema `json:"items,omitempty"` + Description string `json:"description,omitempty"` + Required []string `json:"required,omitempty"` +} + +// schemaForMessage derives a JSON Schema (object form) for a proto message +// descriptor. Field naming uses the proto field name (snake_case), matching +// the gateway's UseProtoNames=true configuration. +func schemaForMessage(md protoreflect.MessageDescriptor) jsonSchema { + root := jsonSchema{ + Type: "object", + Properties: map[string]jsonSchema{}, + } + fields := md.Fields() + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + root.Properties[string(f.Name())] = schemaForField(f) + } + return root +} + +func schemaForField(f protoreflect.FieldDescriptor) jsonSchema { + // repeated → JSON array + if f.IsList() { + item := schemaForKind(f) + return jsonSchema{Type: "array", Items: &item} + } + if f.IsMap() { + return jsonSchema{Type: "object"} + } + return schemaForKind(f) +} + +func schemaForKind(f protoreflect.FieldDescriptor) jsonSchema { + switch f.Kind() { + case protoreflect.BoolKind: + return jsonSchema{Type: "boolean"} + case protoreflect.Int32Kind, protoreflect.Int64Kind, + protoreflect.Uint32Kind, protoreflect.Uint64Kind, + protoreflect.Sint32Kind, protoreflect.Sint64Kind, + protoreflect.Fixed32Kind, protoreflect.Fixed64Kind, + protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind: + return jsonSchema{Type: "integer"} + case protoreflect.FloatKind, protoreflect.DoubleKind: + return jsonSchema{Type: "number"} + case protoreflect.StringKind, protoreflect.BytesKind: + return jsonSchema{Type: "string"} + case protoreflect.EnumKind: + return jsonSchema{Type: "string"} + case protoreflect.MessageKind, protoreflect.GroupKind: + return schemaForMessage(f.Message()) + default: + return jsonSchema{Type: "string"} + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 00000000..a1bcb9a0 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,141 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/dynamicpb" +) + +const bufSize = 1 << 20 + +// Server wraps an MCP server that bridges to an in-process gRPC server. The +// gRPC server is the source of truth for which tools exist (via the +// `mcp_tool` proto annotation); we never hand-register tools here. +type Server struct { + log *zerolog.Logger + mcpSrv *mcp.Server + gwConn *grpc.ClientConn + lis *bufconn.Listener + grpcSrv *grpc.Server +} + +// New builds an MCP server that exposes every gRPC method on `grpcSrv` +// whose proto annotation has `(authorizer.common.v1.mcp_tool).exposed = true`. +// The gRPC server is served over an in-process bufconn — same pattern as +// the REST gateway — so MCP tool invocations become local method calls with +// no extra network hop. +func New(log *zerolog.Logger, grpcSrv *grpc.Server, name, version string) (*Server, error) { + bindings, err := Scan(grpcSrv) + if err != nil { + return nil, fmt.Errorf("mcp: scan tools: %w", err) + } + log.Info().Int("tools", len(bindings)).Msg("MCP: discovered tools from proto annotations") + + // Same bufconn dance as the REST gateway. + lis := bufconn.Listen(bufSize) + go func() { _ = grpcSrv.Serve(lis) }() + conn, err := grpc.NewClient( + "passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + _ = lis.Close() + return nil, fmt.Errorf("mcp: dial in-process grpc: %w", err) + } + + mcpSrv := mcp.NewServer(&mcp.Implementation{ + Name: name, + Version: version, + }, nil) + + for _, b := range bindings { + registerTool(log, mcpSrv, conn, b) + } + + return &Server{ + log: log, + mcpSrv: mcpSrv, + gwConn: conn, + lis: lis, + grpcSrv: grpcSrv, + }, nil +} + +// MCPServer exposes the underlying *mcp.Server. Used by tests to drive the +// server with an in-memory transport pair. +func (s *Server) MCPServer() *mcp.Server { return s.mcpSrv } + +// RunStdio serves MCP over stdio (the default Claude Code transport). Blocks +// until ctx is cancelled or the client disconnects. +func (s *Server) RunStdio(ctx context.Context) error { + defer s.cleanup() + return s.mcpSrv.Run(ctx, &mcp.StdioTransport{}) +} + +func (s *Server) cleanup() { + _ = s.gwConn.Close() + _ = s.lis.Close() +} + +// registerTool wires one ToolBinding into the MCP server. The handler: +// 1. Constructs a fresh proto.Message of the right type via dynamicpb +// 2. Unmarshals JSON args into it +// 3. Invokes the gRPC method via grpc.ClientConn.Invoke +// 4. Marshals the response back to JSON for the MCP client +func registerTool(log *zerolog.Logger, srv *mcp.Server, conn *grpc.ClientConn, b ToolBinding) { + schema := schemaForMessage(b.InputDescriptor) + tool := &mcp.Tool{ + Name: b.Name, + Description: b.Description, + InputSchema: schema, + } + if b.Destructive { + // MCP clients show a destructive-action confirmation when this is set. + tool.Annotations = &mcp.ToolAnnotations{DestructiveHint: ptrTrue()} + } + + srv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Build a dynamic proto.Message for the request, then unmarshal JSON. + reqMsg := dynamicpb.NewMessage(b.InputDescriptor) + if len(req.Params.Arguments) > 0 && string(req.Params.Arguments) != "null" { + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(req.Params.Arguments, reqMsg); err != nil { + return nil, fmt.Errorf("decode arguments: %w", err) + } + } + + respMsg := dynamicpb.NewMessage(b.OutputDescriptor) + if err := conn.Invoke(ctx, b.FullMethod, reqMsg, respMsg); err != nil { + log.Debug().Err(err).Str("tool", b.Name).Str("method", b.FullMethod).Msg("MCP tool invocation failed") + return nil, err + } + + respJSON, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(respMsg) + if err != nil { + return nil, fmt.Errorf("encode response: %w", err) + } + // Surface as both Content (text-shaped) and StructuredContent so MCP + // clients that prefer either get something they can consume. + var structured any + _ = json.Unmarshal(respJSON, &structured) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(respJSON)}}, + StructuredContent: structured, + }, nil + }) +} + +func ptrTrue() *bool { v := true; return &v } + +// compile-time assertion that ToolBinding messages descriptors implement what we need. +var _ proto.Message = (*dynamicpb.Message)(nil)