├── config ├── .gitkeep └── config.yaml ├── charts └── mcp-gateway │ ├── charts │ └── .gitkeep │ ├── templates │ ├── config.yaml │ ├── serviceaccount.yaml │ ├── service.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── hpa.yaml │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── ingress.yaml │ └── deployment.yaml │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md.gotmpl │ ├── values.yaml │ ├── values-dev.yaml │ └── README.md ├── .release-please-manifest.json ├── internal ├── storage │ ├── testsFixtures │ │ ├── docs.go │ │ └── postgres.go │ ├── attribute_to_roles.go │ ├── role.go │ ├── utils │ │ ├── utils.go │ │ └── utils_test.go │ ├── storage.go │ ├── proxy.go │ ├── migrate │ │ ├── migrate_test.go │ │ └── migrate.go │ ├── memory_test.go │ ├── memory.go │ └── postgres_test.go ├── auth │ ├── providers.go │ ├── okta.go │ ├── base.go │ ├── okta_test.go │ └── base_test.go ├── metrics │ └── metrics.go ├── server │ ├── middleware.go │ ├── v1handlers.go │ └── middleware_test.go ├── cfg │ └── cfg.go └── proxy │ └── proxy.go ├── pkg ├── signals │ ├── signal_posix.go │ ├── signals.go │ └── shutdown.go ├── aescipher │ ├── aescipher_test.go │ └── aescipher.go └── logger │ └── logger.go ├── assets └── migrations │ └── postgres │ ├── 000001_initialize_schema.down.sql │ └── 000001_initialize_schema.up.sql ├── main.go ├── docker-compose.yml ├── .envrc-sample ├── cmd ├── util │ └── util.go ├── root.go ├── migrate │ ├── flags.go │ └── migrate.go └── serve │ ├── flags.go │ └── serve.go ├── .gitignore ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── feature_request.yaml │ └── question.yaml ├── workflows │ ├── semantic_pr.yaml │ └── release_please.yaml └── actions │ └── package-docker-image │ └── action.yml ├── Dockerfile ├── .goreleaser.yaml ├── release-please-config.json ├── CONTRIBUTING.md ├── go.mod ├── .golangci.yaml └── Makefile /config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /charts/mcp-gateway/charts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | # MCP Gateway configuration 2 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.0.0" 3 | } -------------------------------------------------------------------------------- /internal/storage/testsFixtures/docs.go: -------------------------------------------------------------------------------- 1 | // Package testfixtures provides test fixtures for the MCP Gateway. 2 | package testfixtures 3 | -------------------------------------------------------------------------------- /pkg/signals/signal_posix.go: -------------------------------------------------------------------------------- 1 | package signals 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGINT} 9 | -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "mcp-gateway.fullname" . }}-config 5 | data: 6 | config.yaml: 7 | {{- toYaml .Values.configuration | nindent 4 }} -------------------------------------------------------------------------------- /assets/migrations/postgres/000001_initialize_schema.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS role_permission CASCADE; 2 | DROP TABLE IF EXISTS attribute_to_roles CASCADE; 3 | DROP TABLE IF EXISTS role CASCADE; 4 | DROP TABLE IF EXISTS proxy_oauth CASCADE; 5 | DROP TABLE IF EXISTS proxy_header CASCADE; 6 | DROP TABLE IF EXISTS proxy CASCADE; 7 | DROP SCHEMA IF EXISTS mcp_gateway CASCADE; -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "mcp-gateway.serviceAccountName" . }} 6 | labels: 7 | {{- include "mcp-gateway.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "mcp-gateway.fullname" . }} 5 | labels: 6 | {{- include "mcp-gateway.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "mcp-gateway.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /charts/mcp-gateway/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "mcp-gateway.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "mcp-gateway.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "mcp-gateway.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main is the main package for the MCP Gateway. 2 | package main 3 | 4 | import ( 5 | "os" 6 | 7 | "github.com/matthisholleville/mcp-gateway/cmd" 8 | "github.com/matthisholleville/mcp-gateway/cmd/migrate" 9 | "github.com/matthisholleville/mcp-gateway/cmd/serve" 10 | ) 11 | 12 | func main() { 13 | rootCmd := cmd.NewRootCommand() 14 | 15 | rootCmd.AddCommand(serve.NewRunCommand()) 16 | rootCmd.AddCommand(migrate.NewMigrateCommand()) 17 | 18 | if err := rootCmd.Execute(); err != nil { 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:16 6 | container_name: mcp-gateway-db 7 | restart: always 8 | environment: 9 | POSTGRES_USER: mcp-gateway 10 | POSTGRES_PASSWORD: changeme 11 | POSTGRES_DB: mcp-gateway 12 | ports: 13 | - "5439:5432" 14 | volumes: 15 | - postgres-mcp-gateway-data:/var/lib/postgresql/data 16 | networks: 17 | - mcp-gateway-network 18 | 19 | networks: 20 | mcp-gateway-network: 21 | driver: bridge 22 | 23 | volumes: 24 | postgres-mcp-gateway-data: -------------------------------------------------------------------------------- /internal/storage/attribute_to_roles.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "context" 4 | 5 | type AttributeToRolesConfig struct { 6 | AttributeKey string `json:"attribute_key"` 7 | AttributeValue string `json:"attribute_value"` 8 | Roles []string `json:"roles"` 9 | } 10 | 11 | type AttributeToRolesInterface interface { 12 | ListAttributeToRoles(ctx context.Context) ([]AttributeToRolesConfig, error) 13 | SetAttributeToRoles(ctx context.Context, attributeToRoles AttributeToRolesConfig) error 14 | GetAttributeToRoles(ctx context.Context, attributeKey, attributeValue string) (AttributeToRolesConfig, error) 15 | DeleteAttributeToRoles(ctx context.Context, attributeKey, attributeValue string) error 16 | } 17 | -------------------------------------------------------------------------------- /.envrc-sample: -------------------------------------------------------------------------------- 1 | # This is a sample environment file 2 | # Copy this to .envrc and fill in your actual values 3 | # DO NOT commit .envrc with real values! 4 | 5 | export MCP_GATEWAY_OKTA_PRIVATE_KEY="***REDACTED_FILE_CONTENT***" 6 | export MCP_GATEWAY_OKTA_PRIVATE_KEY_ID="***REDACTED***" 7 | export MCP_GATEWAY_OKTA_CLIENT_ID="***REDACTED***" 8 | export MCP_GATEWAY_OKTA_ORG_URL="***REDACTED***" 9 | export MCP_GATEWAY_OKTA_ISSUER="***REDACTED***" 10 | export MCP_GATEWAY_AUTH_PROVIDER_ENABLED="***REDACTED***" 11 | export MCP_GATEWAY_AUTH_PROVIDER_NAME="***REDACTED***" 12 | 13 | export MCP_GATEWAY_BACKEND_ENGINE=postgres 14 | export MCP_GATEWAY_BACKEND_URI=postgresql://mcp-gateway:changeme@localhost:5439/mcp-gateway?sslmode=disable -------------------------------------------------------------------------------- /cmd/util/util.go: -------------------------------------------------------------------------------- 1 | // Package util provides cmd utility functions for the MCP Gateway. 2 | // 3 | //nolint:revive // we need to keep the functions as is for the cmd package 4 | package util 5 | 6 | import ( 7 | "github.com/spf13/pflag" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // MustBindPFlag attempts to bind a specific key to a pflag (as used by cobra) and panics 12 | // if the binding fails with a non-nil error. 13 | func MustBindPFlag(key string, flag *pflag.Flag) { 14 | if err := viper.BindPFlag(key, flag); err != nil { 15 | panic("failed to bind pflag: " + err.Error()) 16 | } 17 | } 18 | 19 | // MustBindEnv binds an environment variable to a viper key. 20 | func MustBindEnv(input ...string) { 21 | if err := viper.BindEnv(input...); err != nil { 22 | panic("failed to bind env key: " + err.Error()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Code coverage profiles and other test artifacts 15 | *.out 16 | coverage.* 17 | *.coverprofile 18 | profile.cov 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | 30 | # Editor/IDE 31 | # .idea/ 32 | # .vscode/ 33 | 34 | # Build directories 35 | bin/ 36 | build/ 37 | .envrc 38 | .env 39 | 40 | ### Helm ### 41 | # Chart dependencies 42 | charts/mcp-gateway/charts/*tgz 43 | charts/mcp-gateway/Chart.lock -------------------------------------------------------------------------------- /internal/storage/role.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "context" 4 | 5 | type RoleConfig struct { 6 | Name string `json:"name"` 7 | Permissions []PermissionConfig `json:"permissions"` 8 | } 9 | 10 | type ObjectType string 11 | 12 | const ( 13 | ObjectTypeTools ObjectType = "tools" 14 | ObjectTypeAll ObjectType = "*" 15 | ) 16 | 17 | func (o ObjectType) IsValid() bool { 18 | return o == ObjectTypeTools || o == ObjectTypeAll 19 | } 20 | 21 | type PermissionConfig struct { 22 | ObjectType ObjectType `json:"object_type"` 23 | Proxy string `json:"proxy"` 24 | ObjectName string `json:"object_name"` 25 | } 26 | 27 | type RoleInterface interface { 28 | ListRoles(ctx context.Context) ([]RoleConfig, error) 29 | SetRole(ctx context.Context, role RoleConfig) error 30 | GetRole(ctx context.Context, role string) (RoleConfig, error) 31 | DeleteRole(ctx context.Context, role string) error 32 | } 33 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | Closes # 9 | 10 | ## 📑 Description 11 | 12 | 13 | ## ✅ Checks 14 | 15 | - [ ] My pull request adheres to the code style of this project 16 | - [ ] My code requires changes to the documentation 17 | - [ ] I have updated the documentation as required 18 | - [ ] All the tests have passed 19 | 20 | ## ℹ Additional Information 21 | -------------------------------------------------------------------------------- /pkg/signals/signals.go: -------------------------------------------------------------------------------- 1 | // Package signals provides a signal handler for the MCP Gateway. 2 | // It is used to handle SIGTERM and SIGINT signals and close the MCP Gateway. 3 | // It is used to handle SIGTERM and SIGINT signals and close the MCP Gateway. 4 | package signals 5 | 6 | import ( 7 | "os" 8 | "os/signal" 9 | ) 10 | 11 | var onlyOneSignalHandler = make(chan struct{}) 12 | 13 | // SetupSignalHandler registered for SIGTERM and SIGINT. A stop channel is returned 14 | // which is closed on one of these signals. If a second signal is caught, the program 15 | // is terminated with exit code 1. 16 | func SetupSignalHandler() (stopCh <-chan struct{}) { 17 | close(onlyOneSignalHandler) // panics when called twice 18 | 19 | stop := make(chan struct{}) 20 | c := make(chan os.Signal, 2) 21 | signal.Notify(c, shutdownSignals...) 22 | go func() { 23 | <-c 24 | close(stop) 25 | <-c 26 | os.Exit(1) // second signal. Exit directly. 27 | }() 28 | 29 | return stop 30 | } 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.5-alpine as builder 2 | 3 | WORKDIR /mcp-gateway 4 | 5 | RUN apk update && apk add --no-cache git 6 | 7 | COPY . . 8 | 9 | RUN go mod download 10 | 11 | RUN CGO_ENABLED=0 go build -ldflags "-s -w \ 12 | -X github.com/matthisholleville/mcp-gateway/pkg/version.REVISION=${REVISION}" \ 13 | -a -o bin/mcp-gateway 14 | 15 | FROM alpine:3.20@sha256:77726ef6b57ddf65bb551896826ec38bc3e53f75cdde31354fbffb4f25238ebd 16 | 17 | ARG BUILD_DATE 18 | ARG VERSION 19 | ARG REVISION 20 | 21 | LABEL maintainer="matthis.holleville" 22 | 23 | RUN addgroup -S app \ 24 | && adduser -S -G app app \ 25 | && apk --no-cache add \ 26 | curl netcat-openbsd 27 | 28 | WORKDIR /home/app 29 | 30 | COPY --from=builder /mcp-gateway/bin/mcp-gateway . 31 | COPY --from=builder /mcp-gateway/config/config.yaml ./config.yaml 32 | COPY --from=builder /mcp-gateway/assets/migrations/postgres/ ./assets/migrations/postgres/ 33 | 34 | RUN chown -R app:app ./ 35 | 36 | USER app 37 | 38 | EXPOSE 8080 39 | 40 | ENTRYPOINT ["./mcp-gateway"] -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "mcp-gateway.fullname" . }} 6 | labels: 7 | {{- include "mcp-gateway.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "mcp-gateway.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package cmd provides the root command for the application. 2 | package cmd 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // NewRootCommand creates a new root command. 13 | func NewRootCommand() *cobra.Command { 14 | programName := "MCP Gateway" 15 | 16 | viper.SetConfigName("config") 17 | viper.SetConfigType("yaml") 18 | viper.SetEnvPrefix("MCP_GATEWAY") 19 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 20 | viper.AutomaticEnv() 21 | 22 | configPaths := []string{"/etc/mcp-gateway", "$HOME/.mcp-gateway", "./config"} 23 | for _, path := range configPaths { 24 | viper.AddConfigPath(path) 25 | } 26 | 27 | err := viper.ReadInConfig() 28 | if err != nil { 29 | panic(fmt.Sprintf("unable to read config file: %s", err)) 30 | } 31 | 32 | return &cobra.Command{ 33 | Use: programName, 34 | Short: "A proxy gateway for MCP servers", 35 | Long: `MCP Gateway is a flexible and extensible proxy gateway for MCP servers, with built-in support for middleware, permissions, rate limiting, and observability.`, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/storage/utils/utils.go: -------------------------------------------------------------------------------- 1 | // Package utils provides utility functions for the storage package. 2 | package utils //nolint:revive // this is a utility package and we need to keep the package name short 3 | 4 | import ( 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | // GetURI gets the URI for the storage backend. 10 | func GetURI(inputUser, inputPassword, uri string) (string, error) { 11 | if inputUser != "" || inputPassword != "" { 12 | parsed, err := url.Parse(uri) 13 | if err != nil { 14 | return "", fmt.Errorf("parse postgres connection uri: %w", err) 15 | } 16 | username := "" 17 | switch { 18 | case inputUser != "": 19 | username = inputUser 20 | case parsed.User != nil: 21 | username = parsed.User.Username() 22 | default: 23 | username = "" 24 | } 25 | switch { 26 | case inputPassword != "": 27 | parsed.User = url.UserPassword(username, inputPassword) 28 | case parsed.User != nil: 29 | if password, ok := parsed.User.Password(); ok { 30 | parsed.User = url.UserPassword(username, password) 31 | } else { 32 | parsed.User = url.User(username) 33 | } 34 | default: 35 | parsed.User = url.User(username) 36 | } 37 | 38 | return parsed.String(), nil 39 | } 40 | return uri, nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/auth/providers.go: -------------------------------------------------------------------------------- 1 | // Package auth provides the providers for the MCP Gateway 2 | package auth 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "github.com/matthisholleville/mcp-gateway/internal/cfg" 9 | "github.com/matthisholleville/mcp-gateway/internal/storage" 10 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 11 | ) 12 | 13 | // Provider is the interface for the providers 14 | type Provider interface { 15 | Init() error 16 | VerifyToken(token string) (*Jwt, error) 17 | VerifyPermissions(ctx context.Context, objectType, objectName, proxy string, claims map[string]interface{}) bool 18 | } 19 | 20 | // Jwt is the struct for the JWT token 21 | type Jwt struct { 22 | Claims map[string]interface{} 23 | } 24 | 25 | // NewProvider creates a new provider 26 | // 27 | //nolint:gocritic // we need to keep logger as a parameter for the function 28 | func NewProvider(provider string, cfg *cfg.Config, logger logger.Logger, storage storage.Interface) (Provider, error) { 29 | switch provider { 30 | case "okta": 31 | return &OktaProvider{ 32 | BaseProvider: BaseProvider{ 33 | logger: logger, 34 | storage: storage, 35 | }, 36 | cfg: cfg.AuthProvider.Okta, 37 | oauthCfg: cfg.OAuth, 38 | logger: logger, 39 | }, nil 40 | default: 41 | return nil, fmt.Errorf("provider %s not found", provider) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/matthisholleville/mcp-gateway/internal/cfg" 8 | "github.com/matthisholleville/mcp-gateway/pkg/aescipher" 9 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 10 | ) 11 | 12 | type BaseInterface interface { 13 | GetDefaultScope(ctx context.Context) string 14 | } 15 | 16 | type BaseStorage struct { 17 | defaultScope string 18 | } 19 | 20 | // GetDefaultScope gets the default scope from the base storage. 21 | func (b *BaseStorage) GetDefaultScope(_ context.Context) string { 22 | return b.defaultScope 23 | } 24 | 25 | // Interface is an interface that provides a storage interface for the MCP Gateway. 26 | type Interface interface { 27 | BaseInterface 28 | ProxyInterface 29 | RoleInterface 30 | AttributeToRolesInterface 31 | } 32 | 33 | // NewStorage creates a new storage instance. 34 | // 35 | //nolint:gocritic // we need to keep logger as a parameter for the function 36 | func NewStorage(_ context.Context, storageType, defaultScope string, logger logger.Logger, cfg *cfg.Config, encryptor aescipher.Cryptor) (Interface, error) { 37 | switch storageType { 38 | case "memory": 39 | return NewMemoryStorage(defaultScope), nil 40 | case "postgres": 41 | return NewPostgresStorage(defaultScope, logger, cfg, encryptor) 42 | } 43 | return nil, fmt.Errorf("invalid storage type: %s", storageType) 44 | } 45 | -------------------------------------------------------------------------------- /charts/mcp-gateway/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: mcp-gateway 3 | description: Simple Helm chart to deploy MCP Gateway on Kubernetes. 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "1.16.0" 25 | 26 | dependencies: 27 | - name: postgresql 28 | version: "15.5.38" 29 | condition: postgresql.enabled 30 | repository: "https://charts.bitnami.com/bitnami" 31 | -------------------------------------------------------------------------------- /charts/mcp-gateway/README.md.gotmpl: -------------------------------------------------------------------------------- 1 | {{ template "chart.header" . }} 2 | {{ template "chart.description" . }} 3 | 4 | {{ template "chart.typeBadge" . }} 5 | 6 | Simple Helm chart to deploy MCP Gateway on Kubernetes. 7 | 8 | ## Quick Installation 9 | 10 | ```bash 11 | # Create namespace 12 | kubectl create namespace mcp-gateway 13 | 14 | # Create secrets for authentication 15 | kubectl create secret generic okta-secret \ 16 | --from-literal=issuer="https://your-okta-domain.okta.com/oauth2/default" \ 17 | --from-literal=org_url="https://your-okta-domain.okta.com" \ 18 | --from-literal=client_id="your-client-id" \ 19 | --from-literal=private_key="your-private-key" \ 20 | --from-literal=private_key_id="your-key-id" \ 21 | -n mcp-gateway 22 | 23 | # Create secret for MCP server (example: n8n) 24 | kubectl create secret generic n8n-secret \ 25 | --from-literal=proxy_key="your-n8n-api-key" \ 26 | -n mcp-gateway 27 | 28 | # Install chart 29 | helm install mcp-gateway . --namespace mcp-gateway 30 | 31 | # Verify deployment 32 | kubectl get pods -n mcp-gateway 33 | ``` 34 | 35 | ## Configuration 36 | 37 | Check `values.yaml` for all available configuration options. Customize environment variables and secrets according to your setup. 38 | 39 | ## Uninstall 40 | 41 | ```bash 42 | helm uninstall mcp-gateway -n mcp-gateway 43 | kubectl delete namespace mcp-gateway 44 | ``` 45 | 46 | {{ template "chart.requirementsSection" . }} 47 | 48 | {{ template "chart.valuesSection" . }} 49 | 50 | {{ template "helm-docs.versionFooter" . }} -------------------------------------------------------------------------------- /internal/storage/proxy.go: -------------------------------------------------------------------------------- 1 | // Package storage provides a storage interface for the MCP Gateway. 2 | package storage 3 | 4 | import ( 5 | "context" 6 | "time" 7 | ) 8 | 9 | type ProxyType string 10 | type ProxyAuthType string 11 | 12 | const ( 13 | ProxyTypeStreamableHTTP ProxyType = "streamable-http" 14 | ProxyAuthTypeHeader ProxyAuthType = "header" 15 | ProxyAuthTypeOAuth ProxyAuthType = "oauth" 16 | ) 17 | 18 | func (p ProxyType) IsValid() bool { 19 | return p == ProxyTypeStreamableHTTP 20 | } 21 | 22 | func (p ProxyAuthType) IsValid() bool { 23 | return p == ProxyAuthTypeHeader || p == ProxyAuthTypeOAuth 24 | } 25 | 26 | type ProxyConfig struct { 27 | Name string `json:"name"` 28 | Type ProxyType `json:"type"` 29 | URL string `json:"url"` 30 | Timeout time.Duration `json:"timeout"` 31 | AuthType ProxyAuthType `json:"authType"` 32 | Headers []ProxyHeader `json:"headers"` 33 | OAuth *ProxyOAuth `json:"oauth"` 34 | } 35 | 36 | type ProxyHeader struct { 37 | Key string `json:"key"` 38 | Value string `json:"value"` 39 | } 40 | 41 | type ProxyOAuth struct { 42 | ClientID string `json:"clientId"` 43 | ClientSecret string `json:"clientSecret"` 44 | TokenEndpoint string `json:"tokenEndpoint"` 45 | Scopes string `json:"scopes"` 46 | } 47 | 48 | type ProxyInterface interface { 49 | GetProxy(ctx context.Context, proxy string, decrypt bool) (ProxyConfig, error) 50 | ListProxies(ctx context.Context, decrypt bool) ([]ProxyConfig, error) 51 | SetProxy(ctx context.Context, proxy *ProxyConfig, encrypt bool) error 52 | DeleteProxy(ctx context.Context, proxy string) error 53 | } 54 | -------------------------------------------------------------------------------- /internal/storage/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetURI(t *testing.T) { 10 | for _, test := range []struct { 11 | name string 12 | username string 13 | password string 14 | uri string 15 | expected string 16 | }{ 17 | { 18 | name: "with uri", 19 | username: "", 20 | password: "", 21 | uri: "postgresql://mcp-gateway:changeme@localhost:5439/mcp-gateway?sslmode=disable", 22 | expected: "postgresql://mcp-gateway:changeme@localhost:5439/mcp-gateway?sslmode=disable", 23 | }, 24 | { 25 | name: "with username and password", 26 | username: "mcp-gateway", 27 | password: "changeme", 28 | uri: "postgresql://postgres:postgres@localhost:5439/mcp-gateway?sslmode=disable", 29 | expected: "postgresql://mcp-gateway:changeme@localhost:5439/mcp-gateway?sslmode=disable", 30 | }, 31 | { 32 | name: "with username", 33 | username: "mcp-gateway", 34 | password: "", 35 | uri: "postgresql://postgres:postgres@localhost:5439/mcp-gateway?sslmode=disable", 36 | expected: "postgresql://mcp-gateway:postgres@localhost:5439/mcp-gateway?sslmode=disable", 37 | }, 38 | { 39 | name: "with password", 40 | username: "", 41 | password: "changeme", 42 | uri: "postgresql://postgres:postgres@localhost:5439/mcp-gateway?sslmode=disable", 43 | expected: "postgresql://postgres:changeme@localhost:5439/mcp-gateway?sslmode=disable", 44 | }, 45 | } { 46 | t.Run(test.name, func(t *testing.T) { 47 | uri, err := GetURI(test.username, test.password, test.uri) 48 | assert.NoError(t, err) 49 | assert.Equal(t, test.expected, uri) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/auth/okta.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/matthisholleville/mcp-gateway/internal/cfg" 7 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 8 | jwtverifier "github.com/okta/okta-jwt-verifier-golang/v2" 9 | "github.com/okta/okta-sdk-golang/v5/okta" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // OktaProvider is a provider for Okta 14 | type OktaProvider struct { 15 | BaseProvider 16 | cfg *cfg.OktaConfig 17 | oauthCfg *cfg.OAuthConfig 18 | client *okta.APIClient 19 | logger logger.Logger 20 | } 21 | 22 | // Init initializes the Okta provider 23 | func (p *OktaProvider) Init() error { 24 | oktaConfig, err := okta.NewConfiguration( 25 | okta.WithOrgUrl(p.cfg.OrgURL), 26 | okta.WithClientId(p.cfg.ClientID), 27 | okta.WithAuthorizationMode("PrivateKey"), 28 | okta.WithScopes((p.oauthCfg.ScopesSupported)), 29 | okta.WithPrivateKey(p.cfg.PrivateKey), 30 | okta.WithPrivateKeyId(p.cfg.PrivateKeyID), 31 | ) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | p.client = okta.NewAPIClient(oktaConfig) 37 | return nil 38 | } 39 | 40 | // VerifyToken verifies a JWT token 41 | func (p *OktaProvider) VerifyToken(token string) (*Jwt, error) { 42 | verifierSetup := jwtverifier.JwtVerifier{ 43 | Issuer: p.cfg.Issuer, 44 | } 45 | 46 | verifier, err := verifierSetup.New() 47 | if err != nil { 48 | p.logger.Error("Error setting up JWT verifier", zap.Error(err)) 49 | return nil, fmt.Errorf("error setting up JWT verifier: %w", err) 50 | } 51 | 52 | jwtToken, err := verifier.VerifyAccessToken(token) 53 | if err != nil { 54 | p.logger.Error("Error verifying JWT", zap.Error(err)) 55 | return nil, fmt.Errorf("error verifying JWT: %w", err) 56 | } 57 | 58 | return &Jwt{Claims: jwtToken.Claims}, nil 59 | } 60 | -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mcp-gateway.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "mcp-gateway.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "mcp-gateway.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "mcp-gateway.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /pkg/signals/shutdown.go: -------------------------------------------------------------------------------- 1 | package signals 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "sync/atomic" 7 | "time" 8 | 9 | "github.com/labstack/echo/v4" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | const ( 14 | preStopSleep = 3 * time.Second 15 | ) 16 | 17 | // Shutdown is a struct that contains the logger and the server shutdown timeout. 18 | type Shutdown struct { 19 | logger slog.Logger 20 | serverShutdownTimeout time.Duration 21 | } 22 | 23 | // NewShutdown creates a new Shutdown instance. 24 | func NewShutdown(serverShutdownTimeout time.Duration, logger slog.Logger) (*Shutdown, error) { 25 | srv := &Shutdown{ 26 | logger: logger, 27 | serverShutdownTimeout: serverShutdownTimeout, 28 | } 29 | 30 | return srv, nil 31 | } 32 | 33 | // Graceful shuts down the MCP Gateway gracefully. 34 | func (s *Shutdown) Graceful(stopCh <-chan struct{}, httpServer *echo.Echo, healthy, ready *int32) { 35 | ctx := context.Background() 36 | 37 | // wait for SIGTERM or SIGINT 38 | <-stopCh 39 | ctx, cancel := context.WithTimeout(ctx, s.serverShutdownTimeout) 40 | defer cancel() 41 | 42 | // all calls to /healthz and /readyz will fail from now on 43 | atomic.StoreInt32(healthy, 0) 44 | atomic.StoreInt32(ready, 0) 45 | 46 | //nolint:noctx // no need to pass a context here 47 | s.logger.Info("Shutting down HTTP server", slog.Duration("timeout", s.serverShutdownTimeout)) 48 | 49 | // There could be a period where a terminating pod may still receive requests. Implementing a brief wait can mitigate this. 50 | // See: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination 51 | // the readiness check interval must be lower than the timeout 52 | if viper.GetString("level") != "debug" { 53 | time.Sleep(preStopSleep) 54 | } 55 | 56 | // determine if the http server was started 57 | if httpServer != nil { 58 | if err := httpServer.Shutdown(ctx); err != nil { 59 | //nolint:noctx // no need to pass a context here 60 | s.logger.Warn("HTTP server graceful shutdown failed", slog.Any("error", err)) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/migrate/flags.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "github.com/matthisholleville/mcp-gateway/cmd/util" 5 | "github.com/spf13/cobra" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | // bindRunFlagsFunc binds the run flags to the command. 10 | func bindRunFlagsFunc(flags *pflag.FlagSet) func(*cobra.Command, []string) { 11 | return func(_ *cobra.Command, _ []string) { 12 | util.MustBindPFlag(backendEngineFlag, flags.Lookup(backendEngineFlag)) 13 | util.MustBindEnv(backendEngineFlag, "MCP_GATEWAY_BACKEND_ENGINE") 14 | 15 | util.MustBindPFlag(backendURIFlag, flags.Lookup(backendURIFlag)) 16 | util.MustBindEnv("backend-uri", "MCP_GATEWAY_BACKEND_URI") 17 | 18 | util.MustBindPFlag(backendUsernameFlag, flags.Lookup(backendUsernameFlag)) 19 | util.MustBindEnv(backendUsernameFlag, "MCP_GATEWAY_BACKEND_USERNAME") 20 | 21 | util.MustBindPFlag(backendPasswordFlag, flags.Lookup(backendPasswordFlag)) 22 | util.MustBindEnv(backendPasswordFlag, "MCP_GATEWAY_BACKEND_PASSWORD") 23 | 24 | util.MustBindPFlag(verboseMigrationFlag, flags.Lookup(verboseMigrationFlag)) 25 | util.MustBindEnv(verboseMigrationFlag, "MCP_GATEWAY_VERBOSE") 26 | 27 | util.MustBindPFlag(logFormatFlag, flags.Lookup(logFormatFlag)) 28 | util.MustBindEnv(logFormatFlag, "MCP_GATEWAY_LOG_FORMAT") 29 | 30 | util.MustBindPFlag(logLevelFlag, flags.Lookup(logLevelFlag)) 31 | util.MustBindEnv(logLevelFlag, "MCP_GATEWAY_LOG_LEVEL") 32 | 33 | util.MustBindPFlag(logTimestampFlag, flags.Lookup(logTimestampFlag)) 34 | util.MustBindEnv(logTimestampFlag, "MCP_GATEWAY_LOG_TIMESTAMP_FORMAT") 35 | 36 | util.MustBindPFlag(targetVersionFlag, flags.Lookup(targetVersionFlag)) 37 | util.MustBindEnv(targetVersionFlag, "MCP_GATEWAY_TARGET_VERSION") 38 | 39 | util.MustBindPFlag(timeoutFlag, flags.Lookup(timeoutFlag)) 40 | util.MustBindEnv(timeoutFlag, "MCP_GATEWAY_TIMEOUT") 41 | 42 | util.MustBindPFlag(dropFlag, flags.Lookup(dropFlag)) 43 | util.MustBindEnv(dropFlag, "MCP_GATEWAY_DROP") 44 | 45 | util.MustBindPFlag(dirFlag, flags.Lookup(dirFlag)) 46 | util.MustBindEnv(dirFlag, "MCP_GATEWAY_MIGRATION_DIR") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "mcp-gateway.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "mcp-gateway.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "mcp-gateway.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "mcp-gateway.labels" -}} 37 | helm.sh/chart: {{ include "mcp-gateway.chart" . }} 38 | {{ include "mcp-gateway.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "mcp-gateway.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "mcp-gateway.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "mcp-gateway.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "mcp-gateway.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest and idea for this project 3 | title: "[Feature]: " 4 | labels: ["type:feature"] 5 | assignees: 6 | - matthisholleville 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thank you for initiating this feature request !🤗 12 | - type: checkboxes 13 | id: checklist 14 | attributes: 15 | label: Checklist 16 | description: "Please check the following before submitting this feature request" 17 | options: 18 | - label: I've searched for similar issues and couldn't find anything matching 19 | required: true 20 | 21 | - type: dropdown 22 | id: problem 23 | attributes: 24 | label: Is this feature request related to a problem? 25 | options: 26 | - "Yes" 27 | - "No" 28 | - type: textarea 29 | id: problem_description 30 | attributes: 31 | label: Problem Description 32 | description: If yes, please provide a clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 33 | validations: 34 | required: false 35 | - type: textarea 36 | id: solution_description 37 | attributes: 38 | label: Solution Description 39 | description: A clear and concise description of what you want to happen 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: benefits_description 44 | attributes: 45 | label: Benefits 46 | description: Describe the benefits this feature will bring to the project and its users 47 | validations: 48 | required: true 49 | - type: textarea 50 | id: drawbacks 51 | attributes: 52 | label: Potential Drawbacks 53 | description: Describe any potential drawbacks this feature might bring to the project and its users. 54 | - type: textarea 55 | id: additional_information 56 | attributes: 57 | label: Additional Information 58 | description: Add any other context about your feature request here. If applicable, add drawings to help explain. 59 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | // Package metrics is used to register and expose metrics for the application. 2 | package metrics 3 | 4 | import ( 5 | "github.com/prometheus/client_golang/prometheus" 6 | ) 7 | 8 | const defaultNamespace = "mcp_gateway" 9 | 10 | var ( 11 | ToolsCalledGauge = prometheus.NewGaugeVec( 12 | prometheus.GaugeOpts{ 13 | Name: defaultNamespace + "_tools_called", 14 | Help: "Current tools called by name and proxy", 15 | }, 16 | []string{"tool", "proxy"}, 17 | ) 18 | 19 | ListToolsGauge = prometheus.NewGaugeVec( 20 | prometheus.GaugeOpts{ 21 | Name: defaultNamespace + "_list_tools", 22 | Help: "Current list tools by proxy", 23 | }, 24 | []string{"proxy"}, 25 | ) 26 | 27 | ToolsCallErrorsGauge = prometheus.NewGaugeVec( 28 | prometheus.GaugeOpts{ 29 | Name: defaultNamespace + "_tools_call_errors", 30 | Help: "Current tools call errors by name and proxy", 31 | }, 32 | []string{"tool", "proxy"}, 33 | ) 34 | 35 | ToolsCallSuccessGauge = prometheus.NewGaugeVec( 36 | prometheus.GaugeOpts{ 37 | Name: defaultNamespace + "_tools_call_success", 38 | Help: "Current tools call success by name and proxy", 39 | }, 40 | []string{"tool", "proxy"}, 41 | ) 42 | 43 | CustomGaugeVecMetrics = []*prometheus.GaugeVec{ 44 | ToolsCalledGauge, 45 | ToolsCallErrorsGauge, 46 | ToolsCallSuccessGauge, 47 | ListToolsGauge, 48 | } 49 | 50 | CustomCounterMetrics = []prometheus.Counter{} 51 | 52 | CustomGaugeMetrics = []prometheus.Collector{} 53 | ) 54 | 55 | type Metrics struct { 56 | } 57 | 58 | func NewMetrics() *Metrics { 59 | return &Metrics{} 60 | } 61 | 62 | func (m *Metrics) RegisterCustomMetrics() error { 63 | for _, metric := range CustomGaugeVecMetrics { 64 | if err := prometheus.DefaultRegisterer.Register(metric); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | for _, metric := range CustomCounterMetrics { 70 | if err := prometheus.DefaultRegisterer.Register(metric); err != nil { 71 | return err 72 | } 73 | } 74 | 75 | for _, metric := range CustomGaugeMetrics { 76 | if err := prometheus.DefaultRegisterer.Register(metric); err != nil { 77 | return err 78 | } 79 | } 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | # This is an example .goreleaser.yml file with some sensible defaults. 3 | # Make sure to check the documentation at https://goreleaser.com 4 | before: 5 | hooks: 6 | - go mod download 7 | - go run github.com/steebchen/prisma-client-go generate 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | ldflags: 16 | - -s -w 17 | - -X main.version={{.Version}} 18 | - -X main.commit={{.ShortCommit}} 19 | - -X main.Date={{.CommitDate}} 20 | 21 | nfpms: 22 | - file_name_template: "{{ .ProjectName }}_{{ .Arch }}" 23 | maintainer: "MCP Gateway Maintainers " 24 | homepage: https://github.com/matthisholleville/mcp-gateway 25 | description: >- 26 | MCP Gateway is a flexible and extensible proxy gateway for MCP servers, providing enterprise-grade middleware capabilities including authentication, authorization, rate limiting (coming soon), and observability. 27 | license: "Apache-2.0" 28 | formats: 29 | - deb 30 | - rpm 31 | - apk 32 | bindir: /usr/bin 33 | section: utils 34 | contents: 35 | - src: ./LICENSE 36 | dst: /usr/share/doc/mcp-gateway/copyright 37 | file_info: 38 | mode: 0644 39 | 40 | sboms: 41 | - artifacts: archive 42 | 43 | archives: 44 | - format: tar.gz 45 | # this name template makes the OS and Arch compatible with the results of uname. 46 | name_template: >- 47 | {{ .ProjectName }}_ 48 | {{- title .Os }}_ 49 | {{- if eq .Arch "amd64" }}x86_64 50 | {{- else if eq .Arch "386" }}i386 51 | {{- else }}{{ .Arch }}{{ end }} 52 | {{- if .Arm }}v{{ .Arm }}{{ end }} 53 | # use zip for windows archives 54 | format_overrides: 55 | - goos: windows 56 | format: zip 57 | 58 | checksum: 59 | name_template: "checksums.txt" 60 | 61 | snapshot: 62 | name_template: "{{ incpatch .Version }}-next" 63 | 64 | # skip: true 65 | # The lines beneath this are called `modelines`. See `:help modeline` 66 | # Feel free to remove those if you don't want/use them. 67 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 68 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 69 | -------------------------------------------------------------------------------- /internal/storage/migrate/migrate_test.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | testsFixtures "github.com/matthisholleville/mcp-gateway/internal/storage/testsfixtures" 10 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func setupFixtures(t *testing.T, engine string) (string, logger.Logger, error) { 15 | logger := logger.MustNewLogger("json", "debug", "") 16 | switch engine { 17 | case "postgres": 18 | postgresOpts := &testsFixtures.PostgresTestContainerOptions{} 19 | db := testsFixtures.NewPostgresTestContainer(postgresOpts).RunPostgresTestContainer(t) 20 | return db.GetConnectionURI(true), logger, nil 21 | case "memory": 22 | return "", logger, nil 23 | default: 24 | return "", logger, fmt.Errorf("invalid engine: %s", engine) 25 | } 26 | } 27 | 28 | func TestMigrateUpDownDrop(t *testing.T) { 29 | type EngineConfig struct { 30 | Engine string 31 | } 32 | 33 | engines := []EngineConfig{ 34 | { 35 | Engine: "postgres", 36 | }, 37 | { 38 | Engine: "memory", 39 | }, 40 | } 41 | 42 | for _, engine := range engines { 43 | uri, logger, err := setupFixtures(t, engine.Engine) 44 | assert.NoError(t, err) 45 | 46 | cfg := &MigrationConfig{ 47 | Engine: engine.Engine, 48 | URI: uri, 49 | Logger: logger, 50 | Timeout: 10 * time.Second, 51 | Verbose: true, 52 | MigrationDir: "../../../assets/migrations/postgres", 53 | } 54 | 55 | err = RunMigrations(cfg) 56 | assert.NoError(t, err) 57 | 58 | if engine.Engine == "memory" { 59 | continue 60 | } 61 | 62 | // verify the database is not empty 63 | db, err := sql.Open(engine.Engine, uri) 64 | assert.NoError(t, err) 65 | defer db.Close() 66 | var count int 67 | err = db.QueryRow("SELECT COUNT(*) FROM mcp_gateway.proxy").Scan(&count) 68 | assert.NoError(t, err) 69 | 70 | // drop 71 | cfg.Drop = true 72 | err = RunMigrations(cfg) 73 | assert.NoError(t, err) 74 | 75 | db, err = sql.Open(engine.Engine, uri) 76 | assert.NoError(t, err) 77 | defer db.Close() 78 | 79 | // check if the database is empty 80 | err = db.QueryRow("SELECT COUNT(*) FROM mcp_gateway.proxy").Scan(&count) 81 | assert.Error(t, err) 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yaml: -------------------------------------------------------------------------------- 1 | name: Question / Bug Report 2 | description: Create a report to help us improve 3 | title: "[Question]: " 4 | labels: ["type:question"] 5 | assignees: 6 | - matthisholleville 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thank you for initiating this question! 🤗 12 | - type: checkboxes 13 | id: checklist 14 | attributes: 15 | label: Checklist 16 | description: "Please check the following before submitting this feature request" 17 | options: 18 | - label: I've searched for similar issues and couldn't find anything matching 19 | required: true 20 | - label: I've included steps to reproduce the behavior 21 | required: true 22 | 23 | - type: input 24 | id: mcp_gateway_version 25 | attributes: 26 | label: MCP Gateway Version 27 | description: Which version of MCP Gateway are you using? 28 | placeholder: "v0.0.1" 29 | 30 | - type: input 31 | id: host_os 32 | attributes: 33 | label: Host OS and its Version 34 | description: Which OS are you using (Windows/MacOS/Linux - Distro and Version)? 35 | 36 | - type: textarea 37 | id: reproduce 38 | attributes: 39 | label: Steps to reproduce 40 | description: Tell us how to reproduce this question 41 | placeholder: | 42 | 1. Run '...' 43 | 2. Type '...' 44 | 3. See error 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: expected 50 | attributes: 51 | label: Expected behaviour 52 | description: Tell us what should happen 53 | placeholder: | 54 | A clear and concise description of what you expected to happen. 55 | validations: 56 | required: true 57 | 58 | - type: textarea 59 | id: actual 60 | attributes: 61 | label: Actual behaviour 62 | description: Tell us what happens instead 63 | placeholder: | 64 | A clear and concise description of what actually happens. 65 | 66 | - type: textarea 67 | id: additional_information 68 | attributes: 69 | label: Additional Information 70 | description: Add any other context about the problem here. If applicable, add drawings to help explain (after saving this issue). 71 | -------------------------------------------------------------------------------- /.github/workflows/semantic_pr.yaml: -------------------------------------------------------------------------------- 1 | name: Semantic PR Validation 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | defaults: 9 | run: 10 | shell: bash 11 | jobs: 12 | validate: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read # Needed for checking out the repository 16 | pull-requests: read # Needed for reading prs 17 | steps: 18 | - name: Validate Pull Request 19 | uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | # Configure which types are allowed. 24 | # Default: https://github.com/commitizen/conventional-commit-types 25 | types: | 26 | feat 27 | fix 28 | build 29 | chore 30 | ci 31 | docs 32 | perf 33 | refactor 34 | revert 35 | style 36 | test 37 | deps 38 | scopes: | 39 | deps 40 | main 41 | # Configure that a scope must always be provided. 42 | requireScope: false 43 | # When using "Squash and merge" on a PR with only one commit, GitHub 44 | # will suggest using that commit message instead of the PR title for the 45 | # merge commit, and it's easy to commit this by mistake. Enable this option 46 | # to also validate the commit message for one commit PRs. 47 | validateSingleCommit: true 48 | # Configure additional validation for the subject based on a regex. 49 | # This ensures the subject doesn't start with an uppercase character. 50 | subjectPattern: ^(?![A-Z]).+$ 51 | # If `subjectPattern` is configured, you can use this property to override 52 | # the default error message that is shown when the pattern doesn't match. 53 | # The variables `subject` and `title` can be used within the message. 54 | subjectPatternError: | 55 | The subject "{subject}" found in the pull request title "{title}" 56 | didn't match the configured pattern. Please ensure that the subject 57 | doesn't start with an uppercase character. -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "mcp-gateway.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "mcp-gateway.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /pkg/aescipher/aescipher_test.go: -------------------------------------------------------------------------------- 1 | package aescipher 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "testing" 8 | ) 9 | 10 | // randomKey returns a fresh 32‑byte AES‑256 key generated from crypto/rand. 11 | func randomKey(t *testing.T) []byte { 12 | t.Helper() 13 | k := make([]byte, 32) 14 | if _, err := rand.Read(k); err != nil { 15 | t.Fatalf("rand: %v", err) 16 | } 17 | return k 18 | } 19 | 20 | // randomData returns n random bytes using crypto/rand. 21 | func randomData(t *testing.T, n int) []byte { 22 | t.Helper() 23 | d := make([]byte, n) 24 | if _, err := rand.Read(d); err != nil { 25 | t.Fatalf("rand: %v", err) 26 | } 27 | return d 28 | } 29 | 30 | // TestRoundTrip encrypts and then decrypts a plaintext and expects to get the 31 | // original bytes back unchanged. 32 | func TestRoundTrip(t *testing.T) { 33 | key := hex.EncodeToString(randomKey(t)) 34 | enc, err := New(key) 35 | if err != nil { 36 | t.Fatalf("New: %v", err) 37 | } 38 | plain := []byte("Hello, world!") 39 | 40 | ct, err := enc.Encrypt(plain) 41 | if err != nil { 42 | t.Fatalf("Encrypt: %v", err) 43 | } 44 | got, err := enc.Decrypt(ct) 45 | if err != nil { 46 | t.Fatalf("Decrypt: %v", err) 47 | } 48 | if !bytes.Equal(got, plain) { 49 | t.Fatalf("decrypt mismatch: want %q, got %q", plain, got) 50 | } 51 | } 52 | 53 | // TestWrongKeySize ensures New returns an error when the key length is not 32 bytes. 54 | func TestWrongKeySize(t *testing.T) { 55 | key := "too‑short" 56 | if _, err := New(key); err == nil { 57 | t.Fatal("expected error for invalid key length, got nil") 58 | } 59 | } 60 | 61 | // TestTamper flips a byte in the ciphertext and expects an authentication error on Decrypt. 62 | func TestTamper(t *testing.T) { 63 | key := hex.EncodeToString(randomKey(t)) 64 | enc, _ := New(key) 65 | pt := []byte("secret") 66 | ct, _ := enc.Encrypt(pt) 67 | ct[len(ct)-1] ^= 0xFF // corrupt the last byte 68 | if _, err := enc.Decrypt(ct); err == nil { 69 | t.Fatal("expected authentication error, got nil") 70 | } 71 | } 72 | 73 | // BenchmarkEncrypt times Encrypt on a 1 KiB buffer. 74 | func BenchmarkEncrypt(b *testing.B) { 75 | key := hex.EncodeToString(randomKey(nil)) // zero key avoids allocating in the loop 76 | enc, _ := New(key) 77 | data := randomData(nil, 1<<10) // 1 KiB 78 | 79 | b.ResetTimer() 80 | for i := 0; i < b.N; i++ { 81 | enc.Encrypt(data) 82 | } 83 | } 84 | 85 | // BenchmarkDecrypt times Decrypt on a 1 KiB buffer. 86 | func BenchmarkDecrypt(b *testing.B) { 87 | key := hex.EncodeToString(randomKey(nil)) 88 | enc, _ := New(key) 89 | data := randomData(nil, 1<<10) 90 | ct, _ := enc.Encrypt(data) 91 | 92 | b.ResetTimer() 93 | for i := 0; i < b.N; i++ { 94 | enc.Decrypt(ct) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /internal/server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/labstack/echo/v4" 11 | "github.com/mark3labs/mcp-go/mcp" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // authMiddleware is the middleware that checks if the request is valid and if the user has the necessary permissions 16 | func (s *Server) authMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 17 | return func(c echo.Context) error { 18 | isMCPPath := c.Path() == "/mcp" && c.Request().Method == "POST" 19 | if !isMCPPath { 20 | return next(c) 21 | } 22 | 23 | message, err := s.parseRequestBody(c) 24 | if err != nil { 25 | return s.unauth(c, "invalid_request", "Invalid request") 26 | } 27 | 28 | isOAuthEnabled := s.Config.OAuth.Enabled 29 | isToolCall := message.Method == "tools/call" 30 | if !isOAuthEnabled && !isToolCall { 31 | return next(c) 32 | } 33 | 34 | token := c.Request().Header.Get("Authorization") 35 | if token == "" { 36 | return s.unauth(c, "missing_token", "Missing token") 37 | } 38 | token = strings.TrimPrefix(token, "Bearer ") 39 | 40 | jwtToken, err := s.Provider.VerifyToken(token) 41 | if err != nil { 42 | return s.unauth(c, "invalid_token", "Invalid token") 43 | } 44 | 45 | // tools/call:tools 46 | s.Logger.Debug("Verifying permissions for tool call", 47 | zap.String("method", message.Method), 48 | zap.String("params", message.Params.Name), 49 | zap.Any("claims", jwtToken.Claims)) 50 | objectType := strings.Split(message.Method, "/")[0] 51 | paramsSplit := strings.Split(message.Params.Name, ":") 52 | objectName := paramsSplit[1] 53 | proxyName := paramsSplit[0] 54 | 55 | hasPermission := s.Provider.VerifyPermissions(c.Request().Context(), objectType, proxyName, objectName, jwtToken.Claims) 56 | if !hasPermission { 57 | return s.unauth(c, "insufficient_scope", "Insufficient scope") 58 | } 59 | 60 | c.Set("claims", jwtToken.Claims) 61 | return next(c) 62 | } 63 | } 64 | 65 | // parseRequestBody parses the request body and returns a MCP request 66 | func (s *Server) parseRequestBody(c echo.Context) (*mcp.CallToolRequest, error) { 67 | const maxBodySize = 1 << 20 // 1 MiB 68 | 69 | req := c.Request() 70 | body := req.Body 71 | req.Body = http.MaxBytesReader(c.Response(), body, maxBodySize) 72 | 73 | var copyBuf bytes.Buffer 74 | tee := io.TeeReader(req.Body, ©Buf) 75 | 76 | dec := json.NewDecoder(tee) 77 | 78 | message := &mcp.CallToolRequest{} 79 | err := dec.Decode(message) 80 | if err != nil { 81 | s.Logger.Error("Failed to unmarshal request body", zap.Error(err)) 82 | return nil, err 83 | } 84 | 85 | req.Body = io.NopCloser(©Buf) 86 | 87 | return message, nil 88 | } 89 | -------------------------------------------------------------------------------- /assets/migrations/postgres/000001_initialize_schema.up.sql: -------------------------------------------------------------------------------- 1 | -- Create the mcp_gateway schema if it doesn't exist 2 | CREATE SCHEMA IF NOT EXISTS mcp_gateway; 3 | 4 | -- Set the search path to use the mcp_gateway schema 5 | SET search_path TO mcp_gateway, public; 6 | 7 | -- Create the proxy table 8 | CREATE TABLE proxy ( 9 | Name TEXT PRIMARY KEY, 10 | Type VARCHAR(255) NOT NULL, 11 | URL TEXT NOT NULL, 12 | AuthType VARCHAR(255) NOT NULL, 13 | Timeout INT NOT NULL 14 | ); 15 | 16 | -- Create the proxy_header table 17 | CREATE TABLE proxy_header ( 18 | ProxyName TEXT NOT NULL, 19 | HeaderKey TEXT NOT NULL, 20 | HeaderValue TEXT NOT NULL, 21 | PRIMARY KEY (ProxyName, HeaderKey), 22 | FOREIGN KEY (ProxyName) REFERENCES proxy(Name) ON DELETE CASCADE 23 | ); 24 | 25 | -- Create the proxy_oauth table 26 | CREATE TABLE proxy_oauth ( 27 | ProxyName TEXT, 28 | ClientId TEXT NOT NULL, 29 | ClientSecret TEXT NOT NULL, 30 | TokenEndpoint TEXT NOT NULL, 31 | Scopes TEXT, 32 | PRIMARY KEY (ProxyName, ClientId, ClientSecret), 33 | FOREIGN KEY (ProxyName) REFERENCES proxy(Name) ON DELETE CASCADE 34 | ); 35 | 36 | -- Create the role table 37 | CREATE TABLE role ( 38 | Name VARCHAR(255) PRIMARY KEY 39 | ); 40 | 41 | -- Create the role_permission table 42 | CREATE TABLE role_permission ( 43 | RoleName VARCHAR(255) NOT NULL, 44 | ObjectType VARCHAR(255) NOT NULL, 45 | ObjectName TEXT NOT NULL, 46 | ProxyName TEXT NOT NULL, 47 | PRIMARY KEY (RoleName, ObjectType, ObjectName, ProxyName), 48 | FOREIGN KEY (RoleName) REFERENCES role(Name) ON DELETE CASCADE 49 | ); 50 | 51 | -- Create the attribute_to_roles table 52 | CREATE TABLE attribute_to_roles ( 53 | AttributeKey TEXT NOT NULL, 54 | AttributeValue TEXT NOT NULL, 55 | RoleName VARCHAR(255) NOT NULL, 56 | PRIMARY KEY (AttributeKey, AttributeValue, RoleName), 57 | FOREIGN KEY (RoleName) REFERENCES role(Name) 58 | ); 59 | 60 | -- accelerate GetAttributeToRoles 61 | CREATE INDEX IF NOT EXISTS idx_attr_roles_key_value 62 | ON mcp_gateway.attribute_to_roles (attributekey, attributevalue); 63 | 64 | -- protect frequent role_permission ↔ proxy joins 65 | CREATE INDEX IF NOT EXISTS idx_role_permission_proxyname 66 | ON mcp_gateway.role_permission (proxyname); 67 | 68 | -- allow fast search by header 69 | CREATE INDEX IF NOT EXISTS idx_proxy_header_key 70 | ON mcp_gateway.proxy_header (proxyname, headerkey); 71 | 72 | -- allow fast search by role name 73 | CREATE INDEX IF NOT EXISTS idx_role_permission_rolename 74 | ON mcp_gateway.role_permission (rolename); 75 | 76 | -- allow fast search by object type and proxy name 77 | CREATE INDEX IF NOT EXISTS idx_role_permission_object 78 | ON mcp_gateway.role_permission (objecttype, proxyname); -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "initial-version": "0.1.0", 6 | "release-type": "go", 7 | "prerelease": false, 8 | "bump-minor-pre-major": true, 9 | "bump-patch-for-minor-pre-major": true, 10 | "draft": false, 11 | "extra-files": [ 12 | { 13 | "type": "yaml", 14 | "path": "charts/mcp-gateway/Chart.yaml", 15 | "jsonpath": "$.appVersion" 16 | }, 17 | { 18 | "type": "yaml", 19 | "path": "charts/mcp-gateway/values.yaml", 20 | "jsonpath": "$.image.tag" 21 | } 22 | ], 23 | "changelog-sections": [ 24 | { 25 | "type": "feat", 26 | "section": "Features" 27 | }, 28 | { 29 | "type": "fix", 30 | "section": "Bug Fixes" 31 | }, 32 | { 33 | "type": "chore", 34 | "section": "Other" 35 | }, 36 | { 37 | "type": "docs", 38 | "section": "Docs" 39 | }, 40 | { 41 | "type": "perf", 42 | "section": "Performance" 43 | }, 44 | { 45 | "type": "build", 46 | "hidden": true, 47 | "section": "Build" 48 | }, 49 | { 50 | "type": "deps", 51 | "section": "Dependency Updates" 52 | }, 53 | { 54 | "type": "ci", 55 | "hidden": true, 56 | "section": "CI" 57 | }, 58 | { 59 | "type": "refactor", 60 | "section": "Refactoring" 61 | }, 62 | { 63 | "type": "revert", 64 | "hidden": true, 65 | "section": "Reverts" 66 | }, 67 | { 68 | "type": "style", 69 | "hidden": true, 70 | "section": "Styling" 71 | }, 72 | { 73 | "type": "test", 74 | "hidden": true, 75 | "section": "Tests" 76 | } 77 | ] 78 | } 79 | }, 80 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 81 | } -------------------------------------------------------------------------------- /.github/actions/package-docker-image/action.yml: -------------------------------------------------------------------------------- 1 | name: Package Docker Image 2 | description: "Package a Docker image" 3 | 4 | inputs: 5 | registry: 6 | description: "The Docker registry to push the image to (e.g., Docker Hub)" 7 | required: false 8 | default: "ghcr.io/matthisholleville/" 9 | build_context_directory: 10 | description: "Directory to use as build context" 11 | default: "." 12 | build_image_directory: 13 | description: "Path to the Dockerfile" 14 | default: "./Dockerfile" 15 | container_target_platforms: 16 | description: "Target platforms for the container (e.g., linux/amd64)" 17 | default: "linux/amd64,linux/arm64" 18 | container_image_name: 19 | description: "Name of the image to build" 20 | required: true 21 | container_image_tag: 22 | description: "Tag of the image to build" 23 | required: true 24 | container_image_tag_latest: 25 | description: "Enable this option to also tag the image with 'latest'" 26 | default: "false" 27 | container_image_push: 28 | description: "Enable this option to push the image to the registry" 29 | default: "false" 30 | container_build_args: 31 | description: "Additional build arguments" 32 | default: "" 33 | github_token: 34 | description: "GitHub token" 35 | required: true 36 | runs: 37 | using: composite 38 | steps: 39 | - name: Login to GitHub Container Registry 40 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 41 | with: 42 | registry: "ghcr.io" 43 | username: ${{ github.actor }} 44 | password: ${{ inputs.github_token }} 45 | - name: Set up Docker Buildx 46 | id: buildx 47 | uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 48 | 49 | - name: Generate tags 50 | id: generate-tags 51 | shell: bash 52 | run: | 53 | if [[ ${{ inputs.container_image_tag_latest }} == true ]]; then 54 | TAGS="${{ inputs.container_image_name }}:${{ inputs.container_image_tag }},${{ inputs.container_image_name }}:latest" 55 | else 56 | TAGS="${{ inputs.container_image_name }}:${{ inputs.container_image_tag }}" 57 | fi 58 | echo tags=$TAGS >> $GITHUB_OUTPUT 59 | 60 | - name: Build Docker Image 61 | uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 62 | id: build_docker_image 63 | with: 64 | context: ${{ inputs.build_context_directory }} 65 | file: ${{ inputs.build_image_directory }} 66 | platforms: ${{ inputs.container_target_platforms }} 67 | tags: ${{ steps.generate-tags.outputs.tags }} 68 | build-args: ${{ inputs.container_build_args }} 69 | builder: ${{ steps.buildx.outputs.name }} 70 | push: "${{ inputs.container_image_push }}" 71 | cache-from: type=gha,scope=${{ github.ref_name }}-${{ inputs.container_image_name }} 72 | cache-to: type=gha,scope=${{ github.ref_name }}-${{ inputs.container_image_name }} 73 | -------------------------------------------------------------------------------- /internal/storage/memory_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMemoryProxyStorage(t *testing.T) { 11 | storage := NewMemoryStorage("") 12 | proxy := ProxyConfig{Name: "test", Type: ProxyTypeStreamableHTTP, AuthType: ProxyAuthTypeHeader, Headers: []ProxyHeader{ 13 | {Key: "test", Value: "test"}, 14 | }} 15 | err := storage.SetProxy(context.Background(), &proxy, false) 16 | assert.NoError(t, err) 17 | proxy, err = storage.GetProxy(context.Background(), proxy.Name, false) 18 | assert.NoError(t, err) 19 | assert.Equal(t, proxy.Name, "test") 20 | err = storage.DeleteProxy(context.Background(), proxy.Name) 21 | assert.NoError(t, err) 22 | proxy, err = storage.GetProxy(context.Background(), proxy.Name, false) 23 | assert.Error(t, err) 24 | assert.Equal(t, proxy.Name, "") 25 | } 26 | 27 | func TestMemoryStorageRoles(t *testing.T) { 28 | storage := NewMemoryStorage("") 29 | role := RoleConfig{Name: "admin", Permissions: []PermissionConfig{ 30 | { 31 | ObjectType: "*", 32 | Proxy: "*", 33 | ObjectName: "*", 34 | }, 35 | }} 36 | err := storage.SetRole(context.Background(), role) 37 | assert.NoError(t, err) 38 | role, err = storage.GetRole(context.Background(), role.Name) 39 | assert.NoError(t, err) 40 | assert.Equal(t, role.Permissions, []PermissionConfig{ 41 | { 42 | ObjectType: "*", 43 | Proxy: "*", 44 | ObjectName: "*", 45 | }, 46 | }) 47 | err = storage.SetRole(context.Background(), RoleConfig{Name: "admin", Permissions: []PermissionConfig{ 48 | { 49 | ObjectType: "*", 50 | Proxy: "*", 51 | ObjectName: "*", 52 | }, 53 | }}) 54 | assert.Error(t, err, "role already exists") 55 | roles, err := storage.ListRoles(context.Background()) 56 | assert.NoError(t, err) 57 | assert.Equal(t, roles, []RoleConfig{role}) 58 | err = storage.DeleteRole(context.Background(), role.Name) 59 | assert.NoError(t, err) 60 | roles, err = storage.ListRoles(context.Background()) 61 | assert.NoError(t, err) 62 | assert.Equal(t, roles, []RoleConfig{}) 63 | } 64 | 65 | func TestMemoryStorageClaimToRoles(t *testing.T) { 66 | storage := NewMemoryStorage("") 67 | attributeToRoles := AttributeToRolesConfig{AttributeKey: "email", AttributeValue: "test@test.com", Roles: []string{"test"}} 68 | err := storage.SetAttributeToRoles(context.Background(), attributeToRoles) 69 | assert.Error(t, err, "role not found") 70 | role := RoleConfig{Name: "test", Permissions: []PermissionConfig{ 71 | { 72 | ObjectType: "*", 73 | Proxy: "*", 74 | ObjectName: "*", 75 | }, 76 | }} 77 | err = storage.SetRole(context.Background(), role) 78 | assert.NoError(t, err) 79 | err = storage.SetAttributeToRoles(context.Background(), attributeToRoles) 80 | assert.NoError(t, err) 81 | err = storage.SetAttributeToRoles(context.Background(), attributeToRoles) 82 | assert.Error(t, err, "attribute to roles already exists") 83 | attributeToRolesList, err := storage.ListAttributeToRoles(context.Background()) 84 | assert.NoError(t, err) 85 | assert.Equal(t, attributeToRolesList, []AttributeToRolesConfig{attributeToRoles}) 86 | err = storage.DeleteAttributeToRoles(context.Background(), attributeToRoles.AttributeKey, attributeToRoles.AttributeValue) 87 | assert.NoError(t, err) 88 | } 89 | -------------------------------------------------------------------------------- /charts/mcp-gateway/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "mcp-gateway.fullname" . }} 5 | labels: 6 | {{- include "mcp-gateway.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "mcp-gateway.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "mcp-gateway.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "mcp-gateway.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | {{- if .Values.migrate.enabled }} 31 | initContainers: 32 | - name: migrate 33 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 34 | imagePullPolicy: {{ .Values.image.pullPolicy }} 35 | args: ["migrate", "--verbose","--log-level", "info", "--log-format", "json"] 36 | volumeMounts: 37 | - name: {{ include "mcp-gateway.fullname" . }}-config 38 | mountPath: /etc/mcp-gateway 39 | env: 40 | {{- with .Values.extraEnv }} 41 | {{- toYaml . | nindent 12 }} 42 | {{- end }} 43 | {{- end }} 44 | containers: 45 | - name: {{ .Chart.Name }} 46 | securityContext: 47 | {{- toYaml .Values.securityContext | nindent 12 }} 48 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 49 | imagePullPolicy: {{ .Values.image.pullPolicy }} 50 | args: ["serve", "--log-level", "info", "--log-format", "json"] 51 | volumeMounts: 52 | - name: {{ include "mcp-gateway.fullname" . }}-config 53 | mountPath: /etc/mcp-gateway 54 | ports: 55 | - name: http 56 | containerPort: {{ .Values.containerPort }} 57 | protocol: TCP 58 | livenessProbe: 59 | httpGet: 60 | path: /live 61 | port: http 62 | readinessProbe: 63 | httpGet: 64 | path: /ready 65 | port: http 66 | resources: 67 | {{- toYaml .Values.resources | nindent 12 }} 68 | env: 69 | - name: GOMEMLIMIT 70 | valueFrom: 71 | resourceFieldRef: 72 | resource: limits.memory 73 | {{- with .Values.extraEnv }} 74 | {{- toYaml . | nindent 12 }} 75 | {{- end }} 76 | volumes: 77 | - name: {{ include "mcp-gateway.fullname" . }}-config 78 | configMap: 79 | name: {{ include "mcp-gateway.fullname" . }}-config 80 | {{- with .Values.nodeSelector }} 81 | nodeSelector: 82 | {{- toYaml . | nindent 8 }} 83 | {{- end }} 84 | {{- with .Values.affinity }} 85 | affinity: 86 | {{- toYaml . | nindent 8 }} 87 | {{- end }} 88 | {{- with .Values.tolerations }} 89 | tolerations: 90 | {{- toYaml . | nindent 8 }} 91 | {{- end }} 92 | -------------------------------------------------------------------------------- /cmd/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | // Package migrate provides a command to run the MCP Gateway migrations. 2 | package migrate 3 | 4 | import ( 5 | "time" 6 | 7 | "github.com/matthisholleville/mcp-gateway/internal/cfg" 8 | "github.com/matthisholleville/mcp-gateway/internal/storage/migrate" 9 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | const ( 15 | backendEngineFlag = "backend-engine" 16 | backendURIFlag = "backend-uri" 17 | backendUsernameFlag = "backend-username" 18 | backendPasswordFlag = "backend-password" 19 | logFormatFlag = "log-format" 20 | logLevelFlag = "log-level" 21 | logTimestampFlag = "log-timestamp-format" 22 | targetVersionFlag = "target-version" 23 | verboseMigrationFlag = "verbose" 24 | timeoutFlag = "timeout" 25 | dropFlag = "drop" 26 | dirFlag = "dir" 27 | 28 | defaultTimeout = 30 * time.Second 29 | defaultVersion = 0 30 | ) 31 | 32 | // NewMigrateCommand creates a new migrate command. 33 | func NewMigrateCommand() *cobra.Command { 34 | cmd := &cobra.Command{ 35 | Use: "migrate", 36 | Short: "Run the MCP Gateway migrations", 37 | Long: "Run the MCP Gateway migrations.", 38 | RunE: runMigration, 39 | Args: cobra.NoArgs, 40 | } 41 | defaultConfig := cfg.DefaultConfig() 42 | flags := cmd.Flags() 43 | 44 | flags.String(backendEngineFlag, defaultConfig.BackendConfig.Engine, "(required) The engine to use for the auth backend") 45 | 46 | flags.String(backendURIFlag, defaultConfig.BackendConfig.URI, "(required) The URI to use for the auth backend") 47 | 48 | flags.String(backendUsernameFlag, defaultConfig.BackendConfig.Username, "The username to use for the auth backend") 49 | 50 | flags.String(backendPasswordFlag, defaultConfig.BackendConfig.Password, "The password to use for the auth backend") 51 | 52 | flags.Bool(verboseMigrationFlag, false, "enable verbose migration logs (default false)") 53 | 54 | flags.String(logFormatFlag, defaultConfig.Log.Format, "The format to use for logging") 55 | 56 | flags.String(logLevelFlag, defaultConfig.Log.Level, "The level to use for logging") 57 | 58 | flags.String(logTimestampFlag, defaultConfig.Log.TimestampFormat, "The format to use for logging timestamps") 59 | 60 | flags.Int(targetVersionFlag, defaultVersion, "The target version to migrate to (default 0)") 61 | 62 | flags.Duration(timeoutFlag, defaultTimeout, "The timeout to use for the migration") 63 | 64 | flags.Bool(dropFlag, false, "Drop all migrations") 65 | 66 | flags.String(dirFlag, "", "The directory to use for the migrations") 67 | 68 | cmd.PreRun = bindRunFlagsFunc(flags) 69 | 70 | return cmd 71 | } 72 | 73 | func runMigration(_ *cobra.Command, _ []string) error { 74 | engine := viper.GetString(backendEngineFlag) 75 | uri := viper.GetString(backendURIFlag) 76 | username := viper.GetString(backendUsernameFlag) 77 | password := viper.GetString(backendPasswordFlag) 78 | verbose := viper.GetBool(verboseMigrationFlag) 79 | logFormat := viper.GetString(logFormatFlag) 80 | logLevel := viper.GetString(logLevelFlag) 81 | logTimestamp := viper.GetString(logTimestampFlag) 82 | targetVersion := viper.GetInt(targetVersionFlag) 83 | timeout := viper.GetDuration(timeoutFlag) 84 | drop := viper.GetBool(dropFlag) 85 | 86 | log := logger.MustNewLogger(logFormat, logLevel, logTimestamp) 87 | 88 | config := migrate.MigrationConfig{ 89 | Engine: engine, 90 | URI: uri, 91 | Username: username, 92 | Password: password, 93 | Version: targetVersion, 94 | Timeout: timeout, 95 | Logger: log, 96 | Verbose: verbose, 97 | Drop: drop, 98 | } 99 | 100 | return migrate.RunMigrations(&config) 101 | } 102 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | We're happy that you want to contribute to this project. Please read the sections to make the process as smooth as possible. 3 | 4 | ## Requirements 5 | - Golang `1.24` 6 | - If you want to build the container image, you need to have a container engine (docker, podman, rancher, etc.) installed 7 | 8 | ## Getting Started 9 | 10 | **Where should I start?** 11 | - If you are looking for something to work on, check out our [open issues](https://github.com/matthisholleville/mcp-gateway/issues). 12 | - If you have an idea for a new feature, please open an issue, and we can discuss it. 13 | - We are also happy to help you find something to work on. Just reach out to us. 14 | 15 | **Discuss issues** 16 | * Before you start working on something, propose and discuss your solution on the issue 17 | * If you are unsure about something, ask the community 18 | 19 | **How do I contribute?** 20 | - Fork the repository and clone it locally 21 | - Create a new branch and follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) guidelines for work undertaken 22 | - Assign yourself to the issue, if you are working on it (if you are not a member of the organization, please leave a comment on the issue) 23 | - Make your changes 24 | - Keep pull requests small and focused, if you have multiple changes, please open multiple PRs 25 | - Create a pull request back to the upstream repository and follow the [pull request template](.github/pull_request_template.md) guidelines. 26 | - Wait for a review and address any comments 27 | 28 | **Opening PRs** 29 | - As long as you are working on your PR, please mark it as a draft 30 | - Please make sure that your PR is up-to-date with the latest changes in `main` 31 | - Fill out the PR template 32 | - Mention the issue that your PR is addressing (closes: #) 33 | - Make sure that your PR passes all checks 34 | 35 | **Reviewing PRs** 36 | - Be respectful and constructive 37 | - Assign yourself to the PR 38 | - Check if all checks are passing 39 | - Suggest changes instead of simply commenting on found issues 40 | - If you are unsure about something, ask the author 41 | - If you are not sure if the changes work, try them out 42 | - Reach out to other reviewers if you are unsure about something 43 | - If you are happy with the changes, approve the PR 44 | - Merge the PR once it has all approvals and the checks are passing 45 | 46 | ## Semantic commits 47 | We use [Semantic Commits](https://www.conventionalcommits.org/en/v1.0.0/) to make it easier to understand what a commit does and to build pretty changelogs. Please use the following prefixes for your commits: 48 | - `feat`: A new feature 49 | - `fix`: A bug fix 50 | - `docs`: Documentation changes 51 | - `chores`: Changes to the build process or auxiliary tools and libraries such as documentation generation 52 | - `refactor`: A code change that neither fixes a bug nor adds a feature 53 | - `test`: Adding missing tests or correcting existing tests 54 | - `ci`: Changes to our CI configuration files and scripts 55 | 56 | An example for this could be: 57 | ``` 58 | git commit -m "docs: add a new section to the README" 59 | ``` 60 | 61 | ## Building 62 | Building the binary is as simple as running `make build` in the root of the repository. If you want to build the container image, you can run `make build-container` in the root of the repository. 63 | 64 | ## Releasing 65 | Releases of MCP Gateway are done using [Release Please](https://github.com/googleapis/release-please) and [GoReleaser](https://goreleaser.com/). The workflow looks like this: 66 | 67 | * A PR is merged to the `main` branch: 68 | * Release please is triggered, creates or updates a new release PR 69 | * This is done with every merge to main, the current release PR is updated every time 70 | 71 | * Merging the 'release please' PR to `main`: 72 | * Release please is triggered, creates a new release and updates the changelog based on the commit messages 73 | * GoReleaser is triggered, builds the binaries and attaches them to the release 74 | * Containers are created and pushed to the container registry 75 | 76 | > With the next relevant merge, a new release PR will be created and the process starts again 77 | -------------------------------------------------------------------------------- /charts/mcp-gateway/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for mcp-gateway. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: ghcr.io/matthisholleville/mcp-gateway-dev 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "dev-788f103e590a6381f94cfe88fd190332b336251e" 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | fullnameOverride: "" 16 | 17 | containerPort: 8082 18 | 19 | serviceAccount: 20 | # Specifies whether a service account should be created 21 | create: true 22 | # Annotations to add to the service account 23 | annotations: {} 24 | # The name of the service account to use. 25 | # If not set and create is true, a name is generated using the fullname template 26 | name: "" 27 | 28 | podAnnotations: 29 | prometheus.io/scrape: "true" 30 | prometheus.io/port: "8082" 31 | 32 | podSecurityContext: 33 | {} 34 | # fsGroup: 2000 35 | 36 | securityContext: 37 | {} 38 | # capabilities: 39 | # drop: 40 | # - ALL 41 | # readOnlyRootFilesystem: true 42 | # runAsNonRoot: true 43 | # runAsUser: 1000 44 | 45 | service: 46 | type: ClusterIP 47 | port: 80 48 | 49 | ingress: 50 | enabled: false 51 | className: "" 52 | annotations: 53 | {} 54 | # kubernetes.io/ingress.class: nginx 55 | # kubernetes.io/tls-acme: "true" 56 | hosts: 57 | - host: chart-example.local 58 | paths: 59 | - path: / 60 | pathType: ImplementationSpecific 61 | tls: [] 62 | # - secretName: chart-example-tls 63 | # hosts: 64 | # - chart-example.local 65 | 66 | resources: 67 | limits: 68 | memory: 1Gi 69 | requests: 70 | cpu: 100m 71 | memory: 512Mi 72 | 73 | migrate: 74 | enabled: true 75 | 76 | extraEnv: 77 | - name: MCP_GATEWAY_AUTH_PROVIDER_ENABLED 78 | value: "true" 79 | - name: MCP_GATEWAY_AUTH_PROVIDER_NAME 80 | value: "okta" 81 | - name: MCP_GATEWAY_OKTA_ISSUER 82 | valueFrom: 83 | secretKeyRef: 84 | name: okta-secret 85 | key: issuer 86 | - name: MCP_GATEWAY_OKTA_ORG_URL 87 | valueFrom: 88 | secretKeyRef: 89 | name: okta-secret 90 | key: org_url 91 | - name: MCP_GATEWAY_OKTA_CLIENT_ID 92 | valueFrom: 93 | secretKeyRef: 94 | name: okta-secret 95 | key: client_id 96 | - name: MCP_GATEWAY_OKTA_PRIVATE_KEY 97 | valueFrom: 98 | secretKeyRef: 99 | name: okta-secret 100 | key: private_key 101 | - name: MCP_GATEWAY_OKTA_PRIVATE_KEY_ID 102 | valueFrom: 103 | secretKeyRef: 104 | name: okta-secret 105 | key: private_key_id 106 | - name: MCP_GATEWAY_OAUTH_ENABLED 107 | value: "false" 108 | - name: MCP_GATEWAY_BACKEND_ENGINE 109 | value: "postgres" 110 | - name: MCP_GATEWAY_BACKEND_URI 111 | value: "postgresql://postgres:will-change@mcp-gateway-postgresql:5432/mcp-gateway?sslmode=disable" 112 | - name: MCP_GATEWAY_BACKEND_USERNAME 113 | value: "postgres" 114 | - name: MCP_GATEWAY_BACKEND_PASSWORD 115 | valueFrom: 116 | secretKeyRef: 117 | name: mcp-gateway-postgresql-secret 118 | key: postgresql-password 119 | - name: MCP_GATEWAY_BACKEND_ENCRYPTION_KEY 120 | value: "0123456789abcdeffedcba9876543210cafebabefacefeeddeadbeef00112233" # Change this to a random key 121 | - name: MCP_GATEWAY_HTTP_ADMIN_API_KEY 122 | value: "admin" # Change this to a random string 123 | 124 | configuration: | 125 | # MCP Gateway configuration 126 | 127 | autoscaling: 128 | enabled: false 129 | minReplicas: 1 130 | maxReplicas: 100 131 | targetCPUUtilizationPercentage: 80 132 | # targetMemoryUtilizationPercentage: 80 133 | 134 | nodeSelector: {} 135 | 136 | tolerations: [] 137 | 138 | affinity: {} 139 | 140 | postgresql: 141 | enabled: true 142 | auth: 143 | database: mcp-gateway 144 | username: postgres 145 | # define a secret with values for "postgres-password", "password" (user Password) 146 | existingSecret: mcp-gateway-postgresql-secret 147 | secretKeys: 148 | adminPasswordKey: postgresql-password 149 | -------------------------------------------------------------------------------- /charts/mcp-gateway/values-dev.yaml: -------------------------------------------------------------------------------- 1 | # Default values for mcp-gateway. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: ghcr.io/matthisholleville/mcp-gateway-dev 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "dev-f810708eb3c412858fef136fb8668078457adf92" 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | fullnameOverride: "" 16 | 17 | containerPort: 8082 18 | 19 | serviceAccount: 20 | # Specifies whether a service account should be created 21 | create: true 22 | # Annotations to add to the service account 23 | annotations: {} 24 | # The name of the service account to use. 25 | # If not set and create is true, a name is generated using the fullname template 26 | name: "" 27 | 28 | podAnnotations: 29 | prometheus.io/scrape: "true" 30 | prometheus.io/port: "8082" 31 | 32 | podSecurityContext: 33 | {} 34 | # fsGroup: 2000 35 | 36 | securityContext: 37 | {} 38 | # capabilities: 39 | # drop: 40 | # - ALL 41 | # readOnlyRootFilesystem: true 42 | # runAsNonRoot: true 43 | # runAsUser: 1000 44 | 45 | service: 46 | type: ClusterIP 47 | port: 80 48 | 49 | ingress: 50 | enabled: false 51 | className: "" 52 | annotations: 53 | {} 54 | # kubernetes.io/ingress.class: nginx 55 | # kubernetes.io/tls-acme: "true" 56 | hosts: 57 | - host: chart-example.local 58 | paths: 59 | - path: / 60 | pathType: ImplementationSpecific 61 | tls: [] 62 | # - secretName: chart-example-tls 63 | # hosts: 64 | # - chart-example.local 65 | 66 | resources: 67 | limits: 68 | memory: 1Gi 69 | requests: 70 | cpu: 100m 71 | memory: 512Mi 72 | 73 | migrate: 74 | enabled: true 75 | 76 | extraEnv: 77 | - name: MCP_GATEWAY_AUTH_PROVIDER_ENABLED 78 | value: "true" 79 | - name: MCP_GATEWAY_AUTH_PROVIDER_NAME 80 | value: "okta" 81 | - name: MCP_GATEWAY_OKTA_ISSUER 82 | valueFrom: 83 | secretKeyRef: 84 | name: okta-secret 85 | key: issuer 86 | - name: MCP_GATEWAY_OKTA_ORG_URL 87 | valueFrom: 88 | secretKeyRef: 89 | name: okta-secret 90 | key: org_url 91 | - name: MCP_GATEWAY_OKTA_CLIENT_ID 92 | valueFrom: 93 | secretKeyRef: 94 | name: okta-secret 95 | key: client_id 96 | - name: MCP_GATEWAY_OKTA_PRIVATE_KEY 97 | valueFrom: 98 | secretKeyRef: 99 | name: okta-secret 100 | key: private_key 101 | - name: MCP_GATEWAY_OKTA_PRIVATE_KEY_ID 102 | valueFrom: 103 | secretKeyRef: 104 | name: okta-secret 105 | key: private_key_id 106 | - name: MCP_GATEWAY_OAUTH_ENABLED 107 | value: "false" 108 | - name: MCP_GATEWAY_BACKEND_ENGINE 109 | value: "postgres" 110 | - name: MCP_GATEWAY_BACKEND_URI 111 | value: "postgresql://postgres:will-change@mcp-gateway-postgresql:5432/mcp-gateway?sslmode=disable" 112 | - name: MCP_GATEWAY_BACKEND_USERNAME 113 | value: "postgres" 114 | - name: MCP_GATEWAY_BACKEND_PASSWORD 115 | valueFrom: 116 | secretKeyRef: 117 | name: mcp-gateway-postgresql-secret 118 | key: postgresql-password 119 | - name: MCP_GATEWAY_BACKEND_ENCRYPTION_KEY 120 | value: "0123456789abcdeffedcba9876543210cafebabefacefeeddeadbeef00112233" # Change this to a random key 121 | - name: MCP_GATEWAY_HTTP_ADMIN_API_KEY 122 | value: "admin" # Change this to a random string 123 | 124 | configuration: | 125 | # MCP Gateway configuration 126 | 127 | autoscaling: 128 | enabled: false 129 | minReplicas: 1 130 | maxReplicas: 100 131 | targetCPUUtilizationPercentage: 80 132 | # targetMemoryUtilizationPercentage: 80 133 | 134 | nodeSelector: {} 135 | 136 | tolerations: [] 137 | 138 | affinity: {} 139 | 140 | postgresql: 141 | enabled: true 142 | auth: 143 | database: mcp-gateway 144 | username: postgres 145 | # define a secret with values for "postgres-password", "password" (user Password) 146 | existingSecret: mcp-gateway-postgresql-secret 147 | secretKeys: 148 | adminPasswordKey: postgresql-password 149 | -------------------------------------------------------------------------------- /pkg/aescipher/aescipher.go: -------------------------------------------------------------------------------- 1 | // Package aescipher provides a minimal, opinionated wrapper around AES-GCM 2 | // to make authenticated encryption and decryption straightforward. 3 | package aescipher 4 | 5 | import ( 6 | "bytes" 7 | "crypto/aes" 8 | "crypto/cipher" 9 | "crypto/rand" 10 | "encoding/base64" 11 | "encoding/hex" 12 | "errors" 13 | "io" 14 | ) 15 | 16 | const ( 17 | // NonceSizeGCM is the recommended size for AES-GCM nonces. 18 | NonceSizeGCM = 12 19 | versionPrefix = "v1" // 2-byte marker identifying ciphertexts of this library 20 | ) 21 | 22 | // Cryptor defines the minimal interface for an authenticated symmetric cipher. 23 | type Cryptor interface { 24 | Encrypt(plaintext []byte) ([]byte, error) 25 | Decrypt(ciphertext []byte) ([]byte, error) 26 | 27 | IsEncryptedString(b64 string) bool 28 | EncryptString(plain string) (string, error) 29 | DecryptString(b64 string) (string, error) 30 | } 31 | 32 | type gcmCryptor struct { 33 | aead cipher.AEAD 34 | } 35 | 36 | // New returns a Cryptor backed by AES-GCM. 37 | // Key must be 16, 24, or 32 bytes long (hex-encoded). 38 | func New(key string) (Cryptor, error) { 39 | keyDecoded, err := hex.DecodeString(key) 40 | if err != nil { 41 | return nil, err 42 | } 43 | keyLen := len(keyDecoded) 44 | if keyLen != 16 && keyLen != 24 && keyLen != 32 { 45 | return nil, errors.New("aescipher: key length must be 16, 24, or 32 bytes") 46 | } 47 | 48 | block, err := aes.NewCipher(keyDecoded) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | aead, err := cipher.NewGCM(block) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return &gcmCryptor{aead: aead}, nil 59 | } 60 | 61 | // EncryptString encrypts a UTF-8 string and returns Base64. 62 | func (g *gcmCryptor) EncryptString(plain string) (string, error) { 63 | ct, err := g.Encrypt([]byte(plain)) 64 | if err != nil { 65 | return "", err 66 | } 67 | return base64.StdEncoding.EncodeToString(ct), nil 68 | } 69 | 70 | // DecryptString decrypts Base64 and returns UTF-8. 71 | func (g *gcmCryptor) DecryptString(b64 string) (string, error) { 72 | ct, err := base64.StdEncoding.DecodeString(b64) 73 | if err != nil { 74 | return "", err 75 | } 76 | pt, err := g.Decrypt(ct) 77 | if err != nil { 78 | return "", err 79 | } 80 | return string(pt), nil 81 | } 82 | 83 | // Encrypt encrypts plaintext. 84 | // Layout : "v1" | nonce (12) | ciphertext+tag (Seal output). 85 | func (g *gcmCryptor) Encrypt(plaintext []byte) ([]byte, error) { 86 | nonce := make([]byte, NonceSizeGCM) 87 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 88 | return nil, err 89 | } 90 | 91 | // Seal with AAD = versionPrefix. 92 | enc := g.aead.Seal(nil, nonce, plaintext, []byte(versionPrefix)) 93 | 94 | out := make([]byte, 0, len(versionPrefix)+len(nonce)+len(enc)) 95 | out = append(out, versionPrefix...) 96 | out = append(out, nonce...) 97 | out = append(out, enc...) 98 | return out, nil 99 | } 100 | 101 | // Decrypt decrypts data created by Encrypt. 102 | func (g *gcmCryptor) Decrypt(ciphertext []byte) ([]byte, error) { 103 | minLen := len(versionPrefix) + NonceSizeGCM + g.aead.Overhead() 104 | if len(ciphertext) < minLen { 105 | return nil, errors.New("aescipher: ciphertext too short") 106 | } 107 | if !bytes.HasPrefix(ciphertext, []byte(versionPrefix)) { 108 | return nil, errors.New("aescipher: invalid prefix") 109 | } 110 | 111 | offset := len(versionPrefix) 112 | nonce := ciphertext[offset : offset+NonceSizeGCM] 113 | enc := ciphertext[offset+NonceSizeGCM:] 114 | 115 | plaintext, err := g.aead.Open(nil, nonce, enc, []byte(versionPrefix)) 116 | if err != nil { 117 | return nil, err 118 | } 119 | return plaintext, nil 120 | } 121 | 122 | // IsEncryptedString returns true if the string is an encrypted string 123 | // produced by Encrypt (false positive probability ≈ 2⁻¹²⁸). 124 | func (g *gcmCryptor) IsEncryptedString(b64 string) bool { 125 | ct, err := base64.StdEncoding.DecodeString(b64) 126 | if err != nil { 127 | return false 128 | } 129 | 130 | minLen := len(versionPrefix) + NonceSizeGCM + g.aead.Overhead() 131 | if len(ct) < minLen || !bytes.HasPrefix(ct, []byte(versionPrefix)) { 132 | return false 133 | } 134 | 135 | offset := len(versionPrefix) 136 | nonce := ct[offset : offset+NonceSizeGCM] 137 | enc := ct[offset+NonceSizeGCM:] 138 | 139 | if _, err = g.aead.Open(nil, nonce, enc, []byte(versionPrefix)); err != nil { 140 | return false 141 | } 142 | return true 143 | } 144 | -------------------------------------------------------------------------------- /internal/auth/base.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/matthisholleville/mcp-gateway/internal/storage" 9 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 10 | "go.uber.org/zap" 11 | "golang.org/x/sync/errgroup" 12 | ) 13 | 14 | // BaseProvider is the base provider for the MCP Gateway 15 | type BaseProvider struct { 16 | logger logger.Logger 17 | storage storage.Interface 18 | } 19 | 20 | // VerifyPermissions verifies the permissions of a user for a tool 21 | func (b *BaseProvider) VerifyPermissions( 22 | ctx context.Context, 23 | objectType, proxy, objectName string, 24 | claims map[string]interface{}, 25 | ) bool { 26 | b.logger.Debug("Verifying permissions", 27 | zap.String("objectType", objectType), 28 | zap.String("proxy", proxy), 29 | zap.String("objectName", objectName), 30 | zap.Any("claims", claims)) 31 | roles := b.attributeToRoles(ctx, claims) 32 | 33 | if len(roles) == 0 { 34 | b.logger.Debug("No roles found for claims", zap.Any("claims", claims)) 35 | return false 36 | } 37 | 38 | b.logger.Debug("Found roles for claims", zap.Strings("roles", roles)) 39 | 40 | // Resolve all roles in parallel ‑ stored in a thread‑safe slice. 41 | type rolePerm struct { 42 | name string 43 | permissions []storage.PermissionConfig 44 | } 45 | var ( 46 | mu sync.Mutex 47 | list []rolePerm 48 | ) 49 | g, ctx := errgroup.WithContext(ctx) 50 | 51 | for _, roleName := range roles { 52 | g.Go(func() error { 53 | role, err := b.storage.GetRole(ctx, roleName) 54 | if err != nil { 55 | return fmt.Errorf("GetRole(%s): %w", roleName, err) 56 | } 57 | mu.Lock() 58 | list = append(list, rolePerm{roleName, role.Permissions}) 59 | mu.Unlock() 60 | return nil 61 | }) 62 | } 63 | 64 | if err := g.Wait(); err != nil { 65 | b.logger.Error("role fetch failed", zap.Error(err)) 66 | return false 67 | } 68 | 69 | // Check if the user has the permission for the object type, object name and proxy 70 | for _, r := range list { 71 | for _, p := range r.permissions { 72 | if b.match(string(p.ObjectType), objectType) && 73 | b.match(p.Proxy, proxy) && 74 | b.match(p.ObjectName, objectName) { 75 | b.logger.Debug("permission OK", zap.String("role", r.name)) 76 | return true 77 | } 78 | } 79 | } 80 | 81 | return false 82 | } 83 | 84 | // match handles the wildcard "*" 85 | func (b *BaseProvider) match(pattern, value string) bool { 86 | return pattern == "*" || pattern == value 87 | } 88 | 89 | // attributeToRoles converts the claims into attribute to roles 90 | func (b *BaseProvider) attributeToRoles( 91 | ctx context.Context, 92 | claims map[string]interface{}, 93 | ) []string { 94 | out := make(map[string]struct{}) // set 95 | 96 | for claim, raw := range claims { 97 | switch v := raw.(type) { 98 | case string: 99 | b.appendRoles(out, b.lookup(ctx, claim, v)) 100 | 101 | case bool: // true/false become "true"/"false" 102 | b.appendRoles(out, b.lookup(ctx, claim, fmt.Sprintf("%t", v))) 103 | 104 | case []string: 105 | for _, s := range v { 106 | b.appendRoles(out, b.lookup(ctx, claim, s)) 107 | } 108 | 109 | case []interface{}: 110 | for _, any := range v { 111 | b.appendRoles(out, b.lookup(ctx, claim, fmt.Sprint(any))) 112 | } 113 | 114 | default: 115 | b.logger.Debug("unsupported claim type", 116 | zap.String("claim", claim), 117 | zap.Any("value", raw)) 118 | } 119 | } 120 | 121 | roles := make([]string, 0, len(out)) 122 | for r := range out { 123 | roles = append(roles, r) 124 | } 125 | return roles 126 | } 127 | 128 | // TODO: Actually we query the DB so multiple times (1 call perm), we could cache the results and search in memory 129 | func (b *BaseProvider) lookup( 130 | ctx context.Context, 131 | claim, value string, 132 | ) []string { 133 | mapping, err := b.storage.GetAttributeToRoles(ctx, claim, value) 134 | b.logger.Debug("looking up attribute to roles", 135 | zap.String("claim", claim), 136 | zap.String("value", value), 137 | zap.Any("mapping", mapping), 138 | zap.Error(err)) 139 | if err != nil || len(mapping.Roles) == 0 { 140 | b.logger.Debug("GetAttributeToRoles failed", 141 | zap.String("claim", claim), 142 | zap.String("value", value), 143 | zap.Error(err)) 144 | return []string{} 145 | } 146 | return mapping.Roles 147 | } 148 | 149 | func (b *BaseProvider) appendRoles(dst map[string]struct{}, roles []string) { 150 | for _, r := range roles { 151 | dst[r] = struct{}{} 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /internal/cfg/cfg.go: -------------------------------------------------------------------------------- 1 | // Package cfg provides a configuration for the MCP Gateway. 2 | package cfg 3 | 4 | import ( 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type Config struct { 10 | HTTP *HTTPConfig 11 | Log *LogConfig 12 | OAuth *OAuthConfig 13 | Proxy *ProxyConfig 14 | AuthProvider *AuthProviderConfig 15 | BackendConfig *BackendConfig 16 | } 17 | 18 | type HTTPConfig struct { 19 | Addr string 20 | CORS *CORSConfig 21 | AdminAPIKey string 22 | } 23 | 24 | type LogConfig struct { 25 | // Format is the log format to use in the log output (e.g. 'text' or 'json') 26 | Format string 27 | 28 | // Level is the log level to use in the log output (e.g. 'none', 'debug', or 'info') 29 | Level string 30 | 31 | // Format of the timestamp in the log output (e.g. 'Unix'(default) or 'ISO8601') 32 | TimestampFormat string 33 | } 34 | 35 | type ProxyConfig struct { 36 | CacheTTL time.Duration 37 | Heartbeat *HeartbeatConfig 38 | } 39 | 40 | type HeartbeatConfig struct { 41 | Enabled bool 42 | Interval time.Duration 43 | } 44 | type CORSConfig struct { 45 | Enabled bool 46 | AllowedOrigins []string 47 | AllowedMethods []string 48 | AllowedHeaders []string 49 | AllowCredentials bool 50 | } 51 | 52 | type OAuthConfig struct { 53 | Enabled bool 54 | Resource string 55 | AuthorizationServers []string 56 | BearerMethodsSupported []string 57 | ScopesSupported []string 58 | } 59 | 60 | type AuthProviderConfig struct { 61 | Enabled bool 62 | Name string 63 | Firebase *FirebaseConfig 64 | Okta *OktaConfig 65 | } 66 | 67 | type FirebaseConfig struct { 68 | ProjectID string 69 | } 70 | 71 | type OktaConfig struct { 72 | Issuer string 73 | OrgURL string 74 | ClientID string 75 | PrivateKey string `json:"-"` // private field, won't be logged 76 | PrivateKeyID string `json:"-"` // private field, won't be logged 77 | } 78 | 79 | type BackendConfig struct { 80 | // Engine is the auth backend engine to use (e.g. 'memory', 'postgres') 81 | Engine string 82 | URI string `json:"-"` // private field, won't be logged 83 | 84 | Username string 85 | Password string `json:"-"` // private field, won't be logged 86 | 87 | // MaxOpenConns is the maximum number of open connections to the database. 88 | MaxOpenConns int 89 | 90 | // MaxIdleConns is the maximum number of connections to the datastore in the idle connection 91 | // pool. 92 | MaxIdleConns int 93 | 94 | // ConnMaxIdleTime is the maximum amount of time a connection to the datastore may be idle. 95 | ConnMaxIdleTime time.Duration 96 | 97 | // ConnMaxLifetime is the maximum amount of time a connection to the datastore may be reused. 98 | ConnMaxLifetime time.Duration 99 | 100 | // EncryptionKey is the key used to encrypt and decrypt data. 101 | EncryptionKey string `json:"-"` // private field, won't be logged 102 | } 103 | 104 | func DefaultConfig() *Config { 105 | return &Config{ 106 | HTTP: &HTTPConfig{ 107 | Addr: ":8082", 108 | CORS: &CORSConfig{ 109 | Enabled: true, 110 | AllowedOrigins: []string{"*"}, 111 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, 112 | AllowedHeaders: []string{"Content-Type", "Authorization"}, 113 | AllowCredentials: true, 114 | }, 115 | AdminAPIKey: "change-me", 116 | }, 117 | Log: &LogConfig{ 118 | Format: "text", 119 | Level: "info", 120 | }, 121 | Proxy: &ProxyConfig{ 122 | CacheTTL: 10 * time.Second, 123 | Heartbeat: &HeartbeatConfig{ 124 | Enabled: true, 125 | Interval: 10 * time.Second, 126 | }, 127 | }, 128 | OAuth: &OAuthConfig{ 129 | Enabled: false, 130 | }, 131 | AuthProvider: &AuthProviderConfig{ 132 | Enabled: false, 133 | Name: "", 134 | Firebase: &FirebaseConfig{ 135 | ProjectID: "change-me", 136 | }, 137 | Okta: &OktaConfig{ 138 | Issuer: "", 139 | OrgURL: "", 140 | }, 141 | }, 142 | BackendConfig: &BackendConfig{ 143 | Engine: "memory", 144 | MaxOpenConns: 30, 145 | MaxIdleConns: 10, 146 | }, 147 | } 148 | } 149 | 150 | func (cfg *Config) Verify() error { 151 | if cfg.Proxy.CacheTTL <= 5*time.Second { 152 | return fmt.Errorf("proxy cache TTL must be greater than 5 seconds") 153 | } 154 | 155 | if cfg.Proxy.Heartbeat.Interval <= 5*time.Second { 156 | return fmt.Errorf("proxy heartbeat interval must be greater than 5 seconds") 157 | } 158 | 159 | if cfg.BackendConfig.EncryptionKey == "" && cfg.BackendConfig.Engine != "memory" { 160 | return fmt.Errorf("encryption key is required") 161 | } 162 | 163 | return nil 164 | } 165 | -------------------------------------------------------------------------------- /charts/mcp-gateway/README.md: -------------------------------------------------------------------------------- 1 | # mcp-gateway 2 | 3 | Simple Helm chart to deploy MCP Gateway on Kubernetes. 4 | 5 | ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) 6 | 7 | Simple Helm chart to deploy MCP Gateway on Kubernetes. 8 | 9 | ## Quick Installation 10 | 11 | ```bash 12 | # Create namespace 13 | kubectl create namespace mcp-gateway 14 | 15 | # Create secrets for authentication 16 | kubectl create secret generic okta-secret \ 17 | --from-literal=issuer="https://your-okta-domain.okta.com/oauth2/default" \ 18 | --from-literal=org_url="https://your-okta-domain.okta.com" \ 19 | --from-literal=client_id="your-client-id" \ 20 | --from-literal=private_key="your-private-key" \ 21 | --from-literal=private_key_id="your-key-id" \ 22 | -n mcp-gateway 23 | 24 | # Create secret for MCP server (example: n8n) 25 | kubectl create secret generic n8n-secret \ 26 | --from-literal=proxy_key="your-n8n-api-key" \ 27 | -n mcp-gateway 28 | 29 | # Install chart 30 | helm install mcp-gateway . --namespace mcp-gateway 31 | 32 | # Verify deployment 33 | kubectl get pods -n mcp-gateway 34 | ``` 35 | 36 | ## Configuration 37 | 38 | Check `values.yaml` for all available configuration options. Customize environment variables and secrets according to your setup. 39 | 40 | ## Uninstall 41 | 42 | ```bash 43 | helm uninstall mcp-gateway -n mcp-gateway 44 | kubectl delete namespace mcp-gateway 45 | ``` 46 | 47 | ## Requirements 48 | 49 | | Repository | Name | Version | 50 | |------------|------|---------| 51 | | https://charts.bitnami.com/bitnami | postgresql | 15.5.38 | 52 | 53 | ## Values 54 | 55 | | Key | Type | Default | Description | 56 | |-----|------|---------|-------------| 57 | | affinity | object | `{}` | | 58 | | autoscaling.enabled | bool | `false` | | 59 | | autoscaling.maxReplicas | int | `100` | | 60 | | autoscaling.minReplicas | int | `1` | | 61 | | autoscaling.targetCPUUtilizationPercentage | int | `80` | | 62 | | configuration | string | `"# MCP Gateway configuration\n"` | | 63 | | containerPort | int | `8082` | | 64 | | extraEnv[0].name | string | `"MCP_GATEWAY_BACKEND_ENGINE"` | | 65 | | extraEnv[0].value | string | `"postgres"` | | 66 | | extraEnv[1].name | string | `"MCP_GATEWAY_BACKEND_URI"` | | 67 | | extraEnv[1].value | string | `"postgresql://postgres:will-change@mcp-gateway-postgresql:5432/mcp-gateway?sslmode=disable"` | | 68 | | extraEnv[2].name | string | `"MCP_GATEWAY_BACKEND_PASSWORD"` | | 69 | | extraEnv[2].valueFrom.secretKeyRef.key | string | `"postgresql-password"` | | 70 | | extraEnv[2].valueFrom.secretKeyRef.name | string | `"mcp-gateway-postgresql-secret"` | | 71 | | extraEnv[3].name | string | `"MCP_GATEWAY_BACKEND_ENCRYPTION_KEY"` | | 72 | | extraEnv[3].value | string | `"0123456789abcdeffedcba9876543210cafebabefacefeeddeadbeef00112233"` | | 73 | | extraEnv[4].name | string | `"MCP_GATEWAY_HTTP_ADMIN_API_KEY"` | | 74 | | extraEnv[4].value | string | `"admin"` | | 75 | | fullnameOverride | string | `""` | | 76 | | image.pullPolicy | string | `"IfNotPresent"` | | 77 | | image.repository | string | `"ghcr.io/matthisholleville/mcp-gateway-dev"` | | 78 | | image.tag | string | `"dev-f810708eb3c412858fef136fb8668078457adf92"` | | 79 | | imagePullSecrets | list | `[]` | | 80 | | ingress.annotations | object | `{}` | | 81 | | ingress.className | string | `""` | | 82 | | ingress.enabled | bool | `false` | | 83 | | ingress.hosts[0].host | string | `"chart-example.local"` | | 84 | | ingress.hosts[0].paths[0].path | string | `"/"` | | 85 | | ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | | 86 | | ingress.tls | list | `[]` | | 87 | | migrate.enabled | bool | `true` | | 88 | | nameOverride | string | `""` | | 89 | | nodeSelector | object | `{}` | | 90 | | podAnnotations."prometheus.io/port" | string | `"8082"` | | 91 | | podAnnotations."prometheus.io/scrape" | string | `"true"` | | 92 | | podSecurityContext | object | `{}` | | 93 | | postgresql.auth.database | string | `"mcp-gateway"` | | 94 | | postgresql.auth.existingSecret | string | `"mcp-gateway-postgresql-secret"` | | 95 | | postgresql.auth.secretKeys.adminPasswordKey | string | `"postgresql-password"` | | 96 | | postgresql.auth.username | string | `"postgres"` | | 97 | | postgresql.enabled | bool | `true` | | 98 | | replicaCount | int | `1` | | 99 | | resources.limits.memory | string | `"1Gi"` | | 100 | | resources.requests.cpu | string | `"100m"` | | 101 | | resources.requests.memory | string | `"512Mi"` | | 102 | | securityContext | object | `{}` | | 103 | | service.port | int | `80` | | 104 | | service.type | string | `"ClusterIP"` | | 105 | | serviceAccount.annotations | object | `{}` | | 106 | | serviceAccount.create | bool | `true` | | 107 | | serviceAccount.name | string | `""` | | 108 | | tolerations | list | `[]` | | 109 | 110 | ---------------------------------------------- 111 | Autogenerated from chart metadata using [helm-docs v1.13.1](https://github.com/norwoodj/helm-docs/releases/v1.13.1) -------------------------------------------------------------------------------- /internal/storage/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | // Package migrate provides a migration engine for the MCP Gateway. 2 | package migrate 3 | 4 | import ( 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/golang-migrate/migrate/v4" 10 | "github.com/golang-migrate/migrate/v4/database/postgres" 11 | _ "github.com/golang-migrate/migrate/v4/source/file" // import file source 12 | _ "github.com/lib/pq" // import postgres driver 13 | "github.com/matthisholleville/mcp-gateway/internal/storage/utils" 14 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // MigrationConfig bundles every parameter needed to run a migration session. 19 | type MigrationConfig struct { 20 | Engine string // "memory", "postgres", ... 21 | URI string // connection string for the target database 22 | Username string // username for the target database 23 | Password string // password for the target database 24 | Logger logger.Logger // structured logger implementation 25 | Timeout time.Duration // advisory lock timeout 26 | Verbose bool // enable verbose output on migrate CLI 27 | Version int // target version (0 means "latest") 28 | Drop bool // drop all objects before migrating 29 | MigrationDir string // filesystem path that contains *.sql files 30 | } 31 | 32 | // RunMigrations orchestrates the migration workflow according to cfg. 33 | func RunMigrations(cfg *MigrationConfig) error { 34 | m, err := newMigrator(cfg) 35 | if err != nil { 36 | return err 37 | } 38 | // m == nil means the selected engine does not require migrations (e.g. memory). 39 | if m == nil { 40 | return nil 41 | } 42 | defer m.Close() //nolint:errcheck // nothing interesting to do with the error 43 | 44 | switch { 45 | case cfg.Drop: 46 | return applyDrop(m, cfg.Logger) 47 | 48 | case cfg.Version == 0: 49 | // No explicit version: migrate to the most recent. 50 | return applyUp(m, cfg.Logger) 51 | 52 | default: 53 | // A specific target version was requested. 54 | return applyVersion(m, cfg.Version, cfg.Logger) 55 | } 56 | } 57 | 58 | // newMigrator returns a ready‑to‑use migrate.Migrate instance or nil for 59 | // engines that do not require migrations. All instance‑level settings such 60 | // as the logger, lock timeout and prefetch size are configured here. 61 | func newMigrator(cfg *MigrationConfig) (*migrate.Migrate, error) { 62 | switch cfg.Engine { 63 | case "memory": 64 | cfg.Logger.Debug("no migrations to run for memory engine") 65 | return nil, nil 66 | 67 | case "postgres": 68 | if cfg.MigrationDir == "" { 69 | cfg.MigrationDir = "assets/migrations/postgres" 70 | } 71 | 72 | uri, err := utils.GetURI(cfg.Username, cfg.Password, cfg.URI) 73 | if err != nil { 74 | return nil, fmt.Errorf("get uri: %w", err) 75 | } 76 | 77 | db, err := sql.Open("postgres", uri) 78 | if err != nil { 79 | return nil, fmt.Errorf("open database: %w", err) 80 | } 81 | 82 | driver, err := postgres.WithInstance(db, &postgres.Config{ 83 | MigrationsTable: "migrations", 84 | SchemaName: "public", 85 | }) 86 | if err != nil { 87 | return nil, fmt.Errorf("create driver: %w", err) 88 | } 89 | 90 | m, err := migrate.NewWithDatabaseInstance( 91 | "file://"+cfg.MigrationDir, 92 | "postgres", 93 | driver, 94 | ) 95 | if err != nil { 96 | return nil, fmt.Errorf("create migrator: %w", err) 97 | } 98 | 99 | m.Log = cfg.Logger 100 | m.LockTimeout = cfg.Timeout 101 | m.PrefetchMigrations = 100 102 | return m, nil 103 | 104 | default: 105 | return nil, fmt.Errorf("unsupported engine %q", cfg.Engine) 106 | } 107 | } 108 | 109 | // applyDrop drops every migration then drops the schema itself. 110 | // It is destructive and should only be used in development / CI. 111 | func applyDrop(m *migrate.Migrate, log logger.Logger) error { 112 | log.Info("dropping all migrations") 113 | 114 | if err := m.Down(); err != nil && err != migrate.ErrNoChange { 115 | return fmt.Errorf("down: %w", err) 116 | } 117 | return m.Drop() 118 | } 119 | 120 | // applyUp migrates the database to the latest available version. 121 | func applyUp(m *migrate.Migrate, log logger.Logger) error { 122 | log.Info("running all migrations (up)") 123 | 124 | if err := m.Up(); err != nil && err != migrate.ErrNoChange { 125 | return fmt.Errorf("up: %w", err) 126 | } 127 | log.Info("migrations completed") 128 | return nil 129 | } 130 | 131 | // applyVersion migrates up or down until the requested target version is reached. 132 | func applyVersion(m *migrate.Migrate, target int, log logger.Logger) error { 133 | current, dirty, err := m.Version() 134 | if err != nil && err != migrate.ErrNilVersion { 135 | return fmt.Errorf("current version: %w", err) 136 | } 137 | 138 | if dirty { 139 | // For safety, we refuse to move a dirty database automatically. 140 | return fmt.Errorf("database is dirty at version %d, manual intervention required", current) 141 | } 142 | 143 | log.Info("migrating to version", zap.Int("target", target)) 144 | 145 | targetUint := uint(target) //nolint:gosec // G115: migration versions are always small integers 146 | if err := m.Migrate(targetUint); err != nil && err != migrate.ErrNoChange { 147 | return fmt.Errorf("migrate to %d: %w", target, err) 148 | } 149 | 150 | log.Info("migrations completed") 151 | return nil 152 | } 153 | -------------------------------------------------------------------------------- /cmd/serve/flags.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "github.com/matthisholleville/mcp-gateway/cmd/util" 5 | "github.com/spf13/cobra" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | // bindServeFlagsFunc binds the serve flags to the command. 10 | func bindServeFlagsFunc(flags *pflag.FlagSet) func(*cobra.Command, []string) { 11 | return func(cmd *cobra.Command, _ []string) { 12 | util.MustBindPFlag("http-addr", flags.Lookup("http-addr")) 13 | util.MustBindEnv("http-addr", "MCP_GATEWAY_HTTP_ADDR") 14 | 15 | util.MustBindPFlag("log.format", flags.Lookup("log-format")) 16 | util.MustBindEnv("log.format", "MCP_GATEWAY_LOG_FORMAT") 17 | 18 | util.MustBindPFlag("log.level", flags.Lookup("log-level")) 19 | util.MustBindEnv("log.level", "MCP_GATEWAY_LOG_LEVEL") 20 | 21 | util.MustBindPFlag("log.timestamp-format", flags.Lookup("log-timestamp-format")) 22 | util.MustBindEnv("log.timestamp-format", "MCP_GATEWAY_LOG_TIMESTAMP_FORMAT") 23 | 24 | util.MustBindPFlag("proxy.cache-ttl", flags.Lookup("proxy-cache-ttl")) 25 | util.MustBindEnv("proxy.cache-ttl", "MCP_GATEWAY_PROXY_CACHE_TTL") 26 | 27 | util.MustBindPFlag("proxy.heartbeat.interval", flags.Lookup("proxy-heartbeat-interval")) 28 | util.MustBindEnv("proxy.heartbeat.interval", "MCP_GATEWAY_PROXY_HEARTBEAT_INTERVAL") 29 | 30 | util.MustBindPFlag("oauth.enabled", flags.Lookup("oauth-enabled")) 31 | util.MustBindEnv("oauth.enabled", "MCP_GATEWAY_OAUTH_ENABLED") 32 | 33 | util.MustBindPFlag("oauth.authorizationServers", flags.Lookup("oauth-authorization-servers")) 34 | util.MustBindEnv("oauth.authorizationServers", "MCP_GATEWAY_OAUTH_AUTHORIZATION_SERVERS") 35 | 36 | util.MustBindPFlag("oauth.resource", flags.Lookup("oauth-resource")) 37 | util.MustBindEnv("oauth.resource", "MCP_GATEWAY_OAUTH_RESOURCE") 38 | 39 | util.MustBindPFlag("oauth.bearerMethodsSupported", flags.Lookup("oauth-bearer-methods-supported")) 40 | util.MustBindEnv("oauthConfig.bearerMethodsSupported", "MCP_GATEWAY_OAUTH_BEARER_METHODS_SUPPORTED") 41 | 42 | util.MustBindPFlag("oauth.scopesSupported", flags.Lookup("oauth-scopes-supported")) 43 | util.MustBindEnv("oauth.scopesSupported", "MCP_GATEWAY_OAUTH_SCOPES_SUPPORTED") 44 | 45 | util.MustBindPFlag("authProvider.enabled", flags.Lookup("auth-provider-enabled")) 46 | util.MustBindEnv("authProvider.enabled", "MCP_GATEWAY_AUTH_PROVIDER_ENABLED") 47 | 48 | cmd.MarkFlagsRequiredTogether("auth-provider-enabled", "auth-provider-name", "oauth-enabled", "oauth-authorization-servers", "oauth-bearer-methods-supported", "oauth-scopes-supported", "oauth-resource") 49 | 50 | util.MustBindPFlag("authProvider.name", flags.Lookup("auth-provider-name")) 51 | util.MustBindEnv("authProvider.name", "MCP_GATEWAY_AUTH_PROVIDER_NAME") 52 | 53 | util.MustBindPFlag("backendConfig.engine", flags.Lookup("backend-engine")) 54 | util.MustBindEnv("backendConfig.engine", "MCP_GATEWAY_BACKEND_ENGINE") 55 | 56 | util.MustBindPFlag("backendConfig.uri", flags.Lookup("backend-uri")) 57 | util.MustBindEnv("backendConfig.uri", "MCP_GATEWAY_BACKEND_URI") 58 | 59 | util.MustBindPFlag("backendConfig.username", flags.Lookup("backend-username")) 60 | util.MustBindEnv("backendConfig.username", "MCP_GATEWAY_BACKEND_USERNAME") 61 | 62 | util.MustBindPFlag("backendConfig.password", flags.Lookup("backend-password")) 63 | util.MustBindEnv("backendConfig.password", "MCP_GATEWAY_BACKEND_PASSWORD") 64 | 65 | util.MustBindPFlag("backendConfig.maxOpenConns", flags.Lookup("backend-max-open-conns")) 66 | util.MustBindEnv("backendConfig.maxOpenConns", "MCP_GATEWAY_BACKEND_MAX_OPEN_CONNS") 67 | 68 | util.MustBindPFlag("backendConfig.maxIdleConns", flags.Lookup("backend-max-idle-conns")) 69 | util.MustBindEnv("backendConfig.maxIdleConns", "MCP_GATEWAY_BACKEND_MAX_IDLE_CONNS") 70 | 71 | util.MustBindPFlag("backendConfig.connMaxIdleTime", flags.Lookup("backend-conn-max-idle-time")) 72 | util.MustBindEnv("backendConfig.connMaxIdleTime", "MCP_GATEWAY_BACKEND_CONN_MAX_IDLE_TIME") 73 | 74 | util.MustBindPFlag("backendConfig.connMaxLifetime", flags.Lookup("backend-conn-max-lifetime")) 75 | util.MustBindEnv("backendConfig.connMaxLifetime", "MCP_GATEWAY_BACKEND_CONN_MAX_LIFETIME") 76 | 77 | util.MustBindPFlag("backendConfig.encryptionKey", flags.Lookup("backend-encryption-key")) 78 | util.MustBindEnv("backendConfig.encryptionKey", "MCP_GATEWAY_BACKEND_ENCRYPTION_KEY") 79 | 80 | util.MustBindPFlag("authProvider.okta.issuer", flags.Lookup("okta-issuer")) 81 | util.MustBindEnv("authProvider.okta.issuer", "MCP_GATEWAY_OKTA_ISSUER") 82 | 83 | util.MustBindPFlag("authProvider.okta.orgUrl", flags.Lookup("okta-org-url")) 84 | util.MustBindEnv("authProvider.okta.orgUrl", "MCP_GATEWAY_OKTA_ORG_URL") 85 | 86 | util.MustBindPFlag("authProvider.okta.clientId", flags.Lookup("okta-client-id")) 87 | util.MustBindEnv("authProvider.okta.clientId", "MCP_GATEWAY_OKTA_CLIENT_ID") 88 | 89 | util.MustBindPFlag("authProvider.okta.privateKey", flags.Lookup("okta-private-key")) 90 | util.MustBindEnv("authProvider.okta.privateKey", "MCP_GATEWAY_OKTA_PRIVATE_KEY") 91 | 92 | util.MustBindPFlag("authProvider.okta.privateKeyId", flags.Lookup("okta-private-key-id")) 93 | util.MustBindEnv("authProvider.okta.privateKeyId", "MCP_GATEWAY_OKTA_PRIVATE_KEY_ID") 94 | 95 | cmd.MarkFlagsRequiredTogether("okta-private-key", "okta-private-key-id", "okta-client-id", "okta-org-url", "okta-issuer") 96 | 97 | util.MustBindPFlag("http.adminApiKey", flags.Lookup("http-admin-api-key")) 98 | util.MustBindEnv("http.adminApiKey", "MCP_GATEWAY_HTTP_ADMIN_API_KEY") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /cmd/serve/serve.go: -------------------------------------------------------------------------------- 1 | // Package serve provides a command to run the MCP Gateway server. 2 | package serve 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/matthisholleville/mcp-gateway/internal/cfg" 8 | "github.com/matthisholleville/mcp-gateway/internal/server" 9 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | // NewRunCommand creates a new run command. 15 | func NewRunCommand() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "serve", 18 | Short: "Run the MCP Gateway server", 19 | Long: "Run the MCP Gateway server.", 20 | Run: run, 21 | Args: cobra.NoArgs, 22 | } 23 | 24 | defaultConfig := cfg.DefaultConfig() 25 | flags := cmd.Flags() 26 | 27 | flags.String("http-addr", defaultConfig.HTTP.Addr, "The address to listen on for HTTP requests") 28 | 29 | flags.String("log-format", defaultConfig.Log.Format, "The format to use for logging") 30 | 31 | flags.String("log-level", defaultConfig.Log.Level, "The level to use for logging") 32 | 33 | flags.String("log-timestamp-format", defaultConfig.Log.TimestampFormat, "The format to use for logging timestamps") 34 | 35 | flags.Duration("proxy-cache-ttl", defaultConfig.Proxy.CacheTTL, "The TTL for the proxy cache") 36 | 37 | flags.Duration("proxy-heartbeat-interval", defaultConfig.Proxy.Heartbeat.Interval, "The interval for the proxy heartbeat") 38 | 39 | flags.Bool("oauth-enabled", defaultConfig.OAuth.Enabled, "Whether to enable OAuth") 40 | 41 | flags.StringSlice("oauth-authorization-servers", defaultConfig.OAuth.AuthorizationServers, "The authorization servers for OAuth") 42 | 43 | flags.String("oauth-resource", defaultConfig.OAuth.Resource, "The resource for OAuth") 44 | 45 | flags.StringSlice("oauth-bearer-methods-supported", defaultConfig.OAuth.BearerMethodsSupported, "The bearer methods supported for OAuth") 46 | 47 | flags.StringSlice("oauth-scopes-supported", defaultConfig.OAuth.ScopesSupported, "The scopes supported for OAuth") 48 | 49 | flags.Bool("auth-provider-enabled", defaultConfig.AuthProvider.Enabled, "Whether to enable the auth provider") 50 | 51 | flags.String("auth-provider-name", defaultConfig.AuthProvider.Name, "The name of the auth provider") 52 | 53 | flags.String("backend-engine", defaultConfig.BackendConfig.Engine, "The engine to use for the auth backend") 54 | 55 | flags.String("backend-uri", defaultConfig.BackendConfig.URI, "The URI to use for the auth backend") 56 | 57 | flags.String("backend-username", defaultConfig.BackendConfig.Username, "The username to use for the auth backend. It will override the username in the URI if provided.") 58 | 59 | flags.String("backend-password", defaultConfig.BackendConfig.Password, "The password to use for the auth backend. It will override the password in the URI if provided.") 60 | 61 | flags.Int("backend-max-open-conns", defaultConfig.BackendConfig.MaxOpenConns, "The maximum number of open connections to the database") 62 | 63 | flags.Int("backend-max-idle-conns", defaultConfig.BackendConfig.MaxIdleConns, "The maximum number of connections to the datastore in the idle connection pool") 64 | 65 | flags.Duration("backend-conn-max-idle-time", defaultConfig.BackendConfig.ConnMaxIdleTime, "The maximum amount of time a connection to the datastore may be idle") 66 | 67 | flags.Duration("backend-conn-max-lifetime", defaultConfig.BackendConfig.ConnMaxLifetime, "The maximum amount of time a connection to the datastore may be reused") 68 | 69 | flags.String("backend-encryption-key", defaultConfig.BackendConfig.EncryptionKey, "The key used to encrypt and decrypt data") 70 | 71 | flags.String("okta-issuer", defaultConfig.AuthProvider.Okta.Issuer, "The issuer for the Okta auth provider") 72 | 73 | flags.String("okta-org-url", defaultConfig.AuthProvider.Okta.OrgURL, "The org URL for the Okta auth provider") 74 | 75 | flags.String("okta-client-id", defaultConfig.AuthProvider.Okta.ClientID, "The client ID for the Okta auth provider") 76 | 77 | flags.String("okta-private-key", defaultConfig.AuthProvider.Okta.PrivateKey, "The private key for the Okta auth provider") 78 | 79 | flags.String("okta-private-key-id", defaultConfig.AuthProvider.Okta.PrivateKeyID, "The private key ID for the Okta auth provider") 80 | 81 | flags.String("http-admin-api-key", defaultConfig.HTTP.AdminAPIKey, "The admin API key for the HTTP server. Using to configure the MCP Gateway API.") 82 | 83 | cmd.PreRun = bindServeFlagsFunc(flags) 84 | 85 | return cmd 86 | } 87 | 88 | // ReadConfig reads the config from the file. 89 | func ReadConfig() (*cfg.Config, error) { 90 | config := cfg.DefaultConfig() 91 | 92 | viper.SetTypeByDefaultValue(true) 93 | err := viper.ReadInConfig() 94 | if err != nil { 95 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 96 | return nil, fmt.Errorf("failed to load server config: %w", err) 97 | } 98 | } 99 | 100 | if err := viper.Unmarshal(config); err != nil { 101 | return nil, fmt.Errorf("failed to unmarshal server config: %w", err) 102 | } 103 | 104 | return config, nil 105 | } 106 | 107 | func run(_ *cobra.Command, _ []string) { 108 | config, err := ReadConfig() 109 | if err != nil { 110 | panic(err) 111 | } 112 | 113 | if err := config.Verify(); err != nil { 114 | panic(err) 115 | } 116 | log := logger.MustNewLogger(config.Log.Format, config.Log.Level, config.Log.TimestampFormat) 117 | serverClient, err := server.NewServer(log, config) 118 | if err != nil { 119 | panic(err) 120 | } 121 | err = serverClient.ListenAndServe() 122 | if err != nil { 123 | panic(err) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /internal/storage/memory.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type MemoryStorage struct { 9 | BaseStorage 10 | proxies map[string]ProxyConfig 11 | roles map[string]RoleConfig 12 | attributeToRoles map[string]AttributeToRolesConfig 13 | } 14 | 15 | func NewMemoryStorage(defaultScope string) *MemoryStorage { 16 | return &MemoryStorage{ 17 | BaseStorage: BaseStorage{ 18 | defaultScope: defaultScope, 19 | }, 20 | proxies: make(map[string]ProxyConfig), 21 | roles: make(map[string]RoleConfig), 22 | attributeToRoles: make(map[string]AttributeToRolesConfig), 23 | } 24 | } 25 | 26 | // GetProxy gets a proxy from the memory storage. 27 | func (s *MemoryStorage) GetProxy(_ context.Context, proxy string, _ bool) (ProxyConfig, error) { 28 | proxyConfig, ok := s.proxies[proxy] 29 | if !ok { 30 | return ProxyConfig{}, fmt.Errorf("proxy not found") 31 | } 32 | return proxyConfig, nil 33 | } 34 | 35 | // SetProxy sets a proxy in the memory storage. 36 | func (s *MemoryStorage) SetProxy(_ context.Context, proxy *ProxyConfig, _ bool) error { 37 | if !proxy.Type.IsValid() { 38 | return fmt.Errorf("invalid proxy type: %s", proxy.Type) 39 | } 40 | if !proxy.AuthType.IsValid() { 41 | return fmt.Errorf("invalid proxy auth type: %s", proxy.AuthType) 42 | } 43 | 44 | s.proxies[proxy.Name] = *proxy 45 | return nil 46 | } 47 | 48 | // DeleteProxy deletes a proxy from the memory storage. 49 | func (s *MemoryStorage) DeleteProxy(_ context.Context, proxy string) error { 50 | delete(s.proxies, proxy) 51 | return nil 52 | } 53 | 54 | // ListProxies lists all proxies from the memory storage. 55 | func (s *MemoryStorage) ListProxies(_ context.Context, _ bool) ([]ProxyConfig, error) { 56 | proxies := make([]ProxyConfig, 0, len(s.proxies)) 57 | for _, proxy := range s.proxies { 58 | proxies = append(proxies, proxy) 59 | } 60 | return proxies, nil 61 | } 62 | 63 | // SetRole sets a role in the memory storage. 64 | func (s *MemoryStorage) SetRole(_ context.Context, role RoleConfig) error { 65 | for _, permission := range role.Permissions { 66 | if !permission.ObjectType.IsValid() { 67 | return fmt.Errorf("invalid object type: %s", permission.ObjectType) 68 | } 69 | } 70 | 71 | _, ok := s.roles[role.Name] 72 | if ok { 73 | return fmt.Errorf("role already exists") 74 | } 75 | 76 | for _, permission := range role.Permissions { 77 | if permission.Proxy == "*" { 78 | continue 79 | } 80 | _, ok := s.proxies[permission.Proxy] 81 | if !ok { 82 | return fmt.Errorf("proxy %s not found", permission.Proxy) 83 | } 84 | if permission.ObjectType != ObjectTypeAll && permission.ObjectType != ObjectTypeTools { 85 | return fmt.Errorf("invalid object type") 86 | } 87 | } 88 | 89 | s.roles[role.Name] = role 90 | return nil 91 | } 92 | 93 | // GetRole gets a role from the memory storage. 94 | func (s *MemoryStorage) GetRole(_ context.Context, role string) (RoleConfig, error) { 95 | roleConfig, ok := s.roles[role] 96 | if !ok { 97 | return RoleConfig{}, fmt.Errorf("role not found") 98 | } 99 | return roleConfig, nil 100 | } 101 | 102 | // DeleteRole deletes a role from the memory storage. 103 | func (s *MemoryStorage) DeleteRole(_ context.Context, role string) error { 104 | delete(s.roles, role) 105 | return nil 106 | } 107 | 108 | // ListRoles lists all roles from the memory storage. 109 | func (s *MemoryStorage) ListRoles(_ context.Context) ([]RoleConfig, error) { 110 | roles := make([]RoleConfig, 0, len(s.roles)) 111 | for _, role := range s.roles { 112 | roles = append(roles, role) 113 | } 114 | return roles, nil 115 | } 116 | 117 | // SetAttributeToRoles sets an attribute to roles in the memory storage. 118 | func (s *MemoryStorage) SetAttributeToRoles(_ context.Context, attributeToRoles AttributeToRolesConfig) error { 119 | _, ok := s.attributeToRoles[fmt.Sprintf("%s:%s", attributeToRoles.AttributeKey, attributeToRoles.AttributeValue)] 120 | if ok { 121 | return fmt.Errorf("attribute to roles already exists") 122 | } 123 | 124 | for _, role := range attributeToRoles.Roles { 125 | _, ok := s.roles[role] 126 | if !ok { 127 | return fmt.Errorf("role not found") 128 | } 129 | } 130 | s.attributeToRoles[fmt.Sprintf("%s:%s", attributeToRoles.AttributeKey, attributeToRoles.AttributeValue)] = attributeToRoles 131 | return nil 132 | } 133 | 134 | // DeleteAttributeToRoles deletes an attribute to roles from the memory storage. 135 | func (s *MemoryStorage) DeleteAttributeToRoles(_ context.Context, attributeKey, attributeValue string) error { 136 | delete(s.attributeToRoles, fmt.Sprintf("%s:%s", attributeKey, attributeValue)) 137 | return nil 138 | } 139 | 140 | // ListAttributeToRoles lists all attribute to roles from the memory storage. 141 | func (s *MemoryStorage) ListAttributeToRoles(_ context.Context) ([]AttributeToRolesConfig, error) { 142 | attributeToRoles := make([]AttributeToRolesConfig, 0, len(s.attributeToRoles)) 143 | for _, attributeToRole := range s.attributeToRoles { 144 | attributeToRoles = append(attributeToRoles, attributeToRole) 145 | } 146 | return attributeToRoles, nil 147 | } 148 | 149 | // GetAttributeToRoles gets an attribute to roles from the memory storage. 150 | func (s *MemoryStorage) GetAttributeToRoles(_ context.Context, attributeKey, attributeValue string) (AttributeToRolesConfig, error) { 151 | attributeToRoles, ok := s.attributeToRoles[fmt.Sprintf("%s:%s", attributeKey, attributeValue)] 152 | if !ok { 153 | return AttributeToRolesConfig{}, fmt.Errorf("attribute to roles not found") 154 | } 155 | return attributeToRoles, nil 156 | } 157 | -------------------------------------------------------------------------------- /internal/auth/okta_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | // import ( 4 | // "log/slog" 5 | // "os" 6 | // "testing" 7 | 8 | // "github.com/matthisholleville/mcp-gateway/internal/cfg" 9 | // "github.com/stretchr/testify/assert" 10 | // ) 11 | 12 | // // TestOktaProvider_VerifyClaims tests the verifyClaims method of the OktaProvider 13 | // func TestOktaProvider_VerifyClaims(t *testing.T) { 14 | // for _, test := range []struct { 15 | // name string 16 | // cfgClaims []string 17 | // claims map[string]interface{} 18 | // expected map[string]interface{} 19 | // expectErr bool 20 | // }{ 21 | // { 22 | // name: "valid claims", 23 | // cfgClaims: []string{"groups"}, 24 | // claims: map[string]interface{}{ 25 | // "groups": []string{"Base"}, 26 | // }, 27 | // expected: map[string]interface{}{"groups": []string{"Base"}}, 28 | // expectErr: false, 29 | // }, 30 | // { 31 | // name: "missing claims", 32 | // cfgClaims: []string{"groups", "missing"}, 33 | // claims: map[string]interface{}{ 34 | // "groups": []string{"Base"}, 35 | // }, 36 | // expected: nil, 37 | // expectErr: true, 38 | // }, 39 | // } { 40 | // t.Run(test.name, func(t *testing.T) { 41 | // provider := &OktaProvider{ 42 | // cfg: &cfg.Okta{ 43 | // Issuer: "https://dev-1234567890.okta.com", 44 | // OrgURL: "https://dev-1234567890.okta.com", 45 | // }, 46 | // authCfg: &cfg.Auth{ 47 | // Claims: test.cfgClaims, 48 | // }, 49 | // logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), 50 | // } 51 | 52 | // claims, err := provider.verifyClaims(&Jwt{Claims: test.claims}) 53 | // if test.expectErr { 54 | // assert.Error(t, err) 55 | // } else { 56 | // assert.NoError(t, err) 57 | // assert.Equal(t, test.expected, claims) 58 | // } 59 | // }) 60 | // } 61 | // } 62 | 63 | // // TestOktaProvider_VerifyPermissions tests the VerifyPermissions method of the OktaProvider 64 | // func TestOktaProvider_VerifyPermissions(t *testing.T) { 65 | // for _, test := range []struct { 66 | // name string 67 | // cfgClaims []string 68 | // authCfg *cfg.Auth 69 | // claims map[string]interface{} 70 | // toolName string 71 | // expected bool 72 | // }{ 73 | // { 74 | // name: "valid claims", 75 | // cfgClaims: []string{"groups"}, 76 | // claims: map[string]interface{}{ 77 | // "groups": []string{"Base"}, 78 | // }, 79 | // authCfg: &cfg.Auth{ 80 | // Claims: []string{"groups"}, 81 | // Permissions: map[string][]string{ 82 | // "*": []string{"scope:1"}, 83 | // }, 84 | // Mappings: []cfg.Mapping{ 85 | // { 86 | // Claim: "groups:Base", 87 | // Scopes: []string{"scope:1"}, 88 | // }, 89 | // }, 90 | // Options: cfg.Options{ 91 | // ScopeMode: "any", 92 | // }, 93 | // }, 94 | // toolName: "test", 95 | // expected: true, 96 | // }, 97 | // { 98 | // name: "invalid permissions", 99 | // cfgClaims: []string{"groups"}, 100 | // claims: map[string]interface{}{ 101 | // "groups": []string{"Base"}, 102 | // }, 103 | // authCfg: &cfg.Auth{ 104 | // Claims: []string{"groups"}, 105 | // Permissions: map[string][]string{ 106 | // "*": []string{"scope:1"}, 107 | // }, 108 | // Mappings: []cfg.Mapping{ 109 | // { 110 | // Claim: "groups:Engineering", 111 | // Scopes: []string{"scope:1"}, 112 | // }, 113 | // }, 114 | // Options: cfg.Options{ 115 | // ScopeMode: "any", 116 | // }, 117 | // }, 118 | // toolName: "test", 119 | // expected: false, 120 | // }, 121 | // { 122 | // name: "valid with multiple groups", 123 | // cfgClaims: []string{"groups"}, 124 | // claims: map[string]interface{}{ 125 | // "groups": []string{"Base", "Engineering"}, 126 | // }, 127 | // authCfg: &cfg.Auth{ 128 | // Claims: []string{"groups"}, 129 | // Permissions: map[string][]string{ 130 | // "*": []string{"scope:1"}, 131 | // }, 132 | // Mappings: []cfg.Mapping{ 133 | // { 134 | // Claim: "groups:Engineering", 135 | // Scopes: []string{"scope:1"}, 136 | // }, 137 | // }, 138 | // Options: cfg.Options{ 139 | // ScopeMode: "any", 140 | // }, 141 | // }, 142 | // toolName: "test", 143 | // expected: true, 144 | // }, 145 | // { 146 | // name: "invalid permissions for specific tool", 147 | // cfgClaims: []string{"groups"}, 148 | // claims: map[string]interface{}{ 149 | // "groups": []string{"Base", "Engineering"}, 150 | // }, 151 | // authCfg: &cfg.Auth{ 152 | // Claims: []string{"groups"}, 153 | // Permissions: map[string][]string{ 154 | // "test": []string{"scope:1"}, 155 | // }, 156 | // Mappings: []cfg.Mapping{ 157 | // { 158 | // Claim: "groups:Engineering", 159 | // Scopes: []string{"scope:1"}, 160 | // }, 161 | // }, 162 | // Options: cfg.Options{ 163 | // ScopeMode: "any", 164 | // }, 165 | // }, 166 | // toolName: "private-tool", 167 | // expected: false, 168 | // }, 169 | // } { 170 | // t.Run(test.name, func(t *testing.T) { 171 | // provider := &OktaProvider{ 172 | // BaseProvider: BaseProvider{ 173 | // authCfg: test.authCfg, 174 | // logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), 175 | // }, 176 | // cfg: &cfg.Okta{ 177 | // Issuer: "https://dev-1234567890.okta.com", 178 | // OrgURL: "https://dev-1234567890.okta.com", 179 | // }, 180 | // authCfg: test.authCfg, 181 | // logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), 182 | // } 183 | 184 | // claims := provider.VerifyPermissions(test.toolName, test.claims) 185 | // assert.Equal(t, test.expected, claims) 186 | // }) 187 | // } 188 | // } 189 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matthisholleville/mcp-gateway 2 | 3 | go 1.24.5 4 | 5 | require ( 6 | github.com/cenkalti/backoff/v4 v4.3.0 7 | github.com/containerd/errdefs v1.0.0 8 | github.com/docker/docker v27.2.0+incompatible 9 | github.com/docker/go-connections v0.5.0 10 | github.com/golang-migrate/migrate/v4 v4.18.3 11 | github.com/google/uuid v1.6.0 12 | github.com/jackc/pgx/v5 v5.7.5 13 | github.com/labstack/echo-contrib v0.17.4 14 | github.com/labstack/echo/v4 v4.13.4 15 | github.com/lib/pq v1.10.9 16 | github.com/mark3labs/mcp-go v0.35.0 17 | github.com/oklog/ulid/v2 v2.1.1 18 | github.com/okta/okta-jwt-verifier-golang/v2 v2.1.1 19 | github.com/okta/okta-sdk-golang/v5 v5.0.6 20 | github.com/prometheus/client_golang v1.22.0 21 | github.com/spf13/cobra v1.9.1 22 | github.com/spf13/pflag v1.0.6 23 | github.com/spf13/viper v1.20.1 24 | github.com/stretchr/testify v1.10.0 25 | github.com/swaggo/echo-swagger v1.4.1 26 | github.com/swaggo/swag v1.16.5 27 | go.uber.org/zap v1.27.0 28 | golang.org/x/sync v0.15.0 29 | gorm.io/driver/postgres v1.6.0 30 | gorm.io/gorm v1.30.1 31 | ) 32 | 33 | require ( 34 | github.com/KyleBanks/depth v1.2.1 // indirect 35 | github.com/Microsoft/go-winio v0.6.2 // indirect 36 | github.com/beorn7/perks v1.0.1 // indirect 37 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 38 | github.com/containerd/log v0.1.0 // indirect 39 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 40 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 41 | github.com/distribution/reference v0.6.0 // indirect 42 | github.com/docker/go-units v0.5.0 // indirect 43 | github.com/felixge/httpsnoop v1.0.4 // indirect 44 | github.com/fsnotify/fsnotify v1.8.0 // indirect 45 | github.com/ghodss/yaml v1.0.0 // indirect 46 | github.com/go-jose/go-jose/v3 v3.0.3 // indirect 47 | github.com/go-logr/logr v1.4.3 // indirect 48 | github.com/go-logr/stdr v1.2.2 // indirect 49 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 50 | github.com/go-openapi/jsonreference v0.20.2 // indirect 51 | github.com/go-openapi/spec v0.20.4 // indirect 52 | github.com/go-openapi/swag v0.23.0 // indirect 53 | github.com/go-viper/mapstructure/v2 v2.3.0 // indirect 54 | github.com/goccy/go-json v0.10.3 // indirect 55 | github.com/gogo/protobuf v1.3.2 // indirect 56 | github.com/hashicorp/errwrap v1.1.0 // indirect 57 | github.com/hashicorp/go-multierror v1.1.1 // indirect 58 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 59 | github.com/jackc/pgpassfile v1.0.0 // indirect 60 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 61 | github.com/jackc/puddle/v2 v2.2.2 // indirect 62 | github.com/jinzhu/inflection v1.0.0 // indirect 63 | github.com/jinzhu/now v1.1.5 // indirect 64 | github.com/josharian/intern v1.0.0 // indirect 65 | github.com/kelseyhightower/envconfig v1.4.0 // indirect 66 | github.com/labstack/gommon v0.4.2 // indirect 67 | github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect 68 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 69 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 70 | github.com/lestrrat-go/httprc v1.0.6 // indirect 71 | github.com/lestrrat-go/iter v1.0.2 // indirect 72 | github.com/lestrrat-go/jwx v1.2.29 // indirect 73 | github.com/lestrrat-go/jwx/v2 v2.1.4 // indirect 74 | github.com/lestrrat-go/option v1.0.1 // indirect 75 | github.com/mailru/easyjson v0.7.7 // indirect 76 | github.com/mattn/go-colorable v0.1.14 // indirect 77 | github.com/mattn/go-isatty v0.0.20 // indirect 78 | github.com/moby/docker-image-spec v1.3.1 // indirect 79 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 80 | github.com/opencontainers/go-digest v1.0.0 // indirect 81 | github.com/opencontainers/image-spec v1.1.0 // indirect 82 | github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627 // indirect 83 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 84 | github.com/pkg/errors v0.9.1 // indirect 85 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 86 | github.com/prometheus/client_model v0.6.2 // indirect 87 | github.com/prometheus/common v0.63.0 // indirect 88 | github.com/prometheus/procfs v0.16.1 // indirect 89 | github.com/rogpeppe/go-internal v1.14.1 // indirect 90 | github.com/sagikazarmark/locafero v0.7.0 // indirect 91 | github.com/segmentio/asm v1.2.0 // indirect 92 | github.com/sourcegraph/conc v0.3.0 // indirect 93 | github.com/spf13/afero v1.12.0 // indirect 94 | github.com/spf13/cast v1.7.1 // indirect 95 | github.com/subosito/gotenv v1.6.0 // indirect 96 | github.com/swaggo/files/v2 v2.0.0 // indirect 97 | github.com/valyala/bytebufferpool v1.0.0 // indirect 98 | github.com/valyala/fasttemplate v1.2.2 // indirect 99 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 100 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 101 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 102 | go.opentelemetry.io/otel v1.37.0 // indirect 103 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect 104 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 105 | go.opentelemetry.io/otel/sdk v1.37.0 // indirect 106 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 107 | go.uber.org/atomic v1.11.0 // indirect 108 | go.uber.org/multierr v1.11.0 // indirect 109 | golang.org/x/crypto v0.39.0 // indirect 110 | golang.org/x/mod v0.25.0 // indirect 111 | golang.org/x/net v0.41.0 // indirect 112 | golang.org/x/oauth2 v0.30.0 // indirect 113 | golang.org/x/sys v0.33.0 // indirect 114 | golang.org/x/text v0.26.0 // indirect 115 | golang.org/x/time v0.11.0 // indirect 116 | golang.org/x/tools v0.33.0 // indirect 117 | google.golang.org/protobuf v1.36.6 // indirect 118 | gopkg.in/yaml.v2 v2.4.0 // indirect 119 | gopkg.in/yaml.v3 v3.0.1 // indirect 120 | gotest.tools/v3 v3.5.2 // indirect 121 | ) 122 | -------------------------------------------------------------------------------- /internal/auth/base_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/matthisholleville/mcp-gateway/internal/storage" 8 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func initData(t *testing.T, attributeToRoles []storage.AttributeToRolesConfig, roles []storage.RoleConfig) storage.Interface { 13 | engine := storage.NewMemoryStorage("") 14 | for _, role := range roles { 15 | err := engine.SetRole(context.Background(), role) 16 | if err != nil { 17 | t.Fatalf("Failed to set role: %v", err) 18 | } 19 | } 20 | for _, attributeToRole := range attributeToRoles { 21 | err := engine.SetAttributeToRoles(context.Background(), attributeToRole) 22 | if err != nil { 23 | t.Fatalf("Failed to set attribute to roles: %v", err) 24 | } 25 | } 26 | 27 | return engine 28 | } 29 | 30 | func initLogger() logger.Logger { 31 | return logger.MustNewLogger("json", "debug", "test") 32 | } 33 | 34 | func TestBaseProvider_ClaimToRoles(t *testing.T) { 35 | for _, test := range []struct { 36 | name string 37 | attributeToRoles []storage.AttributeToRolesConfig 38 | roles []storage.RoleConfig 39 | endWithError bool 40 | expected []string 41 | }{ 42 | { 43 | name: "Admin", 44 | attributeToRoles: []storage.AttributeToRolesConfig{ 45 | { 46 | AttributeKey: "Groups", 47 | AttributeValue: "group1", 48 | Roles: []string{"Admin"}, 49 | }, 50 | }, 51 | roles: []storage.RoleConfig{ 52 | { 53 | Name: "Admin", 54 | Permissions: []storage.PermissionConfig{ 55 | { 56 | ObjectType: "*", 57 | Proxy: "*", 58 | ObjectName: "*", 59 | }, 60 | }, 61 | }, 62 | }, 63 | endWithError: false, 64 | expected: []string{"Admin"}, 65 | }, 66 | { 67 | name: "Empty data", 68 | attributeToRoles: []storage.AttributeToRolesConfig{}, 69 | roles: []storage.RoleConfig{}, 70 | endWithError: false, 71 | expected: []string{}, 72 | }, 73 | } { 74 | t.Run(test.name, func(t *testing.T) { 75 | engine := initData(t, test.attributeToRoles, test.roles) 76 | logger := initLogger() 77 | provider := BaseProvider{ 78 | storage: engine, 79 | logger: logger, 80 | } 81 | attributeToRoles := provider.attributeToRoles(context.Background(), map[string]interface{}{ 82 | "email": "test@test.com", 83 | "auth_time": 1717000000, 84 | "email_verified": false, 85 | "identities": map[string]string{"google.com": "test@test.com"}, 86 | "Groups": []string{"group1", "group2"}, 87 | }) 88 | assert.Equal(t, test.expected, attributeToRoles) 89 | }) 90 | } 91 | } 92 | 93 | func TestBaseProvider_VerifyPermissions(t *testing.T) { 94 | for _, test := range []struct { 95 | name string 96 | attributeToRoles []storage.AttributeToRolesConfig 97 | roles []storage.RoleConfig 98 | objectType string 99 | objectName string 100 | proxy string 101 | claims map[string]interface{} 102 | expected bool 103 | }{ 104 | { 105 | name: "Authorized: Admin", 106 | attributeToRoles: []storage.AttributeToRolesConfig{ 107 | { 108 | AttributeKey: "Groups", 109 | AttributeValue: "group1", 110 | Roles: []string{"Admin"}, 111 | }, 112 | }, 113 | roles: []storage.RoleConfig{ 114 | { 115 | Name: "Admin", 116 | Permissions: []storage.PermissionConfig{ 117 | { 118 | ObjectType: "*", 119 | Proxy: "*", 120 | ObjectName: "*", 121 | }, 122 | }, 123 | }, 124 | }, 125 | expected: true, 126 | objectType: "tools", 127 | objectName: "all", 128 | proxy: "tools", 129 | claims: map[string]interface{}{ 130 | "Groups": []string{"group1"}, 131 | }, 132 | }, 133 | { 134 | name: "Unauthorized: Tools Admin requested prompts", 135 | attributeToRoles: []storage.AttributeToRolesConfig{ 136 | { 137 | AttributeKey: "Groups", 138 | AttributeValue: "group1", 139 | Roles: []string{"ToolsAdmin"}, 140 | }, 141 | }, 142 | roles: []storage.RoleConfig{ 143 | { 144 | Name: "ToolsAdmin", 145 | Permissions: []storage.PermissionConfig{ 146 | { 147 | ObjectType: "tools", 148 | Proxy: "*", 149 | ObjectName: "*", 150 | }, 151 | }, 152 | }, 153 | }, 154 | expected: false, 155 | objectType: "prompts", 156 | objectName: "all", 157 | proxy: "tools", 158 | claims: map[string]interface{}{ 159 | "Groups": []string{"group1"}, 160 | }, 161 | }, 162 | { 163 | name: "Unauthorized: User with no role in attribute to roles", 164 | attributeToRoles: []storage.AttributeToRolesConfig{ 165 | { 166 | AttributeKey: "Groups", 167 | AttributeValue: "group1", 168 | Roles: []string{"Admin"}, 169 | }, 170 | }, 171 | roles: []storage.RoleConfig{ 172 | { 173 | Name: "Admin", 174 | Permissions: []storage.PermissionConfig{ 175 | { 176 | ObjectType: "tools", 177 | Proxy: "*", 178 | ObjectName: "*", 179 | }, 180 | }, 181 | }, 182 | }, 183 | expected: false, 184 | objectType: "tools", 185 | objectName: "all", 186 | proxy: "tools", 187 | claims: map[string]interface{}{ 188 | "Groups": []string{"group2"}, 189 | }, 190 | }, 191 | } { 192 | t.Run(test.name, func(t *testing.T) { 193 | engine := initData(t, test.attributeToRoles, test.roles) 194 | logger := initLogger() 195 | provider := BaseProvider{ 196 | storage: engine, 197 | logger: logger, 198 | } 199 | permissions := provider.VerifyPermissions(context.Background(), test.objectType, test.objectName, test.proxy, test.claims) 200 | assert.Equal(t, test.expected, permissions) 201 | }) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /internal/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | // Package proxy provides a proxy for the MCP server. 2 | package proxy 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/mark3labs/mcp-go/client" 12 | "github.com/mark3labs/mcp-go/client/transport" 13 | "github.com/mark3labs/mcp-go/mcp" 14 | "github.com/matthisholleville/mcp-gateway/internal/storage" 15 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | var ( 20 | defaultTimeout = 30 * time.Hour 21 | initialBackoff = 500 * time.Millisecond 22 | maxBackoff = 5 * time.Second 23 | maxRetriesOnConnect = 5 24 | ) 25 | 26 | type proxy struct { 27 | name string 28 | cfg *storage.ProxyConfig 29 | logger logger.Logger 30 | client *client.Client 31 | mu sync.Mutex 32 | } 33 | 34 | type proxyInterface interface { 35 | GetTools() ([]mcp.Tool, error) 36 | CallTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) 37 | GetName() string 38 | } 39 | 40 | var _ proxyInterface = &proxy{} 41 | 42 | // NewProxy creates a new proxy. 43 | // 44 | //nolint:gocritic // we need to keep logger as a parameter for the function 45 | func NewProxy(proxyCfg *[]storage.ProxyConfig, logger logger.Logger) (*[]proxyInterface, error) { 46 | proxies := &[]proxyInterface{} 47 | 48 | for _, srv := range *proxyCfg { 49 | cfgCopy := srv 50 | p := &proxy{ 51 | name: cfgCopy.Name, 52 | cfg: &cfgCopy, 53 | logger: logger.With(zap.String("mcp_proxy", cfgCopy.Name)), 54 | } 55 | 56 | if err := p.ensureConnected(context.Background()); err != nil { 57 | logger.Error("unable to connect to MCP server", zap.String("proxy", cfgCopy.Name), zap.Error(err)) 58 | continue 59 | } 60 | 61 | *proxies = append(*proxies, p) 62 | } 63 | 64 | return proxies, nil 65 | } 66 | 67 | func (p *proxy) dial(ctx context.Context) error { 68 | tr, err := openStreamableHTTPProxy(p.cfg, p.logger) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | cli := client.NewClient(tr) // transport wrapper 74 | 75 | // handshake MCP/initialize 76 | _, err = cli.Initialize(ctx, mcp.InitializeRequest{ 77 | Params: mcp.InitializeParams{ 78 | ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, 79 | ClientInfo: mcp.Implementation{ 80 | Name: "MCP Gateway Proxy", 81 | Version: "1.1.0", 82 | }, 83 | }, 84 | }) 85 | if err != nil { 86 | _ = tr.Close() 87 | return err 88 | } 89 | 90 | p.client = cli 91 | p.logger.Info("connected") 92 | return nil 93 | } 94 | 95 | func (p *proxy) ensureConnected(ctx context.Context) error { 96 | p.mu.Lock() 97 | defer p.mu.Unlock() 98 | 99 | if p.client != nil { 100 | return nil 101 | } 102 | 103 | b := initialBackoff 104 | for i := 0; i < maxRetriesOnConnect; i++ { 105 | err := p.dial(ctx) 106 | if err == nil { 107 | return nil 108 | } 109 | p.logger.Warn("dial failed, retrying...", 110 | zap.Int("attempt", i+1), 111 | zap.Error(err)) 112 | time.Sleep(b) 113 | b *= 2 114 | if b > maxBackoff { 115 | b = maxBackoff 116 | } 117 | } 118 | return fmt.Errorf("unable to connect after %d attempts", maxRetriesOnConnect) 119 | } 120 | 121 | func (p *proxy) CallTool(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { 122 | req.Params.Name = strings.TrimPrefix(req.Params.Name, p.name+":") 123 | 124 | if err := p.ensureConnected(ctx); err != nil { 125 | return nil, err 126 | } 127 | 128 | res, err := p.client.CallTool(ctx, req) 129 | if err == nil || !isTransient(err) { 130 | return res, err 131 | } 132 | 133 | p.logger.Warn("transient error, forcing reconnect", zap.Error(err)) 134 | p.resetClient() 135 | 136 | if err := p.ensureConnected(ctx); err != nil { 137 | return nil, err 138 | } 139 | return p.client.CallTool(ctx, req) 140 | } 141 | 142 | func isTransient(err error) bool { 143 | if err == nil { 144 | return false 145 | } 146 | msg := err.Error() 147 | return strings.Contains(msg, "context canceled") || 148 | strings.Contains(msg, "transport error") || 149 | strings.Contains(msg, "session terminated") || 150 | strings.Contains(msg, "connection reset") 151 | } 152 | 153 | func (p *proxy) resetClient() { 154 | p.mu.Lock() 155 | defer p.mu.Unlock() 156 | if p.client != nil { 157 | _ = p.client.Close() 158 | p.client = nil 159 | } 160 | } 161 | 162 | func (p *proxy) GetTools() ([]mcp.Tool, error) { 163 | ctx := context.Background() 164 | 165 | if err := p.ensureConnected(ctx); err != nil { 166 | return nil, err 167 | } 168 | 169 | toolsResult, err := p.client.ListTools(ctx, mcp.ListToolsRequest{}) 170 | if err != nil { 171 | return nil, err 172 | } 173 | return toolsResult.Tools, nil 174 | } 175 | 176 | // startHeartbeat starts a heartbeat for the proxy. 177 | // func (p *proxy) startHeartbeat(interval time.Duration) { 178 | // ticker := time.NewTicker(interval) 179 | // defer ticker.Stop() 180 | 181 | // for range ticker.C { 182 | // p.logger.Debug("heartbeat...", zap.String("interval", interval.String()), zap.String("proxy", p.name)) 183 | // ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 184 | // if err := p.ensureConnected(ctx); err != nil { 185 | // p.logger.Warn("heartbeat failed", zap.Error(err)) 186 | // } 187 | // cancel() 188 | // } 189 | // } 190 | 191 | func (p *proxy) GetName() string { 192 | return p.name 193 | } 194 | 195 | func openStreamableHTTPProxy(proxyConfig *storage.ProxyConfig, log logger.Logger) (*transport.StreamableHTTP, error) { 196 | log.Debug("opening streamable HTTP proxy", zap.Any("proxyConfig", proxyConfig)) 197 | ctx := context.Background() 198 | endpoint := proxyConfig.URL 199 | 200 | headers := map[string]string{} 201 | for _, header := range proxyConfig.Headers { 202 | headers[header.Key] = header.Value 203 | } 204 | 205 | timeout := defaultTimeout 206 | if proxyConfig.Timeout != 0 { 207 | timeout = proxyConfig.Timeout 208 | } 209 | 210 | httpTransport, err := transport.NewStreamableHTTP( 211 | endpoint, 212 | transport.WithHTTPTimeout(timeout), 213 | transport.WithHTTPHeaders(headers), 214 | ) 215 | if err != nil { 216 | return nil, err 217 | } 218 | 219 | if err := httpTransport.Start(ctx); err != nil { 220 | return nil, err 221 | } 222 | 223 | log.Debug("streamable HTTP proxy opened", zap.Any("proxyConfig", proxyConfig)) 224 | 225 | return httpTransport, nil 226 | } 227 | -------------------------------------------------------------------------------- /.github/workflows/release_please.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "[0-9]+.[0-9]+.x" 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: write 16 | issues: write 17 | pull-requests: write 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | jobs: 24 | release-please: 25 | permissions: 26 | contents: write # for google-github-actions/release-please-action to create release commit 27 | pull-requests: write # for google-github-actions/release-please-action to create release PR 28 | runs-on: ubuntu-latest 29 | outputs: 30 | releases_created: ${{ steps.release.outputs.releases_created }} 31 | tag_name: ${{ steps.release.outputs.tag_name }} 32 | # Release-please creates a PR that tracks all changes 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 36 | 37 | - uses: google-github-actions/release-please-action@e4dc86ba9405554aeba3c6bb2d169500e7d3b4ee # v4.1.1 38 | id: release 39 | with: 40 | token: ${{secrets.RELEASE_TOKEN}} 41 | config-file: release-please-config.json 42 | manifest-file: .release-please-manifest.json 43 | 44 | goreleaser: 45 | if: needs.release-please.outputs.releases_created == 'true' 46 | permissions: 47 | contents: write 48 | needs: 49 | - release-please 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Free Disk Space (Ubuntu) 53 | uses: jlumbroso/free-disk-space@main 54 | with: 55 | # this might remove tools that are actually needed, 56 | # if set to "true" but frees about 6 GB 57 | tool-cache: false 58 | # all of these default to true, but feel free to set to 59 | # "false" if necessary for your workflow 60 | android: false 61 | dotnet: false 62 | haskell: false 63 | large-packages: true 64 | docker-images: true 65 | swap-storage: true 66 | - name: Checkout 67 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 68 | with: 69 | fetch-depth: 0 70 | - name: Set up Go 71 | uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5 72 | with: 73 | go-version: "1.23" 74 | - name: Download Syft 75 | uses: anchore/sbom-action/download-syft@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8 76 | - name: Run GoReleaser 77 | uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6 78 | with: 79 | # either 'goreleaser' (default) or 'goreleaser-pro' 80 | distribution: goreleaser 81 | version: latest 82 | args: release --clean 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 85 | 86 | build-container-dev: 87 | if: needs.release-please.outputs.releases_created != 'true' 88 | needs: 89 | - release-please 90 | runs-on: ubuntu-latest 91 | permissions: 92 | contents: write 93 | packages: write 94 | id-token: write 95 | env: 96 | IMAGE_NAME: ghcr.io/matthisholleville/mcp-gateway-dev 97 | steps: 98 | - name: Checkout 99 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 100 | 101 | - name: Set up Docker Buildx 102 | id: buildx 103 | uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3 104 | 105 | - name: Login to GitHub Container Registry 106 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 107 | with: 108 | registry: "ghcr.io" 109 | username: ${{ github.actor }} 110 | password: ${{ secrets.RELEASE_TOKEN }} 111 | 112 | - name: Build & Push Web Docker Image 113 | uses: ./.github/actions/package-docker-image 114 | with: 115 | build_context_directory: . 116 | build_image_directory: ./Dockerfile 117 | container_target_platforms: linux/amd64,linux/arm64 118 | container_image_name: ${{ env.IMAGE_NAME }} 119 | container_image_tag_latest: true 120 | container_image_tag: dev-${{ github.sha }} 121 | container_image_push: true 122 | github_token: ${{ secrets.RELEASE_TOKEN }} 123 | 124 | build-container: 125 | if: needs.release-please.outputs.releases_created == 'true' 126 | needs: 127 | - release-please 128 | runs-on: ubuntu-latest 129 | permissions: 130 | contents: write 131 | packages: write 132 | id-token: write 133 | env: 134 | IMAGE_NAME: ghcr.io/matthisholleville/mcp-gateway 135 | steps: 136 | - name: Checkout 137 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 138 | 139 | - name: Set up Docker Buildx 140 | id: buildx 141 | uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3 142 | 143 | - name: Login to GitHub Container Registry 144 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 145 | with: 146 | registry: "ghcr.io" 147 | username: ${{ github.actor }} 148 | password: ${{ secrets.GITHUB_TOKEN }} 149 | 150 | - name: Build & Push Web Docker Image 151 | uses: ./.github/actions/package-docker-image 152 | with: 153 | build_context_directory: . 154 | build_image_directory: ./Dockerfile 155 | container_target_platforms: linux/amd64,linux/arm64 156 | container_image_name: ${{ env.IMAGE_NAME }} 157 | container_image_tag_latest: true 158 | container_image_tag: ${{ needs.release-please.outputs.tag_name }} 159 | container_image_push: true 160 | github_token: ${{ secrets.RELEASE_TOKEN }} 161 | 162 | - name: Release helm chart 163 | uses: bitdeps/helm-oci-charts-releaser@main 164 | with: 165 | oci_registry: ghcr.io/matthisholleville/mcp-gateway-charts 166 | oci_username: username 167 | oci_password: ${{ secrets.RELEASE_TOKEN }} 168 | github_token: ${{ secrets.RELEASE_TOKEN }} 169 | 170 | - name: Generate SBOM 171 | uses: anchore/sbom-action@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8 172 | with: 173 | image: ${{ env.IMAGE_NAME }} 174 | artifact-name: sbom-mcp-gateway 175 | output-file: ./sbom-mcp-gateway.spdx.json 176 | 177 | - name: Attach SBOM to release 178 | uses: softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8 # v2 179 | with: 180 | tag_name: ${{ needs.release-please.outputs.tag_name }} 181 | files: ./sbom-mcp-gateway.spdx.json 182 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | timeout: 5m 5 | go: "1.24" # Minimum supported Go version 6 | tests: false 7 | modules-download-mode: readonly 8 | 9 | linters: 10 | default: none # Disable all linters by default 11 | enable: 12 | # Errors and bugs 13 | - errcheck # Checks for unchecked errors 14 | - govet # Official Go static analysis 15 | - staticcheck # Advanced static analysis 16 | - ineffassign # Detects ineffectual assignments 17 | - unused # Detects unused code 18 | - bodyclose # Checks that HTTP response body is closed 19 | - gosec # Security analysis 20 | 21 | # Complexity and performance 22 | - gocyclo # Cyclomatic complexity 23 | - funlen # Function length 24 | - gocognit # Cognitive complexity 25 | - prealloc # Optimizes slice allocations 26 | - unconvert # Detects unnecessary conversions 27 | 28 | # Style and best practices 29 | - revive # Modern replacement for golint 30 | - misspell # Spelling mistakes 31 | - whitespace # Extra whitespace 32 | - nolintlint # Checks nolint directives 33 | 34 | # Tests and documentation 35 | - ginkgolinter # Ginkgo-specific linter 36 | 37 | # Constants and duplications 38 | - goconst # Detects repeated strings that should be constants 39 | - dupl # Detects code duplication 40 | - mnd # Magic numbers detector 41 | 42 | # Domain-specific 43 | # - dogsled # Assignments with too many blank variables (DISABLED) 44 | - gocritic # Meta-linter with many rules 45 | - lll # Line length 46 | - nakedret # Naked returns in large functions 47 | - noctx # Detects HTTP requests without context 48 | - unparam # Unused parameters 49 | - goprintffuncname # Printf function naming 50 | 51 | settings: 52 | # Dupl configuration for duplication detection 53 | dupl: 54 | threshold: 100 55 | 56 | # Errcheck configuration 57 | errcheck: 58 | check-type-assertions: true 59 | check-blank: false 60 | 61 | # Funlen configuration 62 | funlen: 63 | lines: 200 # Maximum 100 lines per function 64 | statements: 50 # Maximum 50 statements per function 65 | ignore-comments: true 66 | 67 | # Goconst configuration 68 | goconst: 69 | min-len: 3 # Minimum 3 characters for a constant 70 | min-occurrences: 3 # Minimum 3 occurrences 71 | 72 | # Gocritic configuration 73 | gocritic: 74 | disabled-checks: 75 | - dupImport 76 | - ifElseChain 77 | - octalLiteral 78 | - whyNoLint 79 | - singleCaseSwitch # Sometimes useful for readability 80 | enabled-tags: 81 | - diagnostic 82 | - experimental 83 | - opinionated 84 | - performance 85 | - style 86 | settings: 87 | captLocal: 88 | paramsOnly: true 89 | hugeParam: 90 | sizeThreshold: 80 91 | 92 | # Gocyclo configuration 93 | gocyclo: 94 | min-complexity: 12 # Slightly stricter than 15 95 | 96 | # Gocognit configuration (cognitive complexity) 97 | gocognit: 98 | min-complexity: 20 99 | 100 | # Lll configuration (line length) 101 | lll: 102 | line-length: 300 103 | tab-width: 2 104 | 105 | # Mnd configuration (magic numbers) 106 | mnd: 107 | checks: 108 | - argument 109 | - case 110 | - condition 111 | - operation 112 | - return 113 | # ignored-numbers: <- We don't want Magic Number 114 | # - "0" 115 | # - "1" 116 | # - "2" 117 | # - "3" 118 | # - "10" 119 | # - "100" 120 | # - "1000" 121 | ignored-functions: 122 | - strings.SplitN 123 | - make 124 | 125 | # Nolintlint configuration 126 | nolintlint: 127 | require-explanation: true 128 | require-specific: true 129 | allow-no-explanation: 130 | - funlen 131 | - lll 132 | - gocyclo 133 | allow-unused: false 134 | 135 | # Revive configuration 136 | revive: 137 | rules: 138 | - name: blank-imports 139 | - name: context-as-argument 140 | - name: context-keys-type 141 | - name: dot-imports 142 | - name: error-return 143 | - name: error-strings 144 | - name: error-naming 145 | - name: exported 146 | - name: if-return 147 | - name: increment-decrement 148 | - name: var-naming 149 | - name: var-declaration 150 | - name: package-comments 151 | - name: range 152 | - name: receiver-naming 153 | - name: time-naming 154 | - name: unexported-return 155 | - name: indent-error-flow 156 | - name: errorf 157 | - name: empty-block 158 | - name: superfluous-else 159 | - name: unused-parameter 160 | - name: unreachable-code 161 | - name: redefines-builtin-id 162 | 163 | # Gosec configuration 164 | gosec: 165 | severity: medium 166 | confidence: medium 167 | excludes: 168 | - G404 # Use of weak random number generator (math/rand) - sometimes OK for tests 169 | 170 | # Staticcheck configuration 171 | staticcheck: 172 | checks: ["all"] 173 | 174 | exclusions: 175 | # Exclusion rules for specific paths and linters 176 | rules: 177 | # Exclude some linters from running on tests files. 178 | - path: _test\.go 179 | linters: 180 | - gocyclo 181 | - errcheck 182 | - dupl 183 | - gosec 184 | - funlen 185 | - gocognit 186 | - mnd 187 | 188 | # Exclude magic number detection in main.go and config files 189 | - path: (main\.go|config\.go|configuration\.go) 190 | linters: 191 | - mnd 192 | 193 | # Exclude depguard for vendor and generated files 194 | - path: (vendor/|generated/|\.pb\.go$|\.gen\.go$) 195 | linters: 196 | - depguard 197 | - revive 198 | - stylecheck 199 | 200 | # Allow embedding interfaces in internal packages 201 | - path: internal/ 202 | text: "exported.*should have comment" 203 | linters: 204 | - revive 205 | 206 | formatters: 207 | enable: 208 | - gofmt # Standard Go formatting 209 | - goimports # Import organization 210 | 211 | issues: 212 | # Maximum issues count per one linter. Set to 0 to disable. 213 | max-issues-per-linter: 50 214 | 215 | # Maximum count of issues with the same text. Set to 0 to disable. 216 | max-same-issues: 3 217 | 218 | # Show only new issues: if there are unstaged changes or untracked files 219 | new: false 220 | 221 | # Fix found issues (if it's supported by the linter) 222 | fix: false 223 | 224 | # Output configuration 225 | output: 226 | formats: 227 | text: 228 | print-issued-lines: true 229 | print-linter-name: true 230 | sort-order: 231 | - linter 232 | - file 233 | -------------------------------------------------------------------------------- /internal/storage/testsFixtures/postgres.go: -------------------------------------------------------------------------------- 1 | package testfixtures 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/cenkalti/backoff/v4" 13 | "github.com/containerd/errdefs" 14 | "github.com/docker/docker/api/types/container" 15 | "github.com/docker/docker/api/types/image" 16 | "github.com/docker/docker/client" 17 | "github.com/docker/go-connections/nat" 18 | "github.com/golang-migrate/migrate/v4" 19 | "github.com/golang-migrate/migrate/v4/database/postgres" 20 | _ "github.com/golang-migrate/migrate/v4/source/file" // need to import the file source 21 | _ "github.com/jackc/pgx/v5/stdlib" // need to import the PostgreSQL driver. 22 | _ "github.com/lib/pq" // need to import the PostgreSQL driver. 23 | "github.com/oklog/ulid/v2" 24 | "github.com/stretchr/testify/require" 25 | ) 26 | 27 | const ( 28 | postgresImage = "postgres:17" 29 | maxElapsedTime = 30 * time.Second 30 | ) 31 | 32 | // PostgresTestContainerOptions are the options for the PostgresTestContainer. 33 | type PostgresTestContainerOptions struct { 34 | MigrationsDir string 35 | } 36 | 37 | // PostgresTestContainer is a test container for Postgres. 38 | type PostgresTestContainer struct { 39 | addr string 40 | version int64 41 | username string 42 | password string 43 | opts *PostgresTestContainerOptions 44 | } 45 | 46 | // NewPostgresTestContainer returns an implementation of the DatastoreTestContainer interface 47 | // for Postgres. 48 | func NewPostgresTestContainer(opts *PostgresTestContainerOptions) *PostgresTestContainer { 49 | return &PostgresTestContainer{ 50 | opts: opts, 51 | } 52 | } 53 | 54 | func (p *PostgresTestContainer) GetDatabaseSchemaVersion() int64 { 55 | return p.version 56 | } 57 | 58 | // RunPostgresTestContainer runs a Postgres container, connects to it, and returns a 59 | // bootstrapped implementation of the DatastoreTestContainer interface wired up for the 60 | // Postgres datastore engine. 61 | func (p *PostgresTestContainer) RunPostgresTestContainer(t testing.TB) *PostgresTestContainer { 62 | dockerClient, err := client.NewClientWithOpts( 63 | client.FromEnv, 64 | client.WithAPIVersionNegotiation(), 65 | ) 66 | require.NoError(t, err) 67 | t.Cleanup(func() { 68 | dockerClient.Close() //nolint:errcheck // no need to check the error here 69 | }) 70 | 71 | allImages, err := dockerClient.ImageList(context.Background(), image.ListOptions{ 72 | All: true, 73 | }) 74 | require.NoError(t, err) 75 | 76 | foundPostgresImage := false 77 | 78 | AllImages: 79 | for i := range allImages { 80 | dockerImage := allImages[i] 81 | for _, tag := range dockerImage.RepoTags { 82 | if strings.Contains(tag, postgresImage) { 83 | foundPostgresImage = true 84 | break AllImages 85 | } 86 | } 87 | } 88 | 89 | if !foundPostgresImage { 90 | reader, err := dockerClient.ImagePull(context.Background(), postgresImage, image.PullOptions{}) 91 | require.NoError(t, err) 92 | 93 | _, err = io.Copy(io.Discard, reader) // consume the image pull output to make sure it's done 94 | require.NoError(t, err) 95 | } 96 | 97 | containerCfg := container.Config{ 98 | Env: []string{ 99 | "POSTGRES_DB=defaultdb", 100 | "POSTGRES_PASSWORD=secret", 101 | }, 102 | ExposedPorts: nat.PortSet{ 103 | nat.Port("5432/tcp"): {}, 104 | }, 105 | Image: postgresImage, 106 | } 107 | 108 | hostCfg := container.HostConfig{ 109 | AutoRemove: true, 110 | PublishAllPorts: true, 111 | Tmpfs: map[string]string{"/var/lib/postgresql/data": ""}, 112 | } 113 | 114 | name := "postgres-" + ulid.Make().String() 115 | 116 | cont, err := dockerClient.ContainerCreate(context.Background(), &containerCfg, &hostCfg, nil, nil, name) 117 | require.NoError(t, err, "failed to create postgres docker container") 118 | 119 | t.Cleanup(func() { 120 | timeoutSec := 5 121 | 122 | err := dockerClient.ContainerStop(context.Background(), cont.ID, container.StopOptions{Timeout: &timeoutSec}) 123 | if err != nil && !errdefs.IsNotFound(err) { 124 | t.Logf("failed to stop postgres container: %v", err) 125 | } 126 | }) 127 | 128 | err = dockerClient.ContainerStart(context.Background(), cont.ID, container.StartOptions{}) 129 | require.NoError(t, err, "failed to start postgres container") 130 | 131 | containerJSON, err := dockerClient.ContainerInspect(context.Background(), cont.ID) 132 | require.NoError(t, err) 133 | 134 | m, ok := containerJSON.NetworkSettings.Ports["5432/tcp"] 135 | if !ok || len(m) == 0 { 136 | require.Fail(t, "failed to get host port mapping from postgres container") 137 | } 138 | 139 | pgTestContainer := &PostgresTestContainer{ 140 | addr: "localhost:" + m[0].HostPort, 141 | username: "postgres", 142 | password: "secret", 143 | } 144 | 145 | uri := fmt.Sprintf( 146 | "postgres://%s:%s@%s/defaultdb?sslmode=disable", 147 | pgTestContainer.username, 148 | pgTestContainer.password, 149 | pgTestContainer.addr, 150 | ) 151 | 152 | db, err := sql.Open("postgres", uri) 153 | require.NoError(t, err) 154 | 155 | // Wait for the database to be ready before creating the driver 156 | backoffPolicy := backoff.NewExponentialBackOff() 157 | backoffPolicy.MaxElapsedTime = maxElapsedTime 158 | err = backoff.Retry( 159 | func() error { 160 | return db.PingContext(context.Background()) 161 | }, 162 | backoffPolicy, 163 | ) 164 | require.NoError(t, err, "failed to connect to postgres container") 165 | 166 | p.executeMigrationsIfNeeded(t, db) 167 | 168 | return pgTestContainer 169 | } 170 | 171 | // GetConnectionURI returns the postgres connection uri for the running postgres test container. 172 | func (p *PostgresTestContainer) GetConnectionURI(includeCredentials bool) string { 173 | creds := "" 174 | if includeCredentials { 175 | creds = fmt.Sprintf("%s:%s@", p.username, p.password) 176 | } 177 | 178 | return fmt.Sprintf( 179 | "postgres://%s%s/%s?sslmode=disable", 180 | creds, 181 | p.addr, 182 | "defaultdb", 183 | ) 184 | } 185 | 186 | func (p *PostgresTestContainer) GetUsername() string { 187 | return p.username 188 | } 189 | 190 | func (p *PostgresTestContainer) GetPassword() string { 191 | return p.password 192 | } 193 | 194 | func (p *PostgresTestContainer) executeMigrationsIfNeeded(t testing.TB, db *sql.DB) { 195 | if p.opts.MigrationsDir != "" { 196 | t.Logf("executing migrations from %s", p.opts.MigrationsDir) 197 | driver, err := postgres.WithInstance(db, &postgres.Config{}) 198 | require.NoError(t, err) 199 | migrateInstance, err := migrate.NewWithDatabaseInstance( 200 | fmt.Sprintf("file://%s", p.opts.MigrationsDir), 201 | "postgres", driver) 202 | require.NoError(t, err) 203 | err = migrateInstance.Up() 204 | require.NoError(t, err) 205 | 206 | version, _, err := migrateInstance.Version() 207 | require.NoError(t, err) 208 | p.version = int64(version) //nolint:gosec // G115: migration versions are always small integers 209 | t.Logf("migrations executed successfully, version: %d", p.version) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /internal/storage/postgres_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/matthisholleville/mcp-gateway/internal/cfg" 9 | testsFixtures "github.com/matthisholleville/mcp-gateway/internal/storage/testsfixtures" 10 | "github.com/matthisholleville/mcp-gateway/pkg/aescipher" 11 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func testPostgresStorage(t *testing.T) (*PostgresStorage, error) { 16 | logger := logger.MustNewLogger("json", "debug", "") 17 | postgresOpts := &testsFixtures.PostgresTestContainerOptions{ 18 | MigrationsDir: "../../assets/migrations/postgres", 19 | } 20 | 21 | encryptor, err := aescipher.New("0123456789abcdeffedcba9876543210cafebabefacefeeddeadbeef00112233") 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | db := testsFixtures.NewPostgresTestContainer(postgresOpts).RunPostgresTestContainer(t) 27 | testConfig := &cfg.Config{ 28 | BackendConfig: &cfg.BackendConfig{ 29 | Engine: "postgres", 30 | URI: db.GetConnectionURI(true), 31 | }, 32 | } 33 | return NewPostgresStorage("test", logger, testConfig, encryptor) 34 | } 35 | 36 | func TestProxyStorage(t *testing.T) { 37 | storage, err := testPostgresStorage(t) 38 | assert.NoError(t, err) 39 | 40 | t.Run("insert proxy with unencrypted headers", func(t *testing.T) { 41 | proxy := ProxyConfig{ 42 | Name: "test", 43 | Type: ProxyTypeStreamableHTTP, 44 | URL: "https://example.com", 45 | Timeout: time.Duration(10 * time.Second), 46 | AuthType: ProxyAuthTypeHeader, 47 | Headers: []ProxyHeader{ 48 | {Key: "test", Value: "test"}, 49 | }, 50 | } 51 | err := storage.SetProxy(context.Background(), &proxy, true) 52 | assert.NoError(t, err) 53 | }) 54 | 55 | t.Run("ensure proxy & headers are inserted and decrypted", func(t *testing.T) { 56 | proxy, err := storage.GetProxy(context.Background(), "test", false) 57 | assert.NoError(t, err) 58 | assert.Equal(t, "test", proxy.Name) 59 | assert.Equal(t, ProxyTypeStreamableHTTP, proxy.Type) 60 | assert.Equal(t, "https://example.com", proxy.URL) 61 | assert.Equal(t, time.Duration(10*time.Second), proxy.Timeout) 62 | assert.Equal(t, ProxyAuthTypeHeader, proxy.AuthType) 63 | assert.NotEqual(t, "test", proxy.Headers[0].Value) 64 | }) 65 | 66 | t.Run("ensure list proxies return 1 element", func(t *testing.T) { 67 | proxies, err := storage.ListProxies(context.Background(), false) 68 | assert.NoError(t, err) 69 | assert.Equal(t, 1, len(proxies)) 70 | assert.Equal(t, "test", proxies[0].Name) 71 | }) 72 | 73 | t.Run("update proxy with new header", func(t *testing.T) { 74 | proxy, err := storage.GetProxy(context.Background(), "test", false) 75 | assert.NoError(t, err) 76 | proxy.Headers = []ProxyHeader{ 77 | {Key: "test", Value: "test2"}, 78 | {Key: "test2", Value: "test3"}, 79 | } 80 | err = storage.SetProxy(context.Background(), &proxy, false) 81 | assert.NoError(t, err) 82 | }) 83 | 84 | t.Run("ensure proxy headers are updated", func(t *testing.T) { 85 | proxy, err := storage.GetProxy(context.Background(), "test", true) 86 | assert.NoError(t, err) 87 | assert.Equal(t, "test2", proxy.Headers[0].Value) 88 | assert.Equal(t, "test3", proxy.Headers[1].Value) 89 | }) 90 | 91 | t.Run("delete proxy", func(t *testing.T) { 92 | err := storage.DeleteProxy(context.Background(), "test") 93 | assert.NoError(t, err) 94 | }) 95 | 96 | t.Run("ensure list proxies return 0 element", func(t *testing.T) { 97 | proxies, err := storage.ListProxies(context.Background(), false) 98 | assert.NoError(t, err) 99 | assert.Equal(t, 0, len(proxies)) 100 | }) 101 | } 102 | 103 | func TestRoleStorage(t *testing.T) { 104 | storage, err := testPostgresStorage(t) 105 | assert.NoError(t, err) 106 | 107 | t.Run("insert role", func(t *testing.T) { 108 | role := RoleConfig{ 109 | Name: "test", 110 | Permissions: []PermissionConfig{ 111 | { 112 | ObjectType: ObjectTypeTools, 113 | ObjectName: "*", 114 | Proxy: "*", 115 | }, 116 | }, 117 | } 118 | err := storage.SetRole(context.Background(), role) 119 | assert.NoError(t, err) 120 | }) 121 | 122 | t.Run("ensure role is inserted", func(t *testing.T) { 123 | role, err := storage.GetRole(context.Background(), "test") 124 | assert.NoError(t, err) 125 | assert.Equal(t, "test", role.Name) 126 | }) 127 | 128 | t.Run("ensure list roles return 1 element", func(t *testing.T) { 129 | roles, err := storage.ListRoles(context.Background()) 130 | assert.NoError(t, err) 131 | assert.Equal(t, 1, len(roles)) 132 | assert.Equal(t, "test", roles[0].Name) 133 | }) 134 | 135 | t.Run("delete role", func(t *testing.T) { 136 | err := storage.DeleteRole(context.Background(), "test") 137 | assert.NoError(t, err) 138 | }) 139 | 140 | t.Run("ensure list roles return 0 element", func(t *testing.T) { 141 | roles, err := storage.ListRoles(context.Background()) 142 | assert.NoError(t, err) 143 | assert.Equal(t, 0, len(roles)) 144 | }) 145 | 146 | t.Run("insert with invalid permission", func(t *testing.T) { 147 | role := RoleConfig{ 148 | Name: "test", 149 | Permissions: []PermissionConfig{ 150 | { 151 | ObjectType: "invalid", 152 | ObjectName: "*", 153 | Proxy: "*", 154 | }, 155 | }, 156 | } 157 | err := storage.SetRole(context.Background(), role) 158 | assert.Error(t, err) 159 | }) 160 | t.Run("ensure no role is inserted", func(t *testing.T) { 161 | roles, err := storage.ListRoles(context.Background()) 162 | assert.NoError(t, err) 163 | assert.Equal(t, 0, len(roles)) 164 | }) 165 | } 166 | 167 | func TestAttributeToRolesStorage(t *testing.T) { 168 | storage, err := testPostgresStorage(t) 169 | assert.NoError(t, err) 170 | 171 | t.Run("ensure failure when reference to non existing role", func(t *testing.T) { 172 | attributeToRoles := AttributeToRolesConfig{ 173 | AttributeKey: "test", 174 | AttributeValue: "test", 175 | Roles: []string{"test"}, 176 | } 177 | err := storage.SetAttributeToRoles(context.Background(), attributeToRoles) 178 | assert.Error(t, err) 179 | }) 180 | 181 | t.Run("insert role", func(t *testing.T) { 182 | role := RoleConfig{ 183 | Name: "test", 184 | Permissions: []PermissionConfig{ 185 | { 186 | ObjectType: ObjectTypeTools, 187 | ObjectName: "*", 188 | Proxy: "*", 189 | }, 190 | }, 191 | } 192 | err := storage.SetRole(context.Background(), role) 193 | assert.NoError(t, err) 194 | }) 195 | 196 | t.Run("insert attribute to roles", func(t *testing.T) { 197 | attributeToRoles := AttributeToRolesConfig{ 198 | AttributeKey: "test", 199 | AttributeValue: "test", 200 | Roles: []string{"test"}, 201 | } 202 | err := storage.SetAttributeToRoles(context.Background(), attributeToRoles) 203 | assert.NoError(t, err) 204 | }) 205 | 206 | t.Run("ensure attribute to roles is inserted", func(t *testing.T) { 207 | attributeToRoles, err := storage.GetAttributeToRoles(context.Background(), "test", "test") 208 | assert.NoError(t, err) 209 | assert.Equal(t, "test", attributeToRoles.AttributeKey) 210 | assert.Equal(t, "test", attributeToRoles.AttributeValue) 211 | }) 212 | 213 | t.Run("delete attribute to roles", func(t *testing.T) { 214 | err := storage.DeleteAttributeToRoles(context.Background(), "test", "test") 215 | assert.NoError(t, err) 216 | }) 217 | 218 | t.Run("ensure attribute to roles is deleted", func(t *testing.T) { 219 | _, err := storage.GetAttributeToRoles(context.Background(), "test", "test") 220 | assert.Error(t, err) 221 | }) 222 | } 223 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger provides a logger for the MCP Gateway. 2 | // 3 | //nolint:revive // need to match the interface 4 | package logger 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | ) 13 | 14 | // Logger is an interface that provides logging methods. 15 | type Logger interface { 16 | // These are ops that call directly to the actual zap implementation 17 | Debug(string, ...zap.Field) 18 | Info(string, ...zap.Field) 19 | Warn(string, ...zap.Field) 20 | Error(string, ...zap.Field) 21 | Panic(string, ...zap.Field) 22 | Fatal(string, ...zap.Field) 23 | With(...zap.Field) Logger 24 | Printf(string, ...interface{}) 25 | Verbose() bool 26 | 27 | // These are the equivalent logger function but with context provided 28 | DebugWithContext(context.Context, string, ...zap.Field) 29 | InfoWithContext(context.Context, string, ...zap.Field) 30 | WarnWithContext(context.Context, string, ...zap.Field) 31 | ErrorWithContext(context.Context, string, ...zap.Field) 32 | PanicWithContext(context.Context, string, ...zap.Field) 33 | FatalWithContext(context.Context, string, ...zap.Field) 34 | } 35 | 36 | // NewNoopLogger provides a noop logger. 37 | func NewNoopLogger() *ZapLogger { 38 | return &ZapLogger{ 39 | zap.NewNop(), 40 | } 41 | } 42 | 43 | // ZapLogger is an implementation of Logger that uses the uber/zap logger underneath. 44 | // It provides additional methods such as ones that logs based on context. 45 | type ZapLogger struct { 46 | *zap.Logger 47 | } 48 | 49 | var _ Logger = (*ZapLogger)(nil) 50 | 51 | // With creates a child logger and adds structured context to it. Fields added 52 | // to the child don't affect the parent, and vice versa. Any fields that 53 | // require evaluation (such as Objects) are evaluated upon invocation of With. 54 | func (l *ZapLogger) With(fields ...zap.Field) Logger { 55 | return &ZapLogger{l.Logger.With(fields...)} 56 | } 57 | 58 | //nolint:revive // need to match the interface 59 | func (l *ZapLogger) Debug(msg string, fields ...zap.Field) { 60 | l.Logger.Debug(msg, fields...) 61 | } 62 | 63 | //nolint:revive // need to match the interface 64 | func (l *ZapLogger) Info(msg string, fields ...zap.Field) { 65 | l.Logger.Info(msg, fields...) 66 | } 67 | 68 | //nolint:revive //need to match the interface 69 | func (l *ZapLogger) Warn(msg string, fields ...zap.Field) { 70 | l.Logger.Warn(msg, fields...) 71 | } 72 | 73 | func (l *ZapLogger) Error(msg string, fields ...zap.Field) { //nolint:revive // need to match the interface 74 | l.Logger.Error(msg, fields...) 75 | } 76 | 77 | //nolint:revive // need to match the interface 78 | func (l *ZapLogger) Panic(msg string, fields ...zap.Field) { 79 | l.Logger.Panic(msg, fields...) 80 | } 81 | 82 | //nolint:revive // need to match the interface 83 | func (l *ZapLogger) Fatal(msg string, fields ...zap.Field) { 84 | l.Logger.Fatal(msg, fields...) 85 | } 86 | 87 | //nolint:revive // need to match the interface 88 | func (l *ZapLogger) Printf(format string, v ...interface{}) { 89 | l.Logger.Info(fmt.Sprintf(format, v...)) 90 | } 91 | 92 | //nolint:revive // need to match the interface 93 | func (l *ZapLogger) Verbose() bool { 94 | return true 95 | } 96 | 97 | //nolint:revive // need to match the interface 98 | func (l *ZapLogger) DebugWithContext(ctx context.Context, msg string, fields ...zap.Field) { 99 | l.Logger.Debug(msg, fields...) 100 | } 101 | 102 | //nolint:revive // need to match the interface 103 | func (l *ZapLogger) InfoWithContext(ctx context.Context, msg string, fields ...zap.Field) { 104 | l.Logger.Info(msg, fields...) 105 | } 106 | 107 | //nolint:revive // need to match the interface 108 | func (l *ZapLogger) WarnWithContext(ctx context.Context, msg string, fields ...zap.Field) { 109 | l.Logger.Warn(msg, fields...) 110 | } 111 | 112 | //nolint:revive // need to match the interface 113 | func (l *ZapLogger) ErrorWithContext(ctx context.Context, msg string, fields ...zap.Field) { 114 | l.Logger.Error(msg, fields...) 115 | } 116 | 117 | //nolint:revive // need to match the interface 118 | func (l *ZapLogger) PanicWithContext(ctx context.Context, msg string, fields ...zap.Field) { 119 | l.Logger.Panic(msg, fields...) 120 | } 121 | 122 | //nolint:revive // need to match the interface 123 | func (l *ZapLogger) FatalWithContext(ctx context.Context, msg string, fields ...zap.Field) { 124 | l.Logger.Fatal(msg, fields...) 125 | } 126 | 127 | // OptionsLogger implements options for logger. 128 | type OptionsLogger struct { 129 | format string 130 | level string 131 | timestampFormat string 132 | outputPaths []string 133 | } 134 | 135 | // OptionLogger is a function that sets an option for the logger. 136 | type OptionLogger func(ol *OptionsLogger) 137 | 138 | // WithFormat sets the log format for the logger. 139 | func WithFormat(format string) OptionLogger { 140 | return func(ol *OptionsLogger) { 141 | ol.format = format 142 | } 143 | } 144 | 145 | // WithLevel sets the log level for the logger. 146 | func WithLevel(level string) OptionLogger { 147 | return func(ol *OptionsLogger) { 148 | ol.level = level 149 | } 150 | } 151 | 152 | // WithTimestampFormat sets the timestamp format for the logger. 153 | func WithTimestampFormat(timestampFormat string) OptionLogger { 154 | return func(ol *OptionsLogger) { 155 | ol.timestampFormat = timestampFormat 156 | } 157 | } 158 | 159 | // WithOutputPaths sets a list of URLs or file paths to write logging output to. 160 | // 161 | // URLs with the "file" scheme must use absolute paths on the local filesystem. 162 | // No user, password, port, fragments, or query parameters are allowed, and the 163 | // hostname must be empty or "localhost". 164 | // 165 | // Since it's common to write logs to the local filesystem, URLs without a scheme 166 | // (e.g., "/var/log/foo.log") are treated as local file paths. Without a scheme, 167 | // the special paths "stdout" and "stderr" are interpreted as os.Stdout and os.Stderr. 168 | // When specified without a scheme, relative file paths also work. 169 | // 170 | // Defaults to "stdout". 171 | func WithOutputPaths(paths ...string) OptionLogger { 172 | return func(ol *OptionsLogger) { 173 | ol.outputPaths = paths 174 | } 175 | } 176 | 177 | // NewLogger creates a new logger with the given options. 178 | func NewLogger(options ...OptionLogger) (*ZapLogger, error) { 179 | logOptions := &OptionsLogger{ 180 | level: "info", 181 | format: "text", 182 | timestampFormat: "ISO8601", 183 | outputPaths: []string{"stdout"}, 184 | } 185 | 186 | for _, opt := range options { 187 | opt(logOptions) 188 | } 189 | 190 | if logOptions.level == "none" { 191 | return NewNoopLogger(), nil 192 | } 193 | 194 | level, err := zap.ParseAtomicLevel(logOptions.level) 195 | if err != nil { 196 | return nil, fmt.Errorf("unknown log level: %s, error: %w", logOptions.level, err) 197 | } 198 | 199 | cfg := zap.NewProductionConfig() 200 | cfg.Level = level 201 | cfg.OutputPaths = logOptions.outputPaths 202 | cfg.EncoderConfig.TimeKey = "timestamp" 203 | cfg.EncoderConfig.CallerKey = "" // remove the "caller" field 204 | cfg.DisableStacktrace = true 205 | 206 | if logOptions.format == "text" { 207 | cfg.Encoding = "console" 208 | cfg.DisableCaller = true 209 | cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 210 | cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 211 | } else { // Json 212 | cfg.EncoderConfig.EncodeTime = zapcore.EpochTimeEncoder // default in json for backward compatibility 213 | if logOptions.timestampFormat == "ISO8601" { 214 | cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 215 | } 216 | } 217 | 218 | log, err := cfg.Build() 219 | if err != nil { 220 | return nil, err 221 | } 222 | 223 | return &ZapLogger{log}, nil 224 | } 225 | 226 | // MustNewLogger creates a new logger with the given format, level, and timestamp format. 227 | // It panics if the logger creation fails. 228 | func MustNewLogger(logFormat, logLevel, logTimestampFormat string) *ZapLogger { 229 | logger, err := NewLogger( 230 | WithFormat(logFormat), 231 | WithLevel(logLevel), 232 | WithTimestampFormat(logTimestampFormat)) 233 | if err != nil { 234 | panic(err) 235 | } 236 | 237 | return logger 238 | } 239 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Variables 2 | APP_NAME := mcp-gateway 3 | MODULE_NAME := github.com/matthisholleville/mcp-gateway 4 | CONFIG_FILE := ./config/config.yaml 5 | BUILD_DIR := ./bin 6 | GO_VERSION := 1.23.2 7 | 8 | # Colors for messages 9 | GREEN := \033[32m 10 | YELLOW := \033[33m 11 | RED := \033[31m 12 | RESET := \033[0m 13 | 14 | .PHONY: help run build clean test lint fmt vet deps install serve serve-postgres migrate dev check mocks swagger envrc-sample check-envrc helm-docs create-migration 15 | 16 | ## help: Show this help 17 | help: 18 | @echo "Available commands:" 19 | @echo "" 20 | @grep -E '^## [a-zA-Z_-]+:' $(MAKEFILE_LIST) | \ 21 | awk 'BEGIN {FS = "## "}; {printf " $(GREEN)%-15s$(RESET) %s\n", $$2, $$3}' | \ 22 | sed 's/:/ /' 23 | 24 | ## serve-postgres: Run the application in server mode with debug configuration and postgres backend 25 | serve-postgres: 26 | @echo "$(YELLOW)Starting server in debug mode...$(RESET)" 27 | go run main.go serve --log-format=text --log-level=debug --http-admin-api-key=admin --backend-engine=postgres --backend-uri='mcp-gateway:changeme@localhost:5439/mcp-gateway?sslmode=disable' --backend-encryption-key=0123456789abcdeffedcba9876543210cafebabefacefeeddeadbeef00112233 28 | 29 | migrate: 30 | @echo "$(YELLOW)Migrating database...$(RESET)" 31 | go run main.go migrate --verbose --log-level=info --log-format=json 32 | 33 | ## serve: Run the application in server mode with debug configuration and memory backend 34 | serve: 35 | @echo "$(YELLOW)Starting server in debug mode...$(RESET)" 36 | go run main.go serve --log-format=text --log-level=debug --http-admin-api-key=admin --backend-engine=memory 37 | 38 | ## dev: Alias for serve (for development) 39 | dev: serve 40 | 41 | ## run: Run the application (equivalent to serve) 42 | run: serve 43 | 44 | ## build: Compile the application 45 | build: 46 | @echo "$(YELLOW)Building application...$(RESET)" 47 | @mkdir -p $(BUILD_DIR) 48 | go build -o $(BUILD_DIR)/$(APP_NAME) main.go 49 | @echo "$(GREEN)✓ Application built in $(BUILD_DIR)/$(APP_NAME)$(RESET)" 50 | 51 | ## install: Install the application in $GOPATH/bin 52 | install: 53 | @echo "$(YELLOW)Installing application...$(RESET)" 54 | go install . 55 | @echo "$(GREEN)✓ Application installed$(RESET)" 56 | 57 | ## test: Run tests 58 | test: 59 | @echo "$(YELLOW)Running tests...$(RESET)" 60 | go test -v ./... 61 | 62 | ## test-cover: Run tests with coverage 63 | test-cover: 64 | @echo "$(YELLOW)Running tests with coverage...$(RESET)" 65 | go test -v -coverprofile=coverage.out ./... 66 | go tool cover -html=coverage.out -o coverage.html 67 | @echo "$(GREEN)✓ Coverage report generated in coverage.html$(RESET)" 68 | 69 | ## bench: Run benchmarks 70 | bench: 71 | @echo "$(YELLOW)Running benchmarks...$(RESET)" 72 | go test -bench=. -benchmem ./... 73 | 74 | ## lint: Run linter (golangci-lint) 75 | lint: 76 | @echo "$(YELLOW)Running linter...$(RESET)" 77 | @if command -v golangci-lint >/dev/null 2>&1; then \ 78 | golangci-lint run; \ 79 | else \ 80 | echo "$(RED)golangci-lint is not installed. Install it with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest$(RESET)"; \ 81 | fi 82 | 83 | ## fmt: Format the code 84 | fmt: 85 | @echo "$(YELLOW)Formatting code...$(RESET)" 86 | go fmt ./... 87 | @echo "$(GREEN)✓ Code formatted$(RESET)" 88 | 89 | ## vet: Run go vet 90 | vet: 91 | @echo "$(YELLOW)Running go vet...$(RESET)" 92 | go vet ./... 93 | @echo "$(GREEN)✓ go vet completed$(RESET)" 94 | 95 | ## deps: Download dependencies 96 | deps: 97 | @echo "$(YELLOW)Downloading dependencies...$(RESET)" 98 | go mod download 99 | @echo "$(GREEN)✓ Dependencies downloaded$(RESET)" 100 | 101 | ## tidy: Clean up dependencies 102 | tidy: 103 | @echo "$(YELLOW)Tidying dependencies...$(RESET)" 104 | go mod tidy 105 | @echo "$(GREEN)✓ Dependencies tidied$(RESET)" 106 | 107 | ## vendor: Create vendor directory 108 | vendor: 109 | @echo "$(YELLOW)Creating vendor directory...$(RESET)" 110 | go mod vendor 111 | @echo "$(GREEN)✓ Vendor directory created$(RESET)" 112 | 113 | ## check: Run all checks (fmt, vet, lint, test) 114 | check: fmt vet lint test check-envrc 115 | @echo "$(GREEN)✓ All checks passed$(RESET)" 116 | 117 | ## clean: Clean generated files 118 | clean: 119 | @echo "$(YELLOW)Cleaning...$(RESET)" 120 | rm -rf $(BUILD_DIR) 121 | rm -f coverage.out coverage.html 122 | go clean 123 | @echo "$(GREEN)✓ Cleaning completed$(RESET)" 124 | 125 | ## docker-build: Build Docker image (if Dockerfile exists) 126 | docker-build: 127 | @if [ -f Dockerfile ]; then \ 128 | echo "$(YELLOW)Building Docker image...$(RESET)"; \ 129 | docker build -t $(APP_NAME) .; \ 130 | echo "$(GREEN)✓ Docker image built$(RESET)"; \ 131 | else \ 132 | echo "$(RED)No Dockerfile found$(RESET)"; \ 133 | fi 134 | 135 | ## version: Display versions 136 | version: 137 | @echo "Go version: $(shell go version)" 138 | @echo "Module: $(MODULE_NAME)" 139 | @echo "App: $(APP_NAME)" 140 | 141 | mocks: 142 | @echo "Generating mocks..." 143 | @mockgen -source=pkg/grafana/grafana.go -destination=pkg/grafana/mocks/grafana_mocks.go -package=mocks 144 | @mockgen -source=pkg/nexus/nexus.go -destination=pkg/nexus/mocks/nexus_mocks.go -package=mocks 145 | @mockgen -source=pkg/pritunl/pritunl.go -destination=pkg/pritunl/mocks/pritunl_mocks.go -package=mocks 146 | @echo "Mocks generated successfully!" 147 | 148 | swagger: 149 | @echo "Generating Swagger documentation..." 150 | @rm -rf ./swagger 151 | @swag init \ 152 | --generalInfo ./internal/server/server.go \ 153 | --output ./swagger \ 154 | --parseDependency \ 155 | --parseInternal \ 156 | --parseDepth 2 157 | @echo "Swagger documentation generated successfully!" 158 | 159 | ## envrc-sample: Generate .envrc-sample with obfuscated values for git safety 160 | envrc-sample: 161 | @echo "$(YELLOW)Generating .envrc-sample...$(RESET)" 162 | @if [ ! -f .envrc ]; then \ 163 | echo "$(RED)No .envrc file found$(RESET)"; \ 164 | exit 1; \ 165 | fi 166 | @sed -E \ 167 | -e 's/export ([^=]+)="[^"]*"/export \1="***REDACTED***"/g' \ 168 | -e 's/export ([^=]+)=\$$\(cat [^)]+\)/export \1="***REDACTED_FILE_CONTENT***"/g' \ 169 | .envrc > .envrc-sample 170 | @echo "# This is a sample environment file" > .envrc-sample.tmp 171 | @echo "# Copy this to .envrc and fill in your actual values" >> .envrc-sample.tmp 172 | @echo "# DO NOT commit .envrc with real values!" >> .envrc-sample.tmp 173 | @echo "" >> .envrc-sample.tmp 174 | @cat .envrc-sample >> .envrc-sample.tmp 175 | @mv .envrc-sample.tmp .envrc-sample 176 | @echo "$(GREEN)✓ .envrc-sample generated successfully$(RESET)" 177 | @echo "$(YELLOW)📋 Review the generated file:$(RESET)" 178 | @cat .envrc-sample 179 | 180 | ## check-envrc: Verify .envrc-sample is up to date 181 | check-envrc: 182 | @echo "$(YELLOW)Checking if .envrc-sample is up to date...$(RESET)" 183 | @if [ ! -f .envrc-sample ]; then \ 184 | echo "$(RED)❌ .envrc-sample missing! Run 'make envrc-sample' to generate it$(RESET)"; \ 185 | exit 1; \ 186 | fi 187 | @if [ ! -f .envrc ]; then \ 188 | echo "$(YELLOW)⚠️ No .envrc found (normal in CI). Skipping .envrc-sample check.$(RESET)"; \ 189 | else \ 190 | echo "$(YELLOW)Comparing .envrc-sample with current .envrc...$(RESET)"; \ 191 | sed -E \ 192 | -e 's/export ([^=]+)="[^"]*"/export \1="***REDACTED***"/g' \ 193 | -e 's/export ([^=]+)=\$$\(cat [^)]+\)/export \1="***REDACTED_FILE_CONTENT***"/g' \ 194 | .envrc > .envrc-temp; \ 195 | echo "# This is a sample environment file" > .envrc-sample-temp; \ 196 | echo "# Copy this to .envrc and fill in your actual values" >> .envrc-sample-temp; \ 197 | echo "# DO NOT commit .envrc with real values!" >> .envrc-sample-temp; \ 198 | echo "" >> .envrc-sample-temp; \ 199 | cat .envrc-temp >> .envrc-sample-temp; \ 200 | rm -f .envrc-temp; \ 201 | if ! diff -q .envrc-sample .envrc-sample-temp >/dev/null 2>&1; then \ 202 | echo "$(RED)❌ .envrc-sample is outdated! Run 'make envrc-sample' to update it$(RESET)"; \ 203 | rm -f .envrc-sample-temp; \ 204 | exit 1; \ 205 | else \ 206 | echo "$(GREEN)✅ .envrc-sample is up to date$(RESET)"; \ 207 | rm -f .envrc-sample-temp; \ 208 | fi; \ 209 | fi 210 | 211 | helm-docs: 212 | @echo "$(YELLOW)Generating Helm documentation...$(RESET)" 213 | helm-docs --chart-search-root=./charts/mcp-gateway --template-files=README.md.gotmpl 214 | @echo "$(GREEN)✓ Helm documentation generated$(RESET)" 215 | 216 | ## create-migration: Create a new migration file 217 | create-migration: 218 | @if [ -z "$(name)" ]; then \ 219 | echo "Error: name parameter is required. Usage: make create-migration name=your_migration_name"; \ 220 | exit 1; \ 221 | fi 222 | migrate create -ext sql -dir "./assets/migrations/postgres" -seq "$(name)" 223 | 224 | # Default rule 225 | .DEFAULT_GOAL := help -------------------------------------------------------------------------------- /internal/server/v1handlers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/labstack/echo/v4" 8 | "github.com/matthisholleville/mcp-gateway/internal/storage" 9 | ) 10 | 11 | func (s *Server) ConfigureRoutes(c *echo.Group) { 12 | admin := c.Group("/admin") 13 | admin.GET("/proxies", s.getProxies) 14 | admin.GET("/proxies/:name", s.getProxy) 15 | admin.PUT("/proxies/:name", s.upsertProxy) 16 | admin.DELETE("/proxies/:name", s.deleteProxy) 17 | 18 | admin.GET("/roles", s.getRoles) 19 | admin.PUT("/roles", s.upsertRole) 20 | admin.DELETE("/roles/:role", s.deleteRole) 21 | 22 | admin.GET("/attribute-to-roles", s.getAttributeToRoles) 23 | admin.PUT("/attribute-to-roles", s.upsertAttributeToRole) 24 | admin.DELETE("/attribute-to-roles/:attributeKey/:attributeValue", s.deleteAttributeToRole) 25 | } 26 | 27 | // @Summary Get all proxies 28 | // @Description Get all proxies 29 | // @Tags proxies 30 | // @Accept json 31 | // @Produce json 32 | // @Security Authentication 33 | // @Success 200 {array} storage.ProxyConfig 34 | // @Failure 500 {object} map[string]string 35 | // @Router /v1/admin/proxies [get] 36 | func (s *Server) getProxies(c echo.Context) error { 37 | proxies, err := s.Storage.ListProxies(c.Request().Context(), false) 38 | if err != nil { 39 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 40 | } 41 | if len(proxies) == 0 { 42 | proxies = []storage.ProxyConfig{} 43 | } 44 | return c.JSON(http.StatusOK, proxies) 45 | } 46 | 47 | // @Summary Get a proxy 48 | // @Description Get a proxy 49 | // @Tags proxies 50 | // @Accept json 51 | // @Produce json 52 | // @Param name path string true "Proxy name" 53 | // @Success 200 {object} storage.ProxyConfig 54 | // @Failure 500 {object} map[string]string 55 | // @Security Authentication 56 | // @Router /v1/admin/proxies/{name} [get] 57 | func (s *Server) getProxy(c echo.Context) error { 58 | name := c.Param("name") 59 | proxy, err := s.Storage.GetProxy(c.Request().Context(), name, false) 60 | if err != nil { 61 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 62 | } 63 | return c.JSON(http.StatusOK, proxy) 64 | } 65 | 66 | // @Summary Upsert a proxy 67 | // @Description Upsert a proxy 68 | // @Tags proxies 69 | // @Accept json 70 | // @Produce json 71 | // @Param proxy body storage.ProxyConfig true "Proxy" 72 | // @Success 200 {object} storage.ProxyConfig 73 | // @Failure 400 {object} map[string]string 74 | // @Failure 500 {object} map[string]string 75 | // @Security Authentication 76 | // @Router /v1/admin/proxies/{name} [put] 77 | func (s *Server) upsertProxy(c echo.Context) error { 78 | proxy := storage.ProxyConfig{} 79 | var err error 80 | if err := c.Bind(&proxy); err != nil { 81 | return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) 82 | } 83 | 84 | proxy.Timeout *= time.Second 85 | 86 | err = s.Storage.SetProxy(c.Request().Context(), &proxy, true) 87 | if err != nil { 88 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 89 | } 90 | return nil 91 | } 92 | 93 | // @Summary Delete a proxy 94 | // @Description Delete a proxy 95 | // @Tags proxies 96 | // @Accept json 97 | // @Produce json 98 | // @Param name path string true "Proxy name" 99 | // @Success 200 {object} map[string]string 100 | // @Failure 500 {object} map[string]string 101 | // @Security Authentication 102 | // @Router /v1/admin/proxies/{name} [delete] 103 | func (s *Server) deleteProxy(c echo.Context) error { 104 | name := c.Param("name") 105 | err := s.Storage.DeleteProxy(c.Request().Context(), name) 106 | if err != nil { 107 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 108 | } 109 | return nil 110 | } 111 | 112 | // @Summary Get all roles 113 | // @Description Get all roles 114 | // @Tags roles 115 | // @Accept json 116 | // @Produce json 117 | // @Security Authentication 118 | // @Success 200 {array} storage.RoleConfig 119 | // @Failure 500 {object} map[string]string 120 | // @Router /v1/admin/roles [get] 121 | func (s *Server) getRoles(c echo.Context) error { 122 | roles, err := s.Storage.ListRoles(c.Request().Context()) 123 | if err != nil { 124 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 125 | } 126 | return c.JSON(http.StatusOK, roles) 127 | } 128 | 129 | // @Summary Upsert a role 130 | // @Description Upsert a role 131 | // @Tags roles 132 | // @Accept json 133 | // @Produce json 134 | // @Param role body storage.RoleConfig true "Role" 135 | // @Success 200 {object} storage.RoleConfig 136 | // @Failure 400 {object} map[string]string 137 | // @Failure 500 {object} map[string]string 138 | // @Security Authentication 139 | // @Router /v1/admin/roles [put] 140 | func (s *Server) upsertRole(c echo.Context) error { 141 | role := storage.RoleConfig{} 142 | if err := c.Bind(&role); err != nil { 143 | return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) 144 | } 145 | err := s.Storage.SetRole(c.Request().Context(), role) 146 | if err != nil { 147 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 148 | } 149 | return nil 150 | } 151 | 152 | // @Summary Delete a role 153 | // @Description Delete a role 154 | // @Tags roles 155 | // @Accept json 156 | // @Produce json 157 | // @Param role path string true "Role" 158 | // @Success 200 {object} map[string]string 159 | // @Failure 400 {object} map[string]string 160 | // @Failure 500 {object} map[string]string 161 | // @Security Authentication 162 | // @Router /v1/admin/roles/{role} [delete] 163 | func (s *Server) deleteRole(c echo.Context) error { 164 | role := c.Param("role") 165 | if role == "" { 166 | return c.JSON(http.StatusBadRequest, map[string]string{"error": "role is required"}) 167 | } 168 | err := s.Storage.DeleteRole(c.Request().Context(), role) 169 | if err != nil { 170 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 171 | } 172 | return nil 173 | } 174 | 175 | // @Summary Get all attribute to roles 176 | // @Description Get all attribute to roles 177 | // @Tags attribute to roles 178 | // @Accept json 179 | // @Produce json 180 | // @Security Authentication 181 | // @Success 200 {array} storage.AttributeToRolesConfig 182 | // @Failure 500 {object} map[string]string 183 | // @Router /v1/admin/attribute-to-roles [get] 184 | func (s *Server) getAttributeToRoles(c echo.Context) error { 185 | attributeToRoles, err := s.Storage.ListAttributeToRoles(c.Request().Context()) 186 | if err != nil { 187 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 188 | } 189 | return c.JSON(http.StatusOK, attributeToRoles) 190 | } 191 | 192 | // @Summary Upsert a attribute to role 193 | // @Description Upsert a attribute to role 194 | // @Tags attribute to roles 195 | // @Accept json 196 | // @Produce json 197 | // @Param attributeToRole body storage.AttributeToRolesConfig true "Attribute to role" 198 | // @Success 200 {object} storage.AttributeToRolesConfig 199 | // @Failure 400 {object} map[string]string 200 | // @Failure 500 {object} map[string]string 201 | // @Security Authentication 202 | // @Router /v1/admin/attribute-to-roles [put] 203 | func (s *Server) upsertAttributeToRole(c echo.Context) error { 204 | attributeToRole := storage.AttributeToRolesConfig{} 205 | if err := c.Bind(&attributeToRole); err != nil { 206 | return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) 207 | } 208 | 209 | err := s.Storage.SetAttributeToRoles(c.Request().Context(), attributeToRole) 210 | if err != nil { 211 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 212 | } 213 | return nil 214 | } 215 | 216 | // @Summary Delete a attribute to role 217 | // @Description Delete a attribute to role 218 | // @Tags attribute to roles 219 | // @Accept json 220 | // @Produce json 221 | // @Param attributeKey path string true "Attribute key" 222 | // @Param attributeValue path string true "Attribute value" 223 | // @Success 200 {object} map[string]string 224 | // @Failure 400 {object} map[string]string 225 | // @Failure 500 {object} map[string]string 226 | // @Security Authentication 227 | // @Router /v1/admin/attribute-to-roles/{attributeKey}/{attributeValue} [delete] 228 | func (s *Server) deleteAttributeToRole(c echo.Context) error { 229 | attributeKey := c.Param("attributeKey") 230 | attributeValue := c.Param("attributeValue") 231 | if attributeKey == "" || attributeValue == "" { 232 | return c.JSON(http.StatusBadRequest, map[string]string{"error": "attribute key and attribute value are required"}) 233 | } 234 | err := s.Storage.DeleteAttributeToRoles(c.Request().Context(), attributeKey, attributeValue) 235 | if err != nil { 236 | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 237 | } 238 | return nil 239 | } 240 | -------------------------------------------------------------------------------- /internal/server/middleware_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/labstack/echo/v4" 14 | "github.com/mark3labs/mcp-go/mcp" 15 | "github.com/matthisholleville/mcp-gateway/internal/auth" 16 | "github.com/matthisholleville/mcp-gateway/internal/cfg" 17 | "github.com/matthisholleville/mcp-gateway/pkg/logger" 18 | "github.com/stretchr/testify/assert" 19 | "github.com/stretchr/testify/require" 20 | ) 21 | 22 | // MockProvider est un mock simple du Provider pour les tests 23 | type MockProvider struct { 24 | shouldVerifyToken bool 25 | shouldVerifyPermissions bool 26 | verifyTokenError error 27 | } 28 | 29 | func (m *MockProvider) Init() error { 30 | return nil 31 | } 32 | 33 | func (m *MockProvider) VerifyToken(token string) (*auth.Jwt, error) { 34 | if m.verifyTokenError != nil { 35 | return nil, m.verifyTokenError 36 | } 37 | if !m.shouldVerifyToken { 38 | return nil, assert.AnError 39 | } 40 | return &auth.Jwt{ 41 | Claims: map[string]interface{}{ 42 | "sub": "test-user", 43 | }, 44 | }, nil 45 | } 46 | 47 | func (m *MockProvider) VerifyPermissions(ctx context.Context, objectType, objectName, proxy string, claims map[string]interface{}) bool { 48 | return m.shouldVerifyPermissions 49 | } 50 | 51 | // createTestServer creates a test server with the given OAuth enabled and provider 52 | func createTestServer(oauthEnabled bool, provider auth.Provider) *Server { 53 | log := logger.MustNewLogger("json", "debug", "test") 54 | return &Server{ 55 | Config: &cfg.Config{ 56 | OAuth: &cfg.OAuthConfig{ 57 | Enabled: oauthEnabled, 58 | AuthorizationServers: []string{"https://test.example.com"}, 59 | }, 60 | }, 61 | Router: echo.New(), 62 | Logger: log, 63 | Provider: provider, 64 | } 65 | } 66 | 67 | // createMCPRequest creates a MCP request with the given method and tool name 68 | func createMCPRequest(method, toolName string) *http.Request { 69 | toolRequest := mcp.CallToolRequest{ 70 | Request: mcp.Request{ 71 | Method: method, 72 | }, 73 | Params: mcp.CallToolParams{ 74 | Name: toolName, 75 | }, 76 | } 77 | 78 | body, _ := json.Marshal(toolRequest) 79 | req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) 80 | req.Header.Set("Content-Type", "application/json") 81 | return req 82 | } 83 | 84 | // createTestContext creates a test context with the given server, request, response recorder and path 85 | func createTestContext(server *Server, req *http.Request, rec *httptest.ResponseRecorder, path string) echo.Context { 86 | c := server.Router.NewContext(req, rec) 87 | c.SetPath(path) 88 | c.SetRequest(req) 89 | return c 90 | } 91 | 92 | // TestAuthMiddleware_NonMCPPath tests the auth middleware with a non-MCP path 93 | func TestAuthMiddleware_NonMCPPath(t *testing.T) { 94 | provider := &MockProvider{} 95 | server := createTestServer(true, provider) 96 | 97 | // Handler simple qui retourne OK 98 | nextHandler := func(c echo.Context) error { 99 | return c.String(http.StatusOK, "ok") 100 | } 101 | 102 | middleware := server.authMiddleware(nextHandler) 103 | 104 | // Test avec un path non-MCP 105 | req := httptest.NewRequest(http.MethodGet, "/other", nil) 106 | rec := httptest.NewRecorder() 107 | c := createTestContext(server, req, rec, "/other") 108 | 109 | err := middleware(c) 110 | 111 | assert.NoError(t, err) 112 | assert.Equal(t, http.StatusOK, rec.Code) 113 | assert.Equal(t, "ok", rec.Body.String()) 114 | } 115 | 116 | // TestAuthMiddleware_OAuthDisabledAndNotToolCall tests the auth middleware with a MCP request and OAuth disabled 117 | func TestAuthMiddleware_OAuthDisabledAndNotToolCall(t *testing.T) { 118 | provider := &MockProvider{} 119 | server := createTestServer(false, provider) // OAuth disabled 120 | 121 | nextHandler := func(c echo.Context) error { 122 | return c.String(http.StatusOK, "ok") 123 | } 124 | 125 | middleware := server.authMiddleware(nextHandler) 126 | 127 | // MCP request but not a tool call 128 | req := createMCPRequest("tools/list", "") 129 | rec := httptest.NewRecorder() 130 | c := createTestContext(server, req, rec, "/mcp") 131 | 132 | err := middleware(c) 133 | 134 | assert.NoError(t, err) 135 | assert.Equal(t, http.StatusOK, rec.Code) 136 | } 137 | 138 | // TestAuthMiddleware_MissingToken tests the auth middleware with a MCP request and missing token 139 | func TestAuthMiddleware_MissingToken(t *testing.T) { 140 | provider := &MockProvider{} 141 | server := createTestServer(true, provider) 142 | 143 | nextHandler := func(c echo.Context) error { 144 | return c.String(http.StatusOK, "ok") 145 | } 146 | 147 | middleware := server.authMiddleware(nextHandler) 148 | 149 | // MCP request but no token 150 | req := createMCPRequest("tools/call", "proxy1:tool1") 151 | rec := httptest.NewRecorder() 152 | c := createTestContext(server, req, rec, "/mcp") 153 | 154 | err := middleware(c) 155 | 156 | // Should return a HTTP 401 error 157 | fmt.Println(err) 158 | httpErr, ok := err.(*echo.HTTPError) 159 | require.True(t, ok) 160 | assert.Equal(t, http.StatusUnauthorized, httpErr.Code) 161 | assert.Equal(t, "Missing token", httpErr.Message) 162 | } 163 | 164 | // TestAuthMiddleware_InvalidToken tests the auth middleware with a MCP request and invalid token 165 | func TestAuthMiddleware_InvalidToken(t *testing.T) { 166 | provider := &MockProvider{ 167 | shouldVerifyToken: false, // Invalid token 168 | } 169 | server := createTestServer(true, provider) 170 | 171 | nextHandler := func(c echo.Context) error { 172 | return c.String(http.StatusOK, "ok") 173 | } 174 | 175 | middleware := server.authMiddleware(nextHandler) 176 | 177 | // Request with invalid token 178 | req := createMCPRequest("tools/call", "proxy1:tool1") 179 | req.Header.Set("Authorization", "Bearer invalid-token") 180 | rec := httptest.NewRecorder() 181 | c := createTestContext(server, req, rec, "/mcp") 182 | 183 | err := middleware(c) 184 | 185 | httpErr, ok := err.(*echo.HTTPError) 186 | require.True(t, ok) 187 | assert.Equal(t, http.StatusUnauthorized, httpErr.Code) 188 | assert.Equal(t, "Invalid token", httpErr.Message) 189 | } 190 | 191 | // TestAuthMiddleware_InsufficientPermissions tests the auth middleware with a MCP request and insufficient permissions 192 | func TestAuthMiddleware_InsufficientPermissions(t *testing.T) { 193 | provider := &MockProvider{ 194 | shouldVerifyToken: true, // Valid token 195 | shouldVerifyPermissions: false, // Insufficient permissions 196 | } 197 | server := createTestServer(true, provider) 198 | 199 | nextHandler := func(c echo.Context) error { 200 | return c.String(http.StatusOK, "ok") 201 | } 202 | 203 | middleware := server.authMiddleware(nextHandler) 204 | 205 | req := createMCPRequest("tools/call", "proxy1:tool1") 206 | req.Header.Set("Authorization", "Bearer valid-token") 207 | rec := httptest.NewRecorder() 208 | c := createTestContext(server, req, rec, "/mcp") 209 | 210 | err := middleware(c) 211 | 212 | httpErr, ok := err.(*echo.HTTPError) 213 | require.True(t, ok) 214 | assert.Equal(t, http.StatusUnauthorized, httpErr.Code) 215 | assert.Equal(t, "Insufficient scope", httpErr.Message) 216 | } 217 | 218 | // TestAuthMiddleware_Success tests the auth middleware with a MCP request and valid token and permissions 219 | func TestAuthMiddleware_Success(t *testing.T) { 220 | provider := &MockProvider{ 221 | shouldVerifyToken: true, // Valid token 222 | shouldVerifyPermissions: true, // Permissions OK 223 | } 224 | server := createTestServer(true, provider) 225 | 226 | nextHandler := func(c echo.Context) error { 227 | // Check that the claims are added to the context 228 | claims := c.Get("claims") 229 | assert.NotNil(t, claims) 230 | return c.String(http.StatusOK, "ok") 231 | } 232 | 233 | middleware := server.authMiddleware(nextHandler) 234 | 235 | req := createMCPRequest("tools/call", "proxy1:tool1") 236 | req.Header.Set("Authorization", "Bearer valid-token") 237 | rec := httptest.NewRecorder() 238 | c := createTestContext(server, req, rec, "/mcp") 239 | 240 | err := middleware(c) 241 | 242 | assert.NoError(t, err) 243 | assert.Equal(t, http.StatusOK, rec.Code) 244 | assert.Equal(t, "ok", rec.Body.String()) 245 | } 246 | 247 | // TestAuthMiddleware_TokenWithBearerPrefix tests the auth middleware with a MCP request and token with bearer prefix 248 | func TestAuthMiddleware_TokenWithBearerPrefix(t *testing.T) { 249 | provider := &MockProvider{ 250 | shouldVerifyToken: true, 251 | shouldVerifyPermissions: true, 252 | } 253 | server := createTestServer(true, provider) 254 | 255 | nextHandler := func(c echo.Context) error { 256 | return c.String(http.StatusOK, "ok") 257 | } 258 | 259 | middleware := server.authMiddleware(nextHandler) 260 | 261 | req := createMCPRequest("tools/call", "proxy1:tool1") 262 | req.Header.Set("Authorization", "Bearer my-token") // With bearer prefix 263 | rec := httptest.NewRecorder() 264 | c := createTestContext(server, req, rec, "/mcp") 265 | 266 | err := middleware(c) 267 | 268 | assert.NoError(t, err) 269 | } 270 | 271 | // TestAuthMiddleware_InvalidRequestBody tests the auth middleware with a MCP request and invalid request body 272 | func TestAuthMiddleware_InvalidRequestBody(t *testing.T) { 273 | provider := &MockProvider{} 274 | server := createTestServer(true, provider) 275 | 276 | nextHandler := func(c echo.Context) error { 277 | return c.String(http.StatusOK, "ok") 278 | } 279 | 280 | middleware := server.authMiddleware(nextHandler) 281 | 282 | // Request with invalid JSON body 283 | req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader("invalid json")) 284 | req.Header.Set("Content-Type", "application/json") 285 | rec := httptest.NewRecorder() 286 | c := createTestContext(server, req, rec, "/mcp") 287 | 288 | err := middleware(c) 289 | 290 | httpErr, ok := err.(*echo.HTTPError) 291 | require.True(t, ok) 292 | assert.Equal(t, http.StatusUnauthorized, httpErr.Code) 293 | assert.Equal(t, "Invalid request", httpErr.Message) 294 | } 295 | 296 | // TestAuthMiddleware_OAuthDisabledButToolCall tests the auth middleware with a MCP request and OAuth disabled but tool call 297 | func TestAuthMiddleware_OAuthDisabledButToolCall(t *testing.T) { 298 | provider := &MockProvider{ 299 | shouldVerifyToken: true, 300 | } 301 | server := createTestServer(false, provider) // OAuth disabled 302 | 303 | nextHandler := func(c echo.Context) error { 304 | return c.String(http.StatusOK, "ok") 305 | } 306 | 307 | middleware := server.authMiddleware(nextHandler) 308 | 309 | // Tool call with OAuth disabled but token present 310 | req := createMCPRequest("tools/call", "proxy1:tool1") 311 | req.Header.Set("Authorization", "Bearer valid-token") 312 | rec := httptest.NewRecorder() 313 | c := createTestContext(server, req, rec, "/mcp") 314 | 315 | err := middleware(c) 316 | 317 | // Shouldn't pass because insufficient permissions 318 | httpErr, ok := err.(*echo.HTTPError) 319 | require.True(t, ok) 320 | assert.Equal(t, http.StatusUnauthorized, httpErr.Code) 321 | assert.Equal(t, "Insufficient scope", httpErr.Message) 322 | 323 | } 324 | --------------------------------------------------------------------------------