e04ed9c211
CF: Deploy Dev Docs / deploy (push) Has been cancelled
Sync Labels / build (push) Has been cancelled
tests / unit tests (macos-latest) (push) Has been cancelled
tests / unit tests (windows-latest) (push) Has been cancelled
tests / unit tests (ubuntu-latest) (push) Has been cancelled
689 lines
23 KiB
Go
689 lines
23 KiB
Go
// Copyright 2024 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/go-chi/httplog/v3"
|
|
"github.com/go-chi/render"
|
|
"github.com/googleapis/mcp-toolbox/internal/auth"
|
|
"github.com/googleapis/mcp-toolbox/internal/embeddingmodels"
|
|
"github.com/googleapis/mcp-toolbox/internal/log"
|
|
"github.com/googleapis/mcp-toolbox/internal/prompts"
|
|
"github.com/googleapis/mcp-toolbox/internal/server/mcp/jsonrpc"
|
|
"github.com/googleapis/mcp-toolbox/internal/server/resources"
|
|
"github.com/googleapis/mcp-toolbox/internal/sources"
|
|
"github.com/googleapis/mcp-toolbox/internal/telemetry"
|
|
"github.com/googleapis/mcp-toolbox/internal/tools"
|
|
"github.com/googleapis/mcp-toolbox/internal/util"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
// Server contains info for running an instance of Toolbox. Should be instantiated with NewServer().
|
|
type Server struct {
|
|
version string
|
|
sqlCommenterEnabled bool
|
|
toolboxUrl string
|
|
srv *http.Server
|
|
listener net.Listener
|
|
root chi.Router
|
|
logger log.Logger
|
|
instrumentation *telemetry.Instrumentation
|
|
sseManager *sseManager
|
|
ResourceMgr *resources.ResourceManager
|
|
mcpPrmFile string
|
|
httpMaxRequestBytes int64
|
|
enableDraftSpecs bool
|
|
}
|
|
|
|
func InitializeConfigs(ctx context.Context, cfg ServerConfig) (
|
|
map[string]sources.Source,
|
|
map[string]auth.AuthService,
|
|
map[string]embeddingmodels.EmbeddingModel,
|
|
map[string]tools.Tool,
|
|
map[string]tools.Toolset,
|
|
map[string]prompts.Prompt,
|
|
map[string]prompts.Promptset,
|
|
error,
|
|
) {
|
|
if cfg.EnableAPI {
|
|
for _, sc := range cfg.AuthServiceConfigs {
|
|
if sc.IsMCPEnabled() {
|
|
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("MCP Auth cannot be enabled together with the legacy HTTP API (EnableAPI)")
|
|
}
|
|
}
|
|
}
|
|
|
|
metadataStr := cfg.Version
|
|
if len(cfg.UserAgentMetadata) > 0 {
|
|
metadataStr += "+" + strings.Join(cfg.UserAgentMetadata, "+")
|
|
}
|
|
ctx = util.WithUserAgent(ctx, metadataStr)
|
|
instrumentation, err := util.InstrumentationFromContext(ctx)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get instrumentation from context: %w", err)
|
|
}
|
|
|
|
l, err := util.LoggerFromContext(ctx)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get logger from context: %w", err)
|
|
}
|
|
|
|
// initialize and validate the sources from configs
|
|
sourcesMap := make(map[string]sources.Source)
|
|
for name, sc := range cfg.SourceConfigs {
|
|
s, err := func() (sources.Source, error) {
|
|
childCtx, span := instrumentation.Tracer.Start(
|
|
ctx,
|
|
"toolbox/server/source/init",
|
|
trace.WithAttributes(attribute.String("source_type", sc.SourceConfigType())),
|
|
trace.WithAttributes(attribute.String("source_name", name)),
|
|
)
|
|
defer span.End()
|
|
s, err := sc.Initialize(childCtx, instrumentation.Tracer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize source %q: %w", name, err)
|
|
}
|
|
return s, nil
|
|
}()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, err
|
|
}
|
|
sourcesMap[name] = s
|
|
}
|
|
sourceNames := make([]string, 0, len(sourcesMap))
|
|
for name := range sourcesMap {
|
|
sourceNames = append(sourceNames, name)
|
|
}
|
|
l.InfoContext(ctx, fmt.Sprintf("Initialized %d sources: %s", len(sourcesMap), strings.Join(sourceNames, ", ")))
|
|
|
|
// initialize and validate the auth services from configs
|
|
authServicesMap := make(map[string]auth.AuthService)
|
|
for name, sc := range cfg.AuthServiceConfigs {
|
|
a, err := func() (auth.AuthService, error) {
|
|
_, span := instrumentation.Tracer.Start(
|
|
ctx,
|
|
"toolbox/server/auth/init",
|
|
trace.WithAttributes(attribute.String("auth_type", sc.AuthServiceConfigType())),
|
|
trace.WithAttributes(attribute.String("auth_name", name)),
|
|
)
|
|
defer span.End()
|
|
a, err := sc.Initialize()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize auth service %q: %w", name, err)
|
|
}
|
|
return a, nil
|
|
}()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, err
|
|
}
|
|
authServicesMap[name] = a
|
|
}
|
|
|
|
authServiceNames := make([]string, 0, len(authServicesMap))
|
|
for name := range authServicesMap {
|
|
authServiceNames = append(authServiceNames, name)
|
|
}
|
|
l.InfoContext(ctx, fmt.Sprintf("Initialized %d authServices: %s", len(authServicesMap), strings.Join(authServiceNames, ", ")))
|
|
|
|
// Initialize and validate embedding models from configs.
|
|
embeddingModelsMap := make(map[string]embeddingmodels.EmbeddingModel)
|
|
for name, ec := range cfg.EmbeddingModelConfigs {
|
|
em, err := func() (embeddingmodels.EmbeddingModel, error) {
|
|
_, span := instrumentation.Tracer.Start(
|
|
ctx,
|
|
"toolbox/server/embeddingmodel/init",
|
|
trace.WithAttributes(attribute.String("model_type", ec.EmbeddingModelConfigType())),
|
|
trace.WithAttributes(attribute.String("model_name", name)),
|
|
)
|
|
defer span.End()
|
|
em, err := ec.Initialize(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize embedding model %q: %w", name, err)
|
|
}
|
|
return em, nil
|
|
}()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, err
|
|
}
|
|
embeddingModelsMap[name] = em
|
|
}
|
|
embeddingModelNames := make([]string, 0, len(embeddingModelsMap))
|
|
for name := range embeddingModelsMap {
|
|
embeddingModelNames = append(embeddingModelNames, name)
|
|
}
|
|
l.InfoContext(ctx, fmt.Sprintf("Initialized %d embeddingModels: %s", len(embeddingModelsMap), strings.Join(embeddingModelNames, ", ")))
|
|
|
|
toolsMap, err := initializeTools(ctx, cfg, instrumentation, l)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, err
|
|
}
|
|
|
|
toolsetsMap, err := initializeToolsets(ctx, cfg, toolsMap, instrumentation, l)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, err
|
|
}
|
|
|
|
// initialize and validate the prompts from configs
|
|
promptsMap := make(map[string]prompts.Prompt)
|
|
for name, pc := range cfg.PromptConfigs {
|
|
p, err := func() (prompts.Prompt, error) {
|
|
_, span := instrumentation.Tracer.Start(
|
|
ctx,
|
|
"toolbox/server/prompt/init",
|
|
trace.WithAttributes(attribute.String("prompt_type", pc.PromptConfigType())),
|
|
trace.WithAttributes(attribute.String("prompt_name", name)),
|
|
)
|
|
defer span.End()
|
|
p, err := pc.Initialize()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize prompt %q: %w", name, err)
|
|
}
|
|
return p, nil
|
|
}()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, err
|
|
}
|
|
promptsMap[name] = p
|
|
}
|
|
promptNames := make([]string, 0, len(promptsMap))
|
|
for name := range promptsMap {
|
|
promptNames = append(promptNames, name)
|
|
}
|
|
l.InfoContext(ctx, fmt.Sprintf("Initialized %d prompts: %s", len(promptsMap), strings.Join(promptNames, ", ")))
|
|
|
|
// create a default promptset that contains all prompts
|
|
allPromptNames := make([]string, 0, len(promptsMap))
|
|
for name := range promptsMap {
|
|
allPromptNames = append(allPromptNames, name)
|
|
}
|
|
if cfg.PromptsetConfigs == nil {
|
|
cfg.PromptsetConfigs = make(PromptsetConfigs)
|
|
}
|
|
cfg.PromptsetConfigs[""] = prompts.PromptsetConfig{Name: "", PromptNames: allPromptNames}
|
|
|
|
// initialize and validate the promptsets from configs
|
|
promptsetsMap := make(map[string]prompts.Promptset)
|
|
for name, pc := range cfg.PromptsetConfigs {
|
|
p, err := func() (prompts.Promptset, error) {
|
|
_, span := instrumentation.Tracer.Start(
|
|
ctx,
|
|
"toolbox/server/prompset/init",
|
|
trace.WithAttributes(attribute.String("prompset_name", name)),
|
|
)
|
|
defer span.End()
|
|
p, err := pc.Initialize(cfg.Version, promptsMap)
|
|
if err != nil {
|
|
return prompts.Promptset{}, fmt.Errorf("unable to initialize promptset %q: %w", name, err)
|
|
}
|
|
return p, err
|
|
}()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, nil, err
|
|
}
|
|
promptsetsMap[name] = p
|
|
}
|
|
promptsetNames := make([]string, 0, len(promptsetsMap))
|
|
for name := range promptsetsMap {
|
|
if name == "" {
|
|
promptsetNames = append(promptsetNames, "default")
|
|
} else {
|
|
promptsetNames = append(promptsetNames, name)
|
|
}
|
|
}
|
|
l.InfoContext(ctx, fmt.Sprintf("Initialized %d promptsets: %s", len(promptsetsMap), strings.Join(promptsetNames, ", ")))
|
|
|
|
return sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, nil
|
|
}
|
|
|
|
// InitializeOfflineConfigs initializes only tools and toolsets from the config,
|
|
// skipping sources, auth services, and embedding models. It backs flows like
|
|
// skills-generate that need tool metadata without live source connections.
|
|
func InitializeOfflineConfigs(ctx context.Context, cfg ServerConfig) (
|
|
map[string]tools.Tool,
|
|
map[string]tools.Toolset,
|
|
error,
|
|
) {
|
|
instrumentation, err := util.InstrumentationFromContext(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get instrumentation from context: %w", err)
|
|
}
|
|
|
|
l, err := util.LoggerFromContext(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get logger from context: %w", err)
|
|
}
|
|
|
|
toolsMap, err := initializeTools(ctx, cfg, instrumentation, l)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
toolsetsMap, err := initializeToolsets(ctx, cfg, toolsMap, instrumentation, l)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return toolsMap, toolsetsMap, nil
|
|
}
|
|
|
|
// initializeTools initializes and validates the tools from the config.
|
|
func initializeTools(ctx context.Context, cfg ServerConfig, instrumentation *telemetry.Instrumentation, l log.Logger) (map[string]tools.Tool, error) {
|
|
toolsMap := make(map[string]tools.Tool)
|
|
for name, tc := range cfg.ToolConfigs {
|
|
t, err := func() (tools.Tool, error) {
|
|
_, span := instrumentation.Tracer.Start(
|
|
ctx,
|
|
"toolbox/server/tool/init",
|
|
trace.WithAttributes(attribute.String("tool_type", tc.ToolConfigType())),
|
|
trace.WithAttributes(attribute.String("tool_name", name)),
|
|
)
|
|
defer span.End()
|
|
t, err := tc.Initialize(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize tool %q: %w", name, err)
|
|
}
|
|
return t, nil
|
|
}()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
toolsMap[name] = t
|
|
}
|
|
toolNames := make([]string, 0, len(toolsMap))
|
|
for name := range toolsMap {
|
|
toolNames = append(toolNames, name)
|
|
}
|
|
l.InfoContext(ctx, fmt.Sprintf("Initialized %d tools: %s", len(toolsMap), strings.Join(toolNames, ", ")))
|
|
return toolsMap, nil
|
|
}
|
|
|
|
// initializeToolsets seeds a default toolset containing all tools, then
|
|
// initializes and validates the toolsets from the config.
|
|
func initializeToolsets(ctx context.Context, cfg ServerConfig, toolsMap map[string]tools.Tool, instrumentation *telemetry.Instrumentation, l log.Logger) (map[string]tools.Toolset, error) {
|
|
// create a default toolset that contains all tools
|
|
allToolNames := make([]string, 0, len(toolsMap))
|
|
for name := range toolsMap {
|
|
allToolNames = append(allToolNames, name)
|
|
}
|
|
if cfg.ToolsetConfigs == nil {
|
|
cfg.ToolsetConfigs = make(ToolsetConfigs)
|
|
}
|
|
cfg.ToolsetConfigs[""] = tools.ToolsetConfig{Name: "", ToolNames: allToolNames}
|
|
|
|
toolsetsMap := make(map[string]tools.Toolset)
|
|
for name, tc := range cfg.ToolsetConfigs {
|
|
if cfg.IgnoreUnknownTools {
|
|
filteredToolNames := make([]string, 0, len(tc.ToolNames))
|
|
for _, tn := range tc.ToolNames {
|
|
if _, ok := toolsMap[tn]; ok {
|
|
filteredToolNames = append(filteredToolNames, tn)
|
|
} else {
|
|
l.WarnContext(ctx, fmt.Sprintf("Skipping missing tool %q in toolset %q", tn, name))
|
|
}
|
|
}
|
|
tc.ToolNames = filteredToolNames
|
|
cfg.ToolsetConfigs[name] = tc
|
|
}
|
|
|
|
t, err := func() (tools.Toolset, error) {
|
|
_, span := instrumentation.Tracer.Start(
|
|
ctx,
|
|
"toolbox/server/toolset/init",
|
|
trace.WithAttributes(attribute.String("toolset.name", name)),
|
|
)
|
|
defer span.End()
|
|
t, err := tc.Initialize(cfg.Version, toolsMap)
|
|
if err != nil {
|
|
return tools.Toolset{}, fmt.Errorf("unable to initialize toolset %q: %w", name, err)
|
|
}
|
|
return t, err
|
|
}()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
toolsetsMap[name] = t
|
|
}
|
|
toolsetNames := make([]string, 0, len(toolsetsMap))
|
|
for name := range toolsetsMap {
|
|
if name == "" {
|
|
toolsetNames = append(toolsetNames, "default")
|
|
} else {
|
|
toolsetNames = append(toolsetNames, name)
|
|
}
|
|
}
|
|
l.InfoContext(ctx, fmt.Sprintf("Initialized %d toolsets: %s", len(toolsetsMap), strings.Join(toolsetNames, ", ")))
|
|
|
|
return toolsetsMap, nil
|
|
}
|
|
|
|
func hostCheck(allowedHosts map[string]struct{}) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, hasWildcard := allowedHosts["*"]
|
|
hostname := r.Host
|
|
if host, _, err := net.SplitHostPort(r.Host); err == nil {
|
|
hostname = host
|
|
}
|
|
_, hostIsAllowed := allowedHosts[hostname]
|
|
if !hasWildcard && !hostIsAllowed {
|
|
// Return 403 Forbidden to block the attack
|
|
http.Error(w, "Invalid Host header", http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// NewServer returns a Server object based on provided Config.
|
|
func NewServer(ctx context.Context, cfg ServerConfig) (*Server, error) {
|
|
instrumentation, err := util.InstrumentationFromContext(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, span := instrumentation.Tracer.Start(ctx, "toolbox/server/init")
|
|
defer span.End()
|
|
|
|
l, err := util.LoggerFromContext(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// set up http serving
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recoverer)
|
|
|
|
// logging
|
|
logLevel, err := log.SeverityToLevel(cfg.LogLevel.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize http log: %w", err)
|
|
}
|
|
|
|
schema := *httplog.SchemaGCP
|
|
schema.Level = cfg.LogLevel.String()
|
|
schema.Concise(true)
|
|
httpOpts := &httplog.Options{
|
|
Level: logLevel,
|
|
Schema: &schema,
|
|
}
|
|
logger := l.SlogLogger()
|
|
r.Use(httplog.RequestLogger(logger, httpOpts))
|
|
|
|
sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := InitializeConfigs(ctx, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize configs: %w", err)
|
|
}
|
|
|
|
addr := net.JoinHostPort(cfg.Address, strconv.Itoa(cfg.Port))
|
|
srv := &http.Server{Addr: addr, Handler: r}
|
|
|
|
sseManager := newSseManager(ctx)
|
|
|
|
resourceManager := resources.NewResourceManager(sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap)
|
|
|
|
limit := cfg.HttpMaxRequestBytes
|
|
if limit <= 0 {
|
|
limit = DefaultHTTPMaxRequestBytes
|
|
}
|
|
|
|
s := &Server{
|
|
version: cfg.Version,
|
|
sqlCommenterEnabled: cfg.SQLCommenter,
|
|
srv: srv,
|
|
root: r,
|
|
logger: l,
|
|
instrumentation: instrumentation,
|
|
sseManager: sseManager,
|
|
ResourceMgr: resourceManager,
|
|
toolboxUrl: cfg.ToolboxUrl,
|
|
mcpPrmFile: cfg.McpPrmFile,
|
|
httpMaxRequestBytes: limit,
|
|
enableDraftSpecs: cfg.EnableDraftSpecs,
|
|
}
|
|
|
|
if s.enableDraftSpecs {
|
|
s.logger.WarnContext(ctx, "Flag --enable-draft-specs is active. Please note that draft specs are subject to breaking changes and will be completely removed (not redirected) once stable MCP specifications are released. Do not use this configuration in production.")
|
|
}
|
|
|
|
// cors
|
|
if slices.Contains(cfg.AllowedOrigins, "*") {
|
|
s.logger.WarnContext(ctx, "wildcard (*) allows any website to access the resources. This creates a security risk regardless of whether you are in a production or local development environment. Recommended to use --allowed-origins with specific local addresses.")
|
|
}
|
|
corsOpts := cors.Options{
|
|
AllowedOrigins: cfg.AllowedOrigins,
|
|
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
|
|
AllowCredentials: true, // required since Toolbox uses auth headers
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Mcp-Session-Id", "MCP-Protocol-Version"},
|
|
ExposedHeaders: []string{"Mcp-Session-Id"}, // headers that are sent to clients
|
|
MaxAge: 300, // cache preflight results for 5 minutes
|
|
}
|
|
r.Use(cors.Handler(corsOpts))
|
|
// validate hosts for DNS rebinding attacks
|
|
if slices.Contains(cfg.AllowedHosts, "*") {
|
|
s.logger.WarnContext(ctx, "wildcard (*) hosts allow any domain to access this resource, making it vulnerable to DNS rebinding attacks regardless of whether you are in a production or local development environment. For improved security, use the --allowed-hosts flag to specify trusted domains.")
|
|
}
|
|
allowedHostsMap := make(map[string]struct{}, len(cfg.AllowedHosts))
|
|
for _, h := range cfg.AllowedHosts {
|
|
hostname := h
|
|
if host, _, err := net.SplitHostPort(h); err == nil {
|
|
hostname = host
|
|
}
|
|
allowedHostsMap[hostname] = struct{}{}
|
|
}
|
|
r.Use(hostCheck(allowedHostsMap))
|
|
|
|
// Host OAuth Protected Resource Metadata endpoint
|
|
mcpAuthEnabled := false
|
|
for _, authSvc := range s.ResourceMgr.GetAuthServiceMap() {
|
|
if mSvc, ok := authSvc.(auth.MCPAuthService); ok && mSvc.IsMCPEnabled() {
|
|
mcpAuthEnabled = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Manual PRM override
|
|
var cachedPrmBytes []byte
|
|
var prmConfig ProtectedResourceMetadata
|
|
if s.mcpPrmFile != "" {
|
|
var err error
|
|
cachedPrmBytes, err = os.ReadFile(s.mcpPrmFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read manual PRM file at startup: %w", err)
|
|
}
|
|
// Unmarshal into the struct to strictly validate the schema
|
|
if err := json.Unmarshal(cachedPrmBytes, &prmConfig); err != nil {
|
|
return nil, fmt.Errorf("manual PRM file does not match expected schema: %w", err)
|
|
}
|
|
}
|
|
|
|
// Register route if auth is enabled or a manual file is provided
|
|
if mcpAuthEnabled || s.mcpPrmFile != "" {
|
|
r.Get("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, req *http.Request) {
|
|
// Serve from memory if file was loaded
|
|
if s.mcpPrmFile != "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write(cachedPrmBytes); err != nil {
|
|
s.logger.ErrorContext(req.Context(), "failed to write manual PRM file response", "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
prmHandler(s, w, req)
|
|
})
|
|
}
|
|
|
|
// control plane
|
|
mcpR, err := mcpRouter(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r.Mount("/mcp", mcpR)
|
|
if cfg.EnableAPI {
|
|
apiR, err := apiRouter(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.Mount("/api", apiR)
|
|
} else {
|
|
r.Handle("/api/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
err := errors.New("/api native endpoints are disabled by default. Please use the standard /mcp JSON-RPC endpoint")
|
|
_ = render.Render(w, r, newErrResponse(err, http.StatusGone))
|
|
}))
|
|
}
|
|
if cfg.UI {
|
|
webR, err := webRouter()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.Mount("/ui", webR)
|
|
}
|
|
// default endpoint for validating server is running
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("🧰 Hello, World! 🧰"))
|
|
})
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func mcpAuthMiddleware(s *Server) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Find McpEnabled auth service
|
|
var mcpSvc auth.MCPAuthService
|
|
for _, authSvc := range s.ResourceMgr.GetAuthServiceMap() {
|
|
if mSvc, ok := authSvc.(auth.MCPAuthService); ok && mSvc.IsMCPEnabled() {
|
|
mcpSvc = mSvc
|
|
break
|
|
}
|
|
}
|
|
|
|
// MCP Auth not enabled
|
|
if mcpSvc == nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
claims, err := mcpSvc.ValidateMCPAuth(r.Context(), r.Header)
|
|
if err != nil {
|
|
var mcpErr *auth.MCPAuthError
|
|
if errors.As(err, &mcpErr) {
|
|
switch mcpErr.Code {
|
|
case http.StatusUnauthorized:
|
|
scopesArg := ""
|
|
if len(mcpErr.ScopesRequired) > 0 {
|
|
scopesArg = fmt.Sprintf(`, scope="%s"`, strings.Join(mcpErr.ScopesRequired, " "))
|
|
}
|
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"%s`, s.toolboxUrl+"/.well-known/oauth-protected-resource", scopesArg))
|
|
render.Status(r, http.StatusUnauthorized)
|
|
render.JSON(w, r, jsonrpc.NewError(nil, jsonrpc.UNAUTHORIZED, mcpErr.Message, nil))
|
|
return
|
|
case http.StatusForbidden:
|
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"`, strings.Join(mcpErr.ScopesRequired, " "), s.toolboxUrl+"/.well-known/oauth-protected-resource", mcpErr.Message))
|
|
render.Status(r, http.StatusForbidden)
|
|
render.JSON(w, r, jsonrpc.NewError(nil, jsonrpc.FORBIDDEN, mcpErr.Message, nil))
|
|
return
|
|
}
|
|
}
|
|
// Fail closed on unexpected errors
|
|
s.logger.ErrorContext(r.Context(), "unexpected error during MCP auth validation", "error", err)
|
|
render.Status(r, http.StatusInternalServerError)
|
|
render.JSON(w, r, jsonrpc.NewError(nil, jsonrpc.INTERNAL_ERROR, "Internal Server Error", nil))
|
|
return
|
|
}
|
|
|
|
ctx := util.WithAuthTokenClaims(r.Context(), claims)
|
|
r = r.WithContext(ctx)
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Listen starts a listener for the given Server instance.
|
|
func (s *Server) Listen(ctx context.Context, certFile, keyFile string) error {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
if s.listener != nil {
|
|
return fmt.Errorf("server is already listening: %s", s.listener.Addr().String())
|
|
}
|
|
lc := net.ListenConfig{KeepAlive: 30 * time.Second}
|
|
ln, err := lc.Listen(ctx, "tcp", s.srv.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open listener for %q: %w", s.srv.Addr, err)
|
|
}
|
|
|
|
if certFile != "" || keyFile != "" {
|
|
// Load the certificates
|
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
|
if err != nil {
|
|
ln.Close()
|
|
return fmt.Errorf("failed to load TLS key pair (cert: %q, key: %q): %w", certFile, keyFile, err)
|
|
}
|
|
// Wrap the listener with TLS
|
|
config := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}
|
|
s.listener = tls.NewListener(ln, config)
|
|
s.logger.DebugContext(ctx, fmt.Sprintf("secure server listening on %s", s.srv.Addr))
|
|
} else {
|
|
s.listener = ln
|
|
s.logger.DebugContext(ctx, fmt.Sprintf("server listening on %s", s.srv.Addr))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Serve starts an HTTP server for the given Server instance.
|
|
func (s *Server) Serve(ctx context.Context) error {
|
|
s.logger.DebugContext(ctx, "Starting a HTTP server.")
|
|
return s.srv.Serve(s.listener)
|
|
}
|
|
|
|
// ServeStdio starts a new stdio session for mcp.
|
|
func (s *Server) ServeStdio(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
|
stdioServer := NewStdioSession(s, stdin, stdout)
|
|
return stdioServer.Start(ctx)
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the server without interrupting any active
|
|
// connections. It uses http.Server.Shutdown() and has the same functionality.
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
s.logger.DebugContext(ctx, "shutting down the server.")
|
|
return s.srv.Shutdown(ctx)
|
|
}
|
|
|
|
func (s *Server) Addr() string {
|
|
return s.listener.Addr().String()
|
|
}
|