diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 00000000..e7d2aa92 --- /dev/null +++ b/cmd/mcp.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/metal-toolbox/governor-api/pkg/mcp" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "go.hollow.sh/toolbox/ginjwt" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" + "go.uber.org/zap" +) + +const ( + defaultMCPGovernorRequestTimeout = 10 * time.Second +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "starts governor MCP server", + RunE: func(cmd *cobra.Command, _ []string) error { + return startMCPServer(cmd.Context()) + }, +} + +func init() { + rootCmd.AddCommand(mcpCmd) + + // MCP server flags + mcpCmd.Flags().String("listen", "0.0.0.0:3001", "sse server listens on") + viperBindFlag("mcp.listen", mcpCmd.Flags().Lookup("listen")) + mcpCmd.Flags().String("metadata-base-url", "http://localhost:3001", "base URL for MCP metadata") + viperBindFlag("mcp.metadata-base-url", mcpCmd.Flags().Lookup("metadata-base-url")) + + // Governor flags + mcpCmd.Flags().String("governor-url", "https://api.iam.equinixmetal.net", "url of the governor api") + viperBindFlag("governor.url", mcpCmd.Flags().Lookup("governor-url")) + mcpCmd.Flags().Duration("governor-timeout", defaultMCPGovernorRequestTimeout, "timeout for requests to governor api") + viperBindFlag("governor.timeout", mcpCmd.Flags().Lookup("governor-timeout")) + + ginjwt.RegisterViperOIDCFlags(viper.GetViper(), mcpCmd) +} + +func startMCPServer(ctx context.Context) error { + logger := logger.Desugar() + logger.Info("starting MCP server") + + if viper.GetBool("tracing.enabled") { + initTracer() + } + + tracer := otel.GetTracerProvider().Tracer("governor-api/mcp") + + authcfgs, err := ginjwt.GetAuthConfigsFromFlags(viper.GetViper()) + if err != nil { + logger.Fatal("failed getting JWT configurations", zap.Error(err)) + } + + if len(authcfgs) == 0 { + logger.Fatal("no oidc auth configs found") + } + + logger.Debug("loaded oidc config(s)", zap.Int("count", len(authcfgs))) + + for _, ac := range authcfgs { + logger.Info( + "OIDC Config", + zap.Bool("enabled", ac.Enabled), + zap.String("audience", ac.Audience), + zap.String("issuer", ac.Issuer), + zap.String("jwksuri", ac.JWKSURI), + zap.String("roles", ac.RolesClaim), + zap.String("username", ac.UsernameClaim), + ) + } + + httpclient := &http.Client{ + Timeout: viper.GetDuration("governor.timeout"), + Transport: otelhttp.NewTransport(http.DefaultTransport), + } + + mcpserver := mcp.NewGovernorMCPServer( + &http.Server{Addr: viper.GetString("mcp.listen")}, + viper.GetString("governor.url"), + mcp.WithLogger(logger), + mcp.WithTracer(tracer), + mcp.WithAuthConfigs(authcfgs), + mcp.WithMetadataBaseURL(viper.GetString("mcp.metadata-base-url")), + mcp.WithHTTPClient(httpclient), + ) + + go func() { + if err := mcpserver.Start(); err != nil { + logger.Fatal("MCP server failed: ", zap.Error(err)) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT) + signal.Notify(sig, syscall.SIGTERM) + + s := <-sig + + logger.Debug("received shutdown signal", zap.Any("signal", s)) + + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + if err := mcpserver.Shutdown(ctx); err != nil { + logger.Fatal("failed to shutdown MCP server: ", zap.Error(err)) + } + + logger.Info("bye") + + return nil +} diff --git a/docs/mcp-agent-prompt.md b/docs/mcp-agent-prompt.md new file mode 100644 index 00000000..b6c7b3ed --- /dev/null +++ b/docs/mcp-agent-prompt.md @@ -0,0 +1,72 @@ +# Governor System Assistant Prompt + +You are a Governor System Assistant—an expert AI agent that helps users navigate and manage the Governor access control and governance platform. + +## Role +- Guide users through access-management tasks: user profiles, group membership, requests/approvals, notification preferences. +- Assist with group operations: create groups, manage memberships and hierarchies, handle application links and requests. +- Support organization and application management: create orgs, register apps, map apps to groups. +- Help with audit/compliance awareness: surface events and explain access patterns. +- Work with extensions: describe extension resource definitions and extension resources, and how users interact with them. + +## System Capabilities (from Governor API) +- OIDC-authenticated identity and access control with roles (User, Admin, Group Admin, Group Approver) and scopes. +- Hierarchical groups and inheritance; request/approval workflows for access changes. +- Application-to-group access mapping; notification types/targets and user preferences. +- Extension resources and resource definitions; organization-based multi-tenancy. +- Comprehensive audit logging and event tracking. + +## How You Help +- Ask clarifying questions about the user’s role, goal, and target resources. +- Explain Governor concepts in business terms and security context. +- Provide step-by-step guidance for workflows; propose alternatives when direct paths aren’t available. +- Highlight required roles/scopes and approvals before suggesting actions. +- Prioritize security, least privilege, and compliance in recommendations. +## Special Workflows +- **Group Creation**: When a user creates a group, automatically create a group membership request for them with admin privileges to join their newly created group. This ensures the group creator can manage their group immediately after creation. +## Available MCP Tools + +### User Information & Management +- `current-user-info` (v1alpha1): fetch authenticated user details to ground responses in who is asking. +- `current-user-groups` (v1alpha1): list the authenticated user's group memberships to reason about access paths and approvals. +- `current-user-group-requests` (v1alpha1): get current user's pending group membership and application requests to track what access they've requested. +- `current-user-group-approvals` (v1alpha1): get group membership and application requests that the current user can approve based on their admin roles and group memberships. +- `remove-authenticated-user-group` (v1alpha1): remove the current user from a specified group. +- `get-user` (v1alpha1): get details of a specific user by ID. +- `list-users` (v1alpha1): list all users in the system. + +### Group Discovery & Information +- `list-groups` (v1alpha1): list all groups in the system (WARNING: can return very large arrays, prefer search-groups for better performance). +- `search-groups` (v1alpha1): search groups by name, slug, or description (PREFERRED over list-groups for finding specific groups). +- `get-group` (v1alpha1): get detailed information about a specific group by ID, including full group metadata. + +### Group Management & Operations +- `create-group` (v1alpha1): create a new group with name, slug, and description. +- `delete-group` (v1alpha1): delete a group (requires appropriate permissions). + +### Group Membership Management +- `list-group-members` (v1alpha1): list all members of a specific group. +- `add-group-member` (v1alpha1): add a user to a group with optional admin privileges. +- `remove-group-member` (v1alpha1): remove a user from a group. + +### Group Request Workflows +- `get-group-requests-all` (v1alpha1): get all group requests across the system (admin access required). +- `get-group-requests` (v1alpha1): get requests for a specific group. +- `create-group-request` (v1alpha1): create a request to join a group (with optional admin privileges and note). +- `process-group-request` (v1alpha1): approve or deny a group membership request with optional note. +- `delete-group-request` (v1alpha1): delete a group membership request. + +### Group Hierarchies +- `list-member-groups` (v1alpha1): list child groups (group hierarchies) for a group. +- `add-member-group` (v1alpha1): add a child group to a parent group (establish hierarchy). +- `update-member-group` (v1alpha1): update a group hierarchy relationship. +- `remove-member-group` (v1alpha1): remove a child group from a parent group. + +More MCP tools are planned; when new tools appear, use them to inspect or act on Governor resources (applications, organizations, notifications, extensions). + +## Interaction Style +- Be concise, proactive, and permission-aware. +- Verify assumptions; surface approvals or prerequisites early. +- When a user asks for an action, outline the steps and required rights; if unsure, ask for confirmation or more detail. + +Remember: Governor’s purpose is secure, auditable access. Keep recommendations aligned with proper approvals and least privilege. diff --git a/go.mod b/go.mod index 0a4d7d66..bd9536e7 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/metal-toolbox/iam-runtime v0.4.1 github.com/metal-toolbox/iam-runtime-contrib v1.0.1 github.com/mitchellh/go-homedir v1.1.0 + github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/nats-io/nats.go v1.47.0 github.com/peterldowns/pgtestdb v0.1.1 github.com/pressly/goose/v3 v3.26.0 @@ -36,6 +37,7 @@ require ( github.com/zsais/go-gin-prometheus v1.0.2 go.hollow.sh/toolbox v0.6.3 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 go.opentelemetry.io/otel/sdk v1.39.0 @@ -52,10 +54,12 @@ require ( github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.19.0 // indirect github.com/gofrs/flock v0.13.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.2 // indirect @@ -66,6 +70,7 @@ require ( github.com/quic-go/quic-go v0.57.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect diff --git a/go.sum b/go.sum index afd04b88..36a3b604 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 h1:R/ZjJpjQKsZ6L/+Gf9WHbt31GG8NMVcpRqUE+1mMIyo= github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk= @@ -110,6 +112,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -232,6 +236,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= 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= @@ -337,6 +343,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zsais/go-gin-prometheus v1.0.2 h1:3asLqrFltMdItpgr/OS4hYc8pLq3HzMa5T1gYuXBIZ0= @@ -349,6 +357,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0. go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0/go.mod h1:+TF5nf3NIv2X8PGxqfYOaRnAoMM43rUA2C3XsN2DoWA= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk= go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -484,6 +494,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/client/governor.go b/pkg/client/governor.go index 377c51a9..04a8eb3e 100644 --- a/pkg/client/governor.go +++ b/pkg/client/governor.go @@ -63,6 +63,12 @@ func WithClientCredentialConfig(c *clientcredentials.Config) Option { } } +func WithTokener(t Tokener) Option { + return func(r *Client) { + r.clientCredentialConfig = t + } +} + // WithLogger sets logger func WithLogger(l *zap.Logger) Option { return func(r *Client) { diff --git a/pkg/mcp/api.go b/pkg/mcp/api.go new file mode 100644 index 00000000..f6dca5fc --- /dev/null +++ b/pkg/mcp/api.go @@ -0,0 +1,123 @@ +package mcp + +import ( + "context" + "errors" + "net/http" + + "go.hollow.sh/toolbox/ginjwt" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" + "go.uber.org/zap" +) + +const ( + // DefaultMetadataBaseURL is the default base URL for the MCP server metadata. + DefaultMetadataBaseURL = "http://localhost:3001" + // WellKnownOAuthProtectedResourcePath is the path for the OAuth protected resource metadata. + WellKnownOAuthProtectedResourcePath = "/.well-known/oauth-protected-resource" +) + +// GovernorMCPServer represents the MCP server for Governor. +type GovernorMCPServer struct { + httpserver *http.Server + authConfigs []ginjwt.AuthConfig + metadataBaseURL string + + httpclient *http.Client + govURL string + + logger *zap.Logger + tracer trace.Tracer +} + +// Option defines a functional option for configuring the GovernorMCPServer. +type Option func(*GovernorMCPServer) + +// WithLogger sets the logger for the GovernorMCPServer. +func WithLogger(logger *zap.Logger) Option { + return func(s *GovernorMCPServer) { + s.logger = logger.With(zap.String("component", "mcp-server")) + } +} + +// WithTracer sets the tracer for the GovernorMCPServer. +func WithTracer(tracer trace.Tracer) Option { + return func(s *GovernorMCPServer) { + s.tracer = tracer + } +} + +// WithAuthConfigs sets the authentication configurations for the GovernorMCPServer. +func WithAuthConfigs(authConfigs []ginjwt.AuthConfig) Option { + return func(s *GovernorMCPServer) { + s.authConfigs = authConfigs + } +} + +// WithMetadataBaseURL sets the metadata base URL for the GovernorMCPServer. +func WithMetadataBaseURL(url string) Option { + return func(s *GovernorMCPServer) { + s.metadataBaseURL = url + } +} + +// WithHTTPClient sets the HTTP client for the GovernorMCPServer, the mcp server +// uses this client to make outbound requests to governor api. +func WithHTTPClient(httpClient *http.Client) Option { + return func(s *GovernorMCPServer) { + s.httpclient = httpClient + } +} + +// NewGovernorMCPServer creates a new instance of GovernorMCPServer with the provided options. +func NewGovernorMCPServer(httpserver *http.Server, govURL string, opts ...Option) *GovernorMCPServer { + s := &GovernorMCPServer{ + govURL: govURL, + httpserver: httpserver, + httpclient: &http.Client{}, + logger: zap.NewNop(), + tracer: noop.NewTracerProvider().Tracer("governor-mcp-server"), + metadataBaseURL: DefaultMetadataBaseURL, + } + + for _, opt := range opts { + opt(s) + } + + v1alpha1Handler := s.v1alpha1() + + mux := http.NewServeMux() + mux.Handle("/v1alpha1", s.authMiddleware()(v1alpha1Handler)) + s.authMetadataHandler(mux) + + s.httpserver.Handler = mux + + return s +} + +// Start starts the MCP server. +func (s *GovernorMCPServer) Start() error { + s.logger.Info("starting MCP server") + + if err := s.httpserver.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil +} + +// Shutdown gracefully shuts down the MCP server. +func (s *GovernorMCPServer) Shutdown(ctx context.Context) error { + s.logger.Info("shutting down MCP server") + + if err := s.httpserver.Close(); err != nil { + return err + } + + if err := s.httpserver.Shutdown(ctx); err != nil { + return err + } + + return nil +} diff --git a/pkg/mcp/auth.go b/pkg/mcp/auth.go new file mode 100644 index 00000000..7cf14611 --- /dev/null +++ b/pkg/mcp/auth.go @@ -0,0 +1,93 @@ +package mcp + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/metal-toolbox/governor-api/internal/auth" + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +const rawTokenKey = "raw-jwt-token" + +func getToken(ti *mcpauth.TokenInfo) string { + if ti == nil { + return "" + } + + if raw, ok := ti.Extra[rawTokenKey]; ok { + if tokenStr, ok := raw.(string); ok { + return tokenStr + } + } + + return "" +} + +// verifyJWT verifies JWT tokens and returns TokenInfo for the auth middleware. +// This function implements the TokenVerifier interface required by auth.RequireBearerToken. +func (s *GovernorMCPServer) verifyJWT(ctx context.Context, tokenString string, _ *http.Request) (*mcpauth.TokenInfo, error) { + // Parse token to check expiration + parser := jwt.NewParser() + + token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, ErrInvalidTokenClaims + } + + exp, err := claims.GetExpirationTime() + if err != nil { + return nil, err + } + + if exp != nil && exp.Before(time.Now()) { + return nil, ErrTokenExpired + } + + userInfo, err := auth.UserInfoFromJWT(ctx, tokenString, s.authConfigs) + if err != nil { + return nil, fmt.Errorf("failed to verify JWT: %w", err) + } + + return &mcpauth.TokenInfo{ + UserID: userInfo.Sub, + Expiration: exp.Time, + Extra: map[string]any{rawTokenKey: tokenString}, + }, nil +} + +func (s *GovernorMCPServer) authMiddleware() func(http.Handler) http.Handler { + return mcpauth.RequireBearerToken(s.verifyJWT, &mcpauth.RequireBearerTokenOptions{ + ResourceMetadataURL: fmt.Sprintf("%s%s", s.metadataBaseURL, WellKnownOAuthProtectedResourcePath), + }) +} + +func (s *GovernorMCPServer) authMetadataHandler(mux *http.ServeMux) { + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: fmt.Sprintf("%s/v1alpha1", s.metadataBaseURL), + } + + authservers := []string{} + + for _, ac := range s.authConfigs { + if ac.Enabled { + authservers = append(authservers, ac.Issuer) + } + } + + metadata.AuthorizationServers = authservers + + mux.Handle( + WellKnownOAuthProtectedResourcePath, + mcpauth.ProtectedResourceMetadataHandler(metadata), + ) +} diff --git a/pkg/mcp/authuser.go b/pkg/mcp/authuser.go new file mode 100644 index 00000000..59df5c4c --- /dev/null +++ b/pkg/mcp/authuser.go @@ -0,0 +1,238 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/metal-toolbox/governor-api/pkg/api/v1alpha1" + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.opentelemetry.io/otel/attribute" + "go.uber.org/zap" +) + +func (s *GovernorMCPServer) CurrentUserInfo(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.CurrentUserInfo") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + u := fmt.Sprintf("%s/api/v1alpha1/user", s.govURL) + + s.logger.Debug("getting current user", zap.String("url", u)) + span.SetAttributes( + attribute.String("governor-url", u), + ) + + govreq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + govreq.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := s.httpclient.Do(govreq) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := s.handleHTTPError(ctx, resp) + return nil, nil, err + } + + user := &v1alpha1.AuthenticatedUser{} + if err := json.NewDecoder(resp.Body).Decode(user); err != nil { + return nil, nil, err + } + + return nil, user, nil +} + +func (s *GovernorMCPServer) CurrentUserGroups(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.CurrentUserGroups") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + u := fmt.Sprintf("%s/api/v1alpha1/user/groups", s.govURL) + + s.logger.Debug("getting current user groups", zap.String("url", u)) + span.SetAttributes( + attribute.String("governor-url", u), + ) + + govreq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + govreq.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := s.httpclient.Do(govreq) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := s.handleHTTPError(ctx, resp) + return nil, nil, err + } + + groups := &[]v1alpha1.AuthenticatedUserGroup{} + if err := json.NewDecoder(resp.Body).Decode(groups); err != nil { + return nil, nil, err + } + + return nil, groups, nil +} + +func (s *GovernorMCPServer) CurrentUserGroupRequests(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.CurrentUserGroupRequests") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + u := fmt.Sprintf("%s/api/v1alpha1/user/groups/requests", s.govURL) + + s.logger.Debug("getting current user group requests", zap.String("url", u)) + span.SetAttributes( + attribute.String("governor-url", u), + ) + + govreq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + govreq.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := s.httpclient.Do(govreq) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := s.handleHTTPError(ctx, resp) + return nil, nil, err + } + + requests := &v1alpha1.AuthenticatedUserRequests{} + if err := json.NewDecoder(resp.Body).Decode(requests); err != nil { + return nil, nil, err + } + + return nil, requests, nil +} + +func (s *GovernorMCPServer) CurrentUserGroupApprovals(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.CurrentUserGroupApprovals") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + u := fmt.Sprintf("%s/api/v1alpha1/user/groups/approvals", s.govURL) + + s.logger.Debug("getting current user group approvals", zap.String("url", u)) + span.SetAttributes( + attribute.String("governor-url", u), + ) + + govreq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + govreq.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := s.httpclient.Do(govreq) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := s.handleHTTPError(ctx, resp) + return nil, nil, err + } + + approvals := &v1alpha1.AuthenticatedUserRequests{} + if err := json.NewDecoder(resp.Body).Decode(approvals); err != nil { + return nil, nil, err + } + + return nil, approvals, nil +} + +type RemoveUserGroupInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group to remove user from"` +} + +func (s *GovernorMCPServer) RemoveAuthenticatedUserGroup(ctx context.Context, req *mcp.CallToolRequest, args RemoveUserGroupInput) (*mcp.CallToolResult, *SimpleGroupOperationResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.RemoveAuthenticatedUserGroup") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + u := fmt.Sprintf("%s/api/v1alpha1/user/groups/%s", s.govURL, args.GroupID) + + s.logger.Debug("removing current user from group", zap.String("url", u), zap.String("group_id", args.GroupID)) + span.SetAttributes( + attribute.String("governor-url", u), + attribute.String("group-id", args.GroupID), + ) + + govreq, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil) + if err != nil { + return nil, nil, err + } + + govreq.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := s.httpclient.Do(govreq) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + err := s.handleHTTPError(ctx, resp) + return nil, nil, err + } + + return nil, &SimpleGroupOperationResult{Status: "success", GroupID: args.GroupID}, nil +} diff --git a/pkg/mcp/doc.go b/pkg/mcp/doc.go new file mode 100644 index 00000000..efac35b3 --- /dev/null +++ b/pkg/mcp/doc.go @@ -0,0 +1,2 @@ +// Package mcp implements the MCP server for Governor. +package mcp diff --git a/pkg/mcp/errors.go b/pkg/mcp/errors.go new file mode 100644 index 00000000..61ce4565 --- /dev/null +++ b/pkg/mcp/errors.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + + "github.com/metal-toolbox/governor-api/pkg/client" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +var ( + // ErrInvalidTokenClaims is returned when the token claims are invalid + ErrInvalidTokenClaims = errors.New("invalid token claims") + // ErrTokenExpired is returned when the token is expired + ErrTokenExpired = errors.New("token is expired") + // ErrNoTokenFound is returned when no token is found in the request + ErrNoTokenFound = errors.New("no token found in the request") +) + +func (s *GovernorMCPServer) handleHTTPError(ctx context.Context, resp *http.Response) error { + span := trace.SpanFromContext(ctx) + msg := "" + + respbody, err := io.ReadAll(resp.Body) + if err == nil { + msg = string(respbody) + } + + if msg == "" { + msg = resp.Status + } + + err = fmt.Errorf("%w: %s", client.ErrRequestNonSuccess, msg) + + span.SetStatus(codes.Error, msg) + span.RecordError(err) + + return err +} diff --git a/pkg/mcp/governor.go b/pkg/mcp/governor.go new file mode 100644 index 00000000..b7572989 --- /dev/null +++ b/pkg/mcp/governor.go @@ -0,0 +1,35 @@ +package mcp + +import ( + "context" + + "github.com/metal-toolbox/governor-api/pkg/client" + "golang.org/x/oauth2" +) + +// governor client tokener +type gcTokener struct { + rawToken string +} + +func (t *gcTokener) Token(ctx context.Context) (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: t.rawToken, + TokenType: "Bearer", + }, nil +} + +func newGCTokener(rawToken string) client.Tokener { + return &gcTokener{ + rawToken: rawToken, + } +} + +func (s *GovernorMCPServer) newGovernorClient(rawToken string) (*client.Client, error) { + return client.NewClient( + client.WithURL(s.govURL), + client.WithTokener(newGCTokener(rawToken)), + client.WithHTTPClient(s.httpclient), + client.WithLogger(s.logger), + ) +} diff --git a/pkg/mcp/groups.go b/pkg/mcp/groups.go new file mode 100644 index 00000000..51c5c618 --- /dev/null +++ b/pkg/mcp/groups.go @@ -0,0 +1,773 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/aarondl/null/v8" + "github.com/metal-toolbox/governor-api/pkg/api/v1alpha1" + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.uber.org/zap" +) + +type SearchGroupsOutput struct { + Groups []ListGroupsGroupInfo `json:"groups" jsonschema:"list of groups matching the search query"` +} + +type ListGroupInput struct { + Deleted bool `json:"deleted" jsonschema:"whether to include deleted groups"` +} + +type ListGroupsGroupInfo struct { + ID string `json:"id" jsonschema:"the unique identifier of the group"` + Name string `json:"name" jsonschema:"the name of the group"` + DeletedAt *time.Time `json:"deleted_at,omitempty" jsonschema:"the time the group was deleted, if applicable"` +} + +type ListGroupsOutput struct { + Groups []ListGroupsGroupInfo `json:"groups" jsonschema:"list of all groups"` +} + +// SimpleGroupOperationResult is a generic result for simple group operations +type SimpleGroupOperationResult struct { + Status string `json:"status" jsonschema:"operation status (success/error)"` + GroupID string `json:"group_id" jsonschema:"unique identifier of the group"` +} + +type GroupRequestResult struct { + Status string `json:"status" jsonschema:"operation status (success/error)"` + GroupID string `json:"group_id" jsonschema:"unique identifier of the group"` + RequestID string `json:"request_id" jsonschema:"unique identifier of the request"` +} + +type GroupMemberResult struct { + Status string `json:"status" jsonschema:"operation status (success/error)"` + GroupID string `json:"group_id" jsonschema:"unique identifier of the group"` + UserID string `json:"user_id" jsonschema:"unique identifier of the user"` + IsAdmin bool `json:"is_admin" jsonschema:"whether the user has admin privileges in the group"` +} + +type UserGroupResult struct { + Status string `json:"status" jsonschema:"operation status (success/error)"` + GroupID string `json:"group_id" jsonschema:"unique identifier of the group"` + UserID string `json:"user_id" jsonschema:"unique identifier of the user"` +} + +type GroupHierarchyResult struct { + Status string `json:"status" jsonschema:"operation status (success/error)"` + GroupID string `json:"group_id" jsonschema:"unique identifier of the parent group"` + MemberGroupID string `json:"member_group_id" jsonschema:"unique identifier of the member group"` +} + +func (s *GovernorMCPServer) ListGroups(ctx context.Context, req *mcp.CallToolRequest, args ListGroupInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.ListGroups") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + groups, err := govclient.Groups(ctx) + if err != nil { + span.SetStatus(codes.Error, "failed to get groups from governor") + span.RecordError(err) + + return nil, nil, err + } + + resp := &ListGroupsOutput{} + + for _, g := range groups { + info := ListGroupsGroupInfo{ + ID: g.ID, + Name: g.Name, + } + + if g.DeletedAt.Valid { + info.DeletedAt = &g.DeletedAt.Time + } + + resp.Groups = append(resp.Groups, info) + } + + return nil, resp, nil +} + +type SearchGroupsInput struct { + Query string `json:"query" jsonschema:"the search query string"` + Deleted bool `json:"deleted" jsonschema:"whether to include deleted groups"` +} + +func (s *GovernorMCPServer) SearchGroups(ctx context.Context, req *mcp.CallToolRequest, args SearchGroupsInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.SearchGroups") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + allgroups, err := govclient.Groups(ctx) + if err != nil { + span.SetStatus(codes.Error, "failed to get groups from governor") + span.RecordError(err) + + return nil, nil, err + } + + resp := &SearchGroupsOutput{} + + for _, g := range allgroups { + q := strings.ToLower(args.Query) + slug := strings.ToLower(g.Slug) + name := strings.ToLower(g.Name) + description := strings.ToLower(g.Description) + + if strings.Contains(name, q) || strings.Contains(slug, q) || strings.Contains(description, q) { + resp.Groups = append(resp.Groups, ListGroupsGroupInfo{ + ID: g.ID, + Name: g.Name, + }) + } + } + + return nil, resp, nil +} + +type GetGroupInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group"` + Deleted bool `json:"deleted" jsonschema:"whether to include deleted groups"` +} + +func (s *GovernorMCPServer) GetGroup(ctx context.Context, req *mcp.CallToolRequest, args GetGroupInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.GetGroup") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + group, err := govclient.Group(ctx, args.GroupID, args.Deleted) + if err != nil { + span.SetStatus(codes.Error, "failed to get group from governor") + span.RecordError(err) + + return nil, nil, err + } + + return nil, group, nil +} + +// Create Group +type CreateGroupInput struct { + Name string `json:"name" jsonschema:"the name of the group"` + Description string `json:"description" jsonschema:"the description of the group"` + Note string `json:"note,omitempty" jsonschema:"optional note for the group creation"` +} + +func (s *GovernorMCPServer) CreateGroup(ctx context.Context, req *mcp.CallToolRequest, args CreateGroupInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.CreateGroup") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + groupReq := &v1alpha1.GroupReq{ + Name: args.Name, + Description: args.Description, + Note: args.Note, + } + + s.logger.Debug("creating group", zap.String("name", args.Name)) + span.SetAttributes( + attribute.String("group-name", args.Name), + ) + + group, err := govclient.CreateGroup(ctx, groupReq) + if err != nil { + span.SetStatus(codes.Error, "failed to create group") + span.RecordError(err) + + return nil, nil, err + } + + return nil, group, nil +} + +// Get All Group Requests +func (s *GovernorMCPServer) GetGroupRequestsAll(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.GetGroupRequestsAll") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("getting all group requests") + + requests, err := govclient.GroupMembershipRequestsAll(ctx, false) + if err != nil { + span.SetStatus(codes.Error, "failed to get all group requests") + span.RecordError(err) + + return nil, nil, err + } + + return nil, requests, nil +} + +// Delete Group +type DeleteGroupInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group"` +} + +func (s *GovernorMCPServer) DeleteGroup(ctx context.Context, req *mcp.CallToolRequest, args DeleteGroupInput) (*mcp.CallToolResult, *SimpleGroupOperationResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.DeleteGroup") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("deleting group", zap.String("group_id", args.GroupID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + ) + + err = govclient.DeleteGroup(ctx, args.GroupID) + if err != nil { + span.SetStatus(codes.Error, "failed to delete group") + span.RecordError(err) + + return nil, nil, err + } + + return nil, &SimpleGroupOperationResult{Status: "success", GroupID: args.GroupID}, nil +} + +// Create Group Request +type CreateGroupRequestInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group"` + Note string `json:"note,omitempty" jsonschema:"optional note for the request"` + IsAdmin bool `json:"is_admin,omitempty" jsonschema:"whether requesting admin privileges"` +} + +func (s *GovernorMCPServer) CreateGroupRequest(ctx context.Context, req *mcp.CallToolRequest, args CreateGroupRequestInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.CreateGroupRequest") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + u := fmt.Sprintf("%s/api/v1alpha1/groups/%s/requests", s.govURL, args.GroupID) + + payload := map[string]interface{}{ + "is_admin": args.IsAdmin, + } + if args.Note != "" { + payload["note"] = args.Note + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("creating group request", zap.String("url", u), zap.String("group_id", args.GroupID)) + span.SetAttributes( + attribute.String("governor-url", u), + attribute.String("group-id", args.GroupID), + ) + + govreq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(jsonPayload)) + if err != nil { + return nil, nil, err + } + + govreq.Header.Set("Authorization", "Bearer "+rawToken) + govreq.Header.Set("Content-Type", "application/json") + + resp, err := s.httpclient.Do(govreq) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + err := s.handleHTTPError(ctx, resp) + return nil, nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: "Request to join group created successfully, a group admin has been notified.", + }, + }, + }, nil, nil +} + +// Get Group Requests +func (s *GovernorMCPServer) GetGroupRequests(ctx context.Context, req *mcp.CallToolRequest, args GetGroupInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.GetGroupRequests") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("getting group requests", zap.String("group_id", args.GroupID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + ) + + requests, err := govclient.GroupMemberRequests(ctx, args.GroupID) + if err != nil { + span.SetStatus(codes.Error, "failed to get group requests") + span.RecordError(err) + + return nil, nil, err + } + + return nil, requests, nil +} + +// Process Group Request +type ProcessGroupRequestInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group"` + RequestID string `json:"request_id" jsonschema:"the unique identifier of the request"` + Approve bool `json:"approve" jsonschema:"whether to approve or deny the request"` + Note string `json:"note,omitempty" jsonschema:"optional note for the decision"` +} + +func (s *GovernorMCPServer) ProcessGroupRequest(ctx context.Context, req *mcp.CallToolRequest, args ProcessGroupRequestInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.ProcessGroupRequest") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + u := fmt.Sprintf("%s/api/v1alpha1/groups/%s/requests/%s", s.govURL, args.GroupID, args.RequestID) + + payload := map[string]interface{}{ + "approve": args.Approve, + } + if args.Note != "" { + payload["note"] = args.Note + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("processing group request", zap.String("url", u), zap.String("group_id", args.GroupID), zap.String("request_id", args.RequestID)) + span.SetAttributes( + attribute.String("governor-url", u), + attribute.String("group-id", args.GroupID), + attribute.String("request-id", args.RequestID), + ) + + govreq, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewBuffer(jsonPayload)) + if err != nil { + return nil, nil, err + } + + govreq.Header.Set("Authorization", "Bearer "+rawToken) + govreq.Header.Set("Content-Type", "application/json") + + resp, err := s.httpclient.Do(govreq) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := s.handleHTTPError(ctx, resp) + return nil, nil, err + } + + var result interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, nil, err + } + + return nil, result, nil +} + +// Delete Group Request +type DeleteGroupRequestInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group"` + RequestID string `json:"request_id" jsonschema:"the unique identifier of the request"` +} + +func (s *GovernorMCPServer) DeleteGroupRequest(ctx context.Context, req *mcp.CallToolRequest, args DeleteGroupRequestInput) (*mcp.CallToolResult, *GroupRequestResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.DeleteGroupRequest") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("deleting group request", zap.String("group_id", args.GroupID), zap.String("request_id", args.RequestID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + attribute.String("request-id", args.RequestID), + ) + + err = govclient.RemoveGroupMembershipRequest(ctx, args.GroupID, args.RequestID) + if err != nil { + span.SetStatus(codes.Error, "failed to delete group request") + span.RecordError(err) + + return nil, nil, err + } + + return nil, &GroupRequestResult{Status: "success", GroupID: args.GroupID, RequestID: args.RequestID}, nil +} + +// List Group Members +func (s *GovernorMCPServer) ListGroupMembers(ctx context.Context, req *mcp.CallToolRequest, args GetGroupInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.ListGroupMembers") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("listing group members", zap.String("group_id", args.GroupID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + ) + + members, err := govclient.GroupMembers(ctx, args.GroupID) + if err != nil { + span.SetStatus(codes.Error, "failed to list group members") + span.RecordError(err) + + return nil, nil, err + } + + return nil, members, nil +} + +// Add Group Member +type AddGroupMemberInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group"` + UserID string `json:"user_id" jsonschema:"the unique identifier of the user"` + IsAdmin bool `json:"is_admin,omitempty" jsonschema:"whether the user should have admin privileges"` +} + +func (s *GovernorMCPServer) AddGroupMember(ctx context.Context, req *mcp.CallToolRequest, args AddGroupMemberInput) (*mcp.CallToolResult, *GroupMemberResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.AddGroupMember") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("adding group member", zap.String("group_id", args.GroupID), zap.String("user_id", args.UserID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + attribute.String("user-id", args.UserID), + ) + + err = govclient.AddGroupMember(ctx, args.GroupID, args.UserID, args.IsAdmin) + if err != nil { + span.SetStatus(codes.Error, "failed to add group member") + span.RecordError(err) + + return nil, nil, err + } + + return nil, &GroupMemberResult{Status: "success", GroupID: args.GroupID, UserID: args.UserID, IsAdmin: args.IsAdmin}, nil +} + +// Remove Group Member +type RemoveGroupMemberInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the group"` + UserID string `json:"user_id" jsonschema:"the unique identifier of the user"` +} + +func (s *GovernorMCPServer) RemoveGroupMember(ctx context.Context, req *mcp.CallToolRequest, args RemoveGroupMemberInput) (*mcp.CallToolResult, *UserGroupResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.RemoveGroupMember") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("removing group member", zap.String("group_id", args.GroupID), zap.String("user_id", args.UserID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + attribute.String("user-id", args.UserID), + ) + + err = govclient.RemoveGroupMember(ctx, args.GroupID, args.UserID) + if err != nil { + span.SetStatus(codes.Error, "failed to remove group member") + span.RecordError(err) + + return nil, nil, err + } + + return nil, &UserGroupResult{Status: "success", GroupID: args.GroupID, UserID: args.UserID}, nil +} + +// List Member Groups (Group Hierarchies) +func (s *GovernorMCPServer) ListMemberGroups(ctx context.Context, req *mcp.CallToolRequest, args GetGroupInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.ListMemberGroups") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("listing member groups", zap.String("group_id", args.GroupID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + ) + + hierarchies, err := govclient.MemberGroups(ctx, args.GroupID) + if err != nil { + span.SetStatus(codes.Error, "failed to list member groups") + span.RecordError(err) + + return nil, nil, err + } + + return nil, hierarchies, nil +} + +// Add Member Group +type AddMemberGroupInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the parent group"` + MemberGroupID string `json:"member_group_id" jsonschema:"the unique identifier of the member group"` +} + +func (s *GovernorMCPServer) AddMemberGroup(ctx context.Context, req *mcp.CallToolRequest, args AddMemberGroupInput) (*mcp.CallToolResult, *GroupHierarchyResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.AddMemberGroup") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("adding member group", zap.String("group_id", args.GroupID), zap.String("member_group_id", args.MemberGroupID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + attribute.String("member-group-id", args.MemberGroupID), + ) + + // Use null.Time{} for no expiration + err = govclient.AddMemberGroup(ctx, args.GroupID, args.MemberGroupID, null.Time{}) + if err != nil { + span.SetStatus(codes.Error, "failed to add member group") + span.RecordError(err) + + return nil, nil, err + } + + return nil, &GroupHierarchyResult{Status: "success", GroupID: args.GroupID, MemberGroupID: args.MemberGroupID}, nil +} + +// Update Member Group +type UpdateMemberGroupInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the parent group"` + MemberGroupID string `json:"member_group_id" jsonschema:"the unique identifier of the member group"` + // Add other fields as needed based on what can be updated +} + +func (s *GovernorMCPServer) UpdateMemberGroup(ctx context.Context, req *mcp.CallToolRequest, args UpdateMemberGroupInput) (*mcp.CallToolResult, *GroupHierarchyResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.UpdateMemberGroup") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("updating member group", zap.String("group_id", args.GroupID), zap.String("member_group_id", args.MemberGroupID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + attribute.String("member-group-id", args.MemberGroupID), + ) + + // Use null.Time{} for no expiration + err = govclient.UpdateMemberGroup(ctx, args.GroupID, args.MemberGroupID, null.Time{}) + if err != nil { + span.SetStatus(codes.Error, "failed to update member group") + span.RecordError(err) + + return nil, nil, err + } + + return nil, &GroupHierarchyResult{Status: "success", GroupID: args.GroupID, MemberGroupID: args.MemberGroupID}, nil +} + +// Remove Member Group +type RemoveMemberGroupInput struct { + GroupID string `json:"group_id" jsonschema:"the unique identifier of the parent group"` + MemberGroupID string `json:"member_group_id" jsonschema:"the unique identifier of the member group"` +} + +func (s *GovernorMCPServer) RemoveMemberGroup(ctx context.Context, req *mcp.CallToolRequest, args RemoveMemberGroupInput) (*mcp.CallToolResult, *GroupHierarchyResult, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.RemoveMemberGroup") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("removing member group", zap.String("group_id", args.GroupID), zap.String("member_group_id", args.MemberGroupID)) + span.SetAttributes( + attribute.String("group-id", args.GroupID), + attribute.String("member-group-id", args.MemberGroupID), + ) + + err = govclient.DeleteMemberGroup(ctx, args.GroupID, args.MemberGroupID) + if err != nil { + span.SetStatus(codes.Error, "failed to remove member group") + span.RecordError(err) + + return nil, nil, err + } + + return nil, &GroupHierarchyResult{Status: "success", GroupID: args.GroupID, MemberGroupID: args.MemberGroupID}, nil +} diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go new file mode 100644 index 00000000..b6c94f22 --- /dev/null +++ b/pkg/mcp/tools.go @@ -0,0 +1,162 @@ +package mcp + +import ( + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func (s *GovernorMCPServer) v1alpha1() *mcp.StreamableHTTPHandler { + v1alpha1 := mcp.NewServer(&mcp.Implementation{Name: "governor-v1alpha1"}, nil) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "current-user-info", Description: "Get current user information"}, + s.CurrentUserInfo, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "current-user-groups", Description: "Get current user group memberships"}, + s.CurrentUserGroups, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "current-user-group-requests", Description: "Get current user group membership and application requests"}, + s.CurrentUserGroupRequests, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "current-user-group-approvals", Description: "Get group membership and application requests that the current user can approve"}, + s.CurrentUserGroupApprovals, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "list-groups", Description: "List all groups (warning: can return very large arrays, prefer search-groups)"}, + s.ListGroups, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "search-groups", Description: "Search groups by name, slug, or description (preferred over list-groups)"}, + s.SearchGroups, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "get-group", Description: "Get detailed information about a specific group by ID"}, + s.GetGroup, + ) + + // Authenticated User Tools + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "remove-authenticated-user-group", Description: "Remove the current user from a group"}, + s.RemoveAuthenticatedUserGroup, + ) + + // Group Management Tools + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "create-group", Description: "Create a new group with name and description"}, + s.CreateGroup, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "get-group-requests-all", Description: "Get all group requests across the system (admin access required)"}, + s.GetGroupRequestsAll, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "delete-group", Description: "Delete a group"}, + s.DeleteGroup, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "create-group-request", Description: "Create a request to join a group"}, + s.CreateGroupRequest, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "get-group-requests", Description: "Get requests for a specific group"}, + s.GetGroupRequests, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "process-group-request", Description: "Approve or deny a group membership request"}, + s.ProcessGroupRequest, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "delete-group-request", Description: "Delete a group membership request"}, + s.DeleteGroupRequest, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "list-group-members", Description: "List all members of a specific group"}, + s.ListGroupMembers, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "add-group-member", Description: "Add a user to a group"}, + s.AddGroupMember, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "remove-group-member", Description: "Remove a user from a group"}, + s.RemoveGroupMember, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "list-member-groups", Description: "List child groups (group hierarchies) for a group"}, + s.ListMemberGroups, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "add-member-group", Description: "Add a child group to a parent group (group hierarchy)"}, + s.AddMemberGroup, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "update-member-group", Description: "Update a group hierarchy relationship"}, + s.UpdateMemberGroup, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "remove-member-group", Description: "Remove a child group from a parent group"}, + s.RemoveMemberGroup, + ) + + // User Management Tools + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "get-user", Description: "Get details of a specific user by ID"}, + s.GetUser, + ) + + mcp.AddTool( + v1alpha1, + &mcp.Tool{Name: "list-users", Description: "List all users in the system"}, + s.ListUsers, + ) + + return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return v1alpha1 + }, nil) +} diff --git a/pkg/mcp/users.go b/pkg/mcp/users.go new file mode 100644 index 00000000..28105915 --- /dev/null +++ b/pkg/mcp/users.go @@ -0,0 +1,81 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.uber.org/zap" +) + +// Get User +type GetUserInput struct { + UserID string `json:"user_id" jsonschema:"the unique identifier of the user"` +} + +func (s *GovernorMCPServer) GetUser(ctx context.Context, req *mcp.CallToolRequest, args GetUserInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.GetUser") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("getting user", zap.String("user_id", args.UserID)) + span.SetAttributes( + attribute.String("user-id", args.UserID), + ) + + user, err := govclient.User(ctx, args.UserID, false) + if err != nil { + span.SetStatus(codes.Error, "failed to get user") + span.RecordError(err) + + return nil, nil, err + } + + return nil, user, nil +} + +type ListUsersInput struct { + Deleted bool `json:"deleted" jsonschema:"whether to include deleted users"` +} + +// List Users +func (s *GovernorMCPServer) ListUsers(ctx context.Context, req *mcp.CallToolRequest, args ListUsersInput) (*mcp.CallToolResult, any, error) { + ctx, span := s.tracer.Start(ctx, "GovernorMCPServer.ListUsers") + defer span.End() + + tokeninfo := req.Extra.TokenInfo + + rawToken := getToken(tokeninfo) + if rawToken == "" { + return nil, nil, ErrNoTokenFound + } + + govclient, err := s.newGovernorClient(rawToken) + if err != nil { + return nil, nil, err + } + + s.logger.Debug("listing users") + + users, err := govclient.Users(ctx, args.Deleted) + if err != nil { + span.SetStatus(codes.Error, "failed to list users") + span.RecordError(err) + + return nil, nil, err + } + + return nil, users, nil +}