Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
83 changes: 83 additions & 0 deletions internal/integration_tests/mcp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
123 changes: 123 additions & 0 deletions internal/mcp/scanner.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading