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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,27 @@ import (

const startupMCPToolLoadTimeout = 1500 * time.Millisecond

var defaultRegistryLoadMCPTools = tool.LoadMCPTools
var (
defaultRegistryLoadMCPTools = tool.LoadMCPTools
defaultRegistryLoadRemoteMCPTools = tool.LoadRemoteMCPTools
)

type startupMCPServerSpec struct {
name string
command string
args []string

// Set only for non-stdio (remote) servers: serverType is "http", "sse",
// or "websocket", url is the server's endpoint, and headers carries any
// static headers from config plus an auto-injected OAuth bearer token
// if one is stored for this server (see internal/mcp/oauth.go).
serverType string
url string
headers map[string]string
}

func (s startupMCPServerSpec) isRemote() bool { return s.serverType != "" }

func essentialTools() []tool.Tool {
// Core tools needed for basic agent operation - always loaded at startup
return []tool.Tool{
Expand Down Expand Up @@ -105,14 +118,32 @@ func optionalTools() []tool.Tool {
func configuredStartupMCPServers(settings hawkconfig.Settings) []startupMCPServerSpec {
servers := make([]startupMCPServerSpec, 0, len(settings.MCPServers)+len(mcpServers))
for _, cfg := range settings.MCPServers {
if cfg.Name == "" || cfg.Command == "" {
if cfg.Name == "" {
continue
}
switch cfg.Type {
case "", "stdio":
if cfg.Command == "" {
continue
}
servers = append(servers, startupMCPServerSpec{
name: cfg.Name,
command: cfg.Command,
args: cfg.Args,
})
case "http", "sse", "websocket":
if cfg.URL == "" {
continue
}
servers = append(servers, startupMCPServerSpec{
name: cfg.Name,
serverType: cfg.Type,
url: cfg.URL,
headers: mergedMCPHeaders(cfg),
})
default:
continue
}
servers = append(servers, startupMCPServerSpec{
name: cfg.Name,
command: cfg.Command,
args: cfg.Args,
})
}
for _, cmd := range mcpServers {
parts := strings.Fields(cmd)
Expand All @@ -128,6 +159,24 @@ func configuredStartupMCPServers(settings hawkconfig.Settings) []startupMCPServe
return servers
}

// mergedMCPHeaders combines a remote MCP server's static configured headers
// with an auto-injected "Authorization: Bearer <token>" if a valid,
// non-expired OAuth token is stored for this server (see
// internal/tool/mcp_auth.go). A configured static Authorization header, if
// any, takes precedence over the auto-injected one.
func mergedMCPHeaders(cfg hawkconfig.MCPServerConfig) map[string]string {
headers := make(map[string]string, len(cfg.Headers)+1)
for k, v := range cfg.Headers {
headers[k] = v
}
if _, hasAuth := headers["Authorization"]; !hasAuth {
if bearer, ok := tool.AuthHeaderForMCPServer(cfg.Name); ok {
headers["Authorization"] = bearer
}
}
return headers
}

func loadStartupMCPToolSets(servers []startupMCPServerSpec) [][]tool.Tool {
results := make([][]tool.Tool, len(servers))
var wg sync.WaitGroup
Expand All @@ -138,7 +187,15 @@ func loadStartupMCPToolSets(servers []startupMCPServerSpec) [][]tool.Tool {
ctx, cancel := context.WithTimeout(context.Background(), startupMCPToolLoadTimeout)
defer cancel()

mcpTools, err := defaultRegistryLoadMCPTools(ctx, spec.name, spec.command, spec.args...)
var (
mcpTools []tool.Tool
err error
)
if spec.isRemote() {
mcpTools, err = defaultRegistryLoadRemoteMCPTools(ctx, spec.name, spec.serverType, spec.url, spec.headers)
} else {
mcpTools, err = defaultRegistryLoadMCPTools(ctx, spec.name, spec.command, spec.args...)
}
if err != nil {
return
}
Expand Down
118 changes: 118 additions & 0 deletions cmd/chat_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,124 @@ func TestLoadStartupMCPToolSets_UsesTimeoutAndPreservesOrder(t *testing.T) {
}
}

func TestConfiguredStartupMCPServers_DispatchesByType(t *testing.T) {
settings := hawkconfig.Settings{
MCPServers: []hawkconfig.MCPServerConfig{
{Name: "stdio-default", Command: "stdio-mcp"},
{Name: "stdio-explicit", Type: "stdio", Command: "stdio-mcp-2"},
{Name: "http-server", Type: "http", URL: "https://example.com/mcp"},
{Name: "sse-server", Type: "sse", URL: "https://example.com/sse", Headers: map[string]string{"X-Custom": "1"}},
{Name: "ws-server", Type: "websocket", URL: "wss://example.com/ws"},
// Invalid/incomplete entries must be skipped, not error.
{Name: "", Command: "no-name"},
{Name: "no-command"}, // stdio with no Command
{Name: "http-no-url", Type: "http"}, // http with no URL
{Name: "unknown-type", Type: "carrier-pigeon", URL: "x"},
},
}

specs := configuredStartupMCPServers(settings)

byName := make(map[string]startupMCPServerSpec, len(specs))
for _, s := range specs {
byName[s.name] = s
}
if len(specs) != 5 {
t.Fatalf("expected 5 valid specs, got %d: %+v", len(specs), specs)
}

stdioDefault, ok := byName["stdio-default"]
if !ok || stdioDefault.isRemote() || stdioDefault.command != "stdio-mcp" {
t.Fatalf("stdio-default spec wrong: %+v (ok=%v)", stdioDefault, ok)
}

httpServer, ok := byName["http-server"]
if !ok || !httpServer.isRemote() || httpServer.serverType != "http" || httpServer.url != "https://example.com/mcp" {
t.Fatalf("http-server spec wrong: %+v (ok=%v)", httpServer, ok)
}

sseServer, ok := byName["sse-server"]
if !ok || sseServer.serverType != "sse" || sseServer.headers["X-Custom"] != "1" {
t.Fatalf("sse-server spec wrong: %+v (ok=%v)", sseServer, ok)
}

wsServer, ok := byName["ws-server"]
if !ok || wsServer.serverType != "websocket" {
t.Fatalf("ws-server spec wrong: %+v (ok=%v)", wsServer, ok)
}

for _, missing := range []string{"", "no-command", "http-no-url", "unknown-type"} {
if _, found := byName[missing]; found {
t.Fatalf("expected %q to be skipped, but it was included", missing)
}
}
}

func TestLoadStartupMCPToolSets_DispatchesRemoteSpecsToRemoteLoader(t *testing.T) {
origStdio := defaultRegistryLoadMCPTools
origRemote := defaultRegistryLoadRemoteMCPTools
t.Cleanup(func() {
defaultRegistryLoadMCPTools = origStdio
defaultRegistryLoadRemoteMCPTools = origRemote
})

var stdioCalled, remoteCalled bool
var gotServerType, gotURL string
var gotHeaders map[string]string

defaultRegistryLoadMCPTools = func(ctx context.Context, name, command string, args ...string) ([]tool.Tool, error) {
stdioCalled = true
return []tool.Tool{registryTestTool{name: name}}, nil
}
defaultRegistryLoadRemoteMCPTools = func(ctx context.Context, name, serverType, url string, headers map[string]string) ([]tool.Tool, error) {
remoteCalled = true
gotServerType = serverType
gotURL = url
gotHeaders = headers
return []tool.Tool{registryTestTool{name: name}}, nil
}

sets := loadStartupMCPToolSets([]startupMCPServerSpec{
{name: "stdio-one", command: "cmd"},
{
name: "remote-one",
serverType: "http",
url: "https://example.com/mcp",
headers: map[string]string{"Authorization": "Bearer xyz"},
},
})

if !stdioCalled {
t.Error("expected the stdio loader to be called for the stdio spec")
}
if !remoteCalled {
t.Fatal("expected the remote loader to be called for the remote spec")
}
if gotServerType != "http" || gotURL != "https://example.com/mcp" {
t.Errorf("remote loader got serverType=%q url=%q", gotServerType, gotURL)
}
if gotHeaders["Authorization"] != "Bearer xyz" {
t.Errorf("remote loader did not receive configured headers: %+v", gotHeaders)
}
if len(sets) != 2 || len(sets[0]) != 1 || len(sets[1]) != 1 {
t.Fatalf("expected both specs to produce one tool each, got %+v", sets)
}
}

func TestMergedMCPHeaders_ConfiguredAuthorizationTakesPrecedence(t *testing.T) {
cfg := hawkconfig.MCPServerConfig{
Name: "svc",
Headers: map[string]string{"Authorization": "Bearer static-token", "X-Other": "1"},
}
headers := mergedMCPHeaders(cfg)
if headers["Authorization"] != "Bearer static-token" {
t.Errorf("expected static Authorization header to win, got %q", headers["Authorization"])
}
if headers["X-Other"] != "1" {
t.Errorf("expected other static headers to be preserved, got %+v", headers)
}
}

func TestDefaultRegistry_SkipsFailedStartupMCPServers(t *testing.T) {
orig := defaultRegistryLoadMCPTools
t.Cleanup(func() { defaultRegistryLoadMCPTools = orig })
Expand Down
8 changes: 4 additions & 4 deletions go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use .
replace (
github.com/GrayCodeAI/eyrie => ./external/eyrie
github.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts
github.com/GrayCodeAI/hawk-mcpkit => ./hawk-mcpkit
github.com/GrayCodeAI/inspect => ./external/inspect
github.com/GrayCodeAI/sight => ./external/sight
github.com/GrayCodeAI/tok => ./external/tok
Expand Down
10 changes: 7 additions & 3 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,13 @@ type MCPServerConfig struct {
Name string `json:"name"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Type string `json:"type,omitempty"` // "stdio" (default), "sse", "http"
URL string `json:"url,omitempty"` // for sse/http transports
Headers map[string]string `json:"headers,omitempty"` // custom headers for sse/http
Type string `json:"type,omitempty"` // "stdio" (default), "sse", "http", "websocket"
URL string `json:"url,omitempty"` // for sse/http/websocket transports
Headers map[string]string `json:"headers,omitempty"` // custom headers for sse/http/websocket
// ClientID is an OAuth client_id pre-registered with the server's
// authorization server. When empty, dynamic client registration (RFC
// 7591) is attempted if the server advertises a registration_endpoint.
ClientID string `json:"client_id,omitempty"`
}

func globalSettingsPath() string {
Expand Down
Loading
Loading