Skip to content

Commit fe58f64

Browse files
p10qharathom
andauthored
feat(remote): add headers-based authentication and cursor tool support (#12)
- Add support for headers-based auth via mcp.header.* labels as alternative to OAuth - Add cursor to list of tools supporting remote MCP servers - Support http:// URLs in addition to https:// for remote servers - Refactor ValidateRemoteServerOAuth to ValidateRemoteServerAuth supporting both auth methods - Add ExtractHeaders function with environment variable expansion - Update tests to reflect cursor support for remote servers Co-authored-by: Tom Harada <harathom@amazon.com>
1 parent a3c3447 commit fe58f64

4 files changed

Lines changed: 138 additions & 42 deletions

File tree

cmd/integration_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,9 @@ REMOTE_CLIENT_SECRET=test-client-secret
166166
t.Errorf("q-cli should support mixed servers: %v", err)
167167
}
168168

169-
// Should fail for unsupported tools
170-
if err := ValidateToolSupport("cursor", mixedServers); err == nil {
171-
t.Error("cursor should not support remote servers")
169+
// Should pass for cursor (now supported)
170+
if err := ValidateToolSupport("cursor", mixedServers); err != nil {
171+
t.Errorf("cursor should support remote servers: %v", err)
172172
}
173173

174174
// Should pass when no tool is specified

cmd/remote.go

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,45 +11,116 @@ import (
1111
"time"
1212
)
1313

14-
// IsRemoteServer detects if a service is a remote MCP server by checking if the command starts with https://
14+
// IsRemoteServer detects if a service is a remote MCP server by checking if the command starts with https:// or http://
1515
func IsRemoteServer(service Service) bool {
16-
return strings.HasPrefix(service.Command, "https://")
16+
return strings.HasPrefix(service.Command, "https://") || strings.HasPrefix(service.Command, "http://")
1717
}
1818

19-
// ValidateRemoteServerOAuth validates that a remote server has all required OAuth labels
20-
func ValidateRemoteServerOAuth(name string, service Service) error {
21-
requiredLabels := []string{
22-
"mcp.grant-type",
23-
"mcp.token-endpoint",
24-
"mcp.client-id",
25-
"mcp.client-secret",
19+
// UsesHeadersAuth checks if a remote server uses headers-based authentication instead of OAuth
20+
func UsesHeadersAuth(service Service) bool {
21+
// Check if any mcp.header.* labels exist
22+
for label := range service.Labels {
23+
if strings.HasPrefix(label, "mcp.header.") {
24+
return true
25+
}
2626
}
27+
return false
28+
}
2729

28-
var missingLabels []string
29-
for _, label := range requiredLabels {
30-
if _, exists := service.Labels[label]; !exists {
31-
missingLabels = append(missingLabels, label)
32-
}
30+
// ValidateRemoteServerAuth validates that a remote server has either OAuth or headers-based auth configured
31+
func ValidateRemoteServerAuth(name string, service Service) error {
32+
usesHeaders := UsesHeadersAuth(service)
33+
hasOAuthLabels := service.Labels["mcp.grant-type"] != ""
34+
35+
if !usesHeaders && !hasOAuthLabels {
36+
return fmt.Errorf("remote server '%s' must have either OAuth labels (mcp.grant-type, mcp.token-endpoint, mcp.client-id, mcp.client-secret) or headers labels (mcp.header.*)", name)
3337
}
3438

35-
if len(missingLabels) > 0 {
36-
return fmt.Errorf("remote server '%s' missing required OAuth labels: %s",
37-
name, strings.Join(missingLabels, ", "))
39+
if usesHeaders && hasOAuthLabels {
40+
return fmt.Errorf("remote server '%s' cannot have both OAuth labels and headers labels", name)
3841
}
3942

40-
// Validate grant type is client_credentials
41-
if grantType := service.Labels["mcp.grant-type"]; grantType != "client_credentials" {
42-
return fmt.Errorf("remote server '%s' must use 'client_credentials' grant type, got: %s",
43-
name, grantType)
43+
if hasOAuthLabels {
44+
// Validate OAuth configuration
45+
requiredLabels := []string{
46+
"mcp.grant-type",
47+
"mcp.token-endpoint",
48+
"mcp.client-id",
49+
"mcp.client-secret",
50+
}
51+
52+
var missingLabels []string
53+
for _, label := range requiredLabels {
54+
if _, exists := service.Labels[label]; !exists {
55+
missingLabels = append(missingLabels, label)
56+
}
57+
}
58+
59+
if len(missingLabels) > 0 {
60+
return fmt.Errorf("remote server '%s' missing required OAuth labels: %s",
61+
name, strings.Join(missingLabels, ", "))
62+
}
63+
64+
// Validate grant type is client_credentials
65+
if grantType := service.Labels["mcp.grant-type"]; grantType != "client_credentials" {
66+
return fmt.Errorf("remote server '%s' must use 'client_credentials' grant type, got: %s",
67+
name, grantType)
68+
}
4469
}
4570

4671
return nil
4772
}
4873

74+
// ValidateRemoteServerOAuth validates that a remote server has all required OAuth labels (deprecated, use ValidateRemoteServerAuth)
75+
func ValidateRemoteServerOAuth(name string, service Service) error {
76+
return ValidateRemoteServerAuth(name, service)
77+
}
78+
79+
// ExtractHeaders extracts headers from service labels (mcp.header.*) with environment variable expansion
80+
func ExtractHeaders(service Service, envVars map[string]string) (map[string]string, error) {
81+
headers := make(map[string]string)
82+
hasHeaders := false
83+
84+
for label, value := range service.Labels {
85+
if strings.HasPrefix(label, "mcp.header.") {
86+
hasHeaders = true
87+
// Extract header name (everything after "mcp.header.")
88+
headerName := strings.TrimPrefix(label, "mcp.header.")
89+
if headerName == "" {
90+
continue
91+
}
92+
93+
// Skip empty placeholder headers (e.g., "X-Empty: "" for servers that need no auth)
94+
if headerName == "X-Empty" && value == "" {
95+
continue
96+
}
97+
98+
// Expand environment variables in header value
99+
expandedValue := expandEnvVars(value, envVars)
100+
101+
// Validate that environment variables were resolved
102+
if strings.Contains(expandedValue, "${") || (strings.Contains(expandedValue, "$") && !strings.HasPrefix(expandedValue, "$")) {
103+
return nil, fmt.Errorf("environment variable in header '%s' was not resolved: %s", headerName, expandedValue)
104+
}
105+
106+
headers[headerName] = expandedValue
107+
}
108+
}
109+
110+
// Return headers map (can be empty for servers with no authentication)
111+
// If no mcp.header.* labels exist at all, that's an error (use OAuth or headers)
112+
if !hasHeaders {
113+
return nil, fmt.Errorf("no headers found (expected mcp.header.* labels)")
114+
}
115+
116+
return headers, nil
117+
}
118+
49119
// remoteSupportedTools defines which tools support remote MCP servers
50120
var remoteSupportedTools = map[string]bool{
51-
"kiro": true,
52-
"q-cli": true,
121+
"cursor": true,
122+
"kiro": true,
123+
"q-cli": true,
53124
}
54125

55126
// ValidateToolSupport validates that the specified tool supports remote servers if any are present

cmd/set.go

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ If no profile is specified, it uses default servers.`,
6161
}
6262
}
6363

64-
// Validate remote servers have required OAuth labels
64+
// Validate remote servers have required auth configuration (OAuth or headers)
6565
for name, service := range servers {
6666
if IsRemoteServer(service) {
67-
if err := ValidateRemoteServerOAuth(name, service); err != nil {
67+
if err := ValidateRemoteServerAuth(name, service); err != nil {
6868
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
6969
os.Exit(1)
7070
}
@@ -165,22 +165,47 @@ func convertToMCPConfig(servers map[string]Service, envVars map[string]string) M
165165
mcpServer.Type = "http"
166166
mcpServer.URL = service.Command
167167

168-
// Acquire OAuth access token for remote servers
169-
oauthConfig, err := ExtractOAuthConfig(service, envVars)
170-
if err != nil {
171-
fmt.Fprintf(os.Stderr, "Error extracting OAuth config for '%s': %v\n", name, err)
172-
os.Exit(1)
168+
// Merge service environment variables into envVars for expansion
169+
serviceEnvVars := make(map[string]string)
170+
for k, v := range envVars {
171+
serviceEnvVars[k] = v
173172
}
174-
175-
accessToken, err := AcquireAccessTokenWithFeedback(name, oauthConfig)
176-
if err != nil {
177-
fmt.Fprintf(os.Stderr, "Failed to acquire access token for '%s': %v\n", name, err)
178-
os.Exit(1)
173+
// Add service-specific environment variables (with expansion)
174+
for key, value := range service.Environment {
175+
expandedValue := expandEnvVars(value, envVars)
176+
serviceEnvVars[key] = expandedValue
179177
}
180178

181-
// Set Authorization header with Bearer token
182-
mcpServer.Headers = map[string]string{
183-
"Authorization": fmt.Sprintf("Bearer %s", accessToken),
179+
if UsesHeadersAuth(service) {
180+
// Headers-based authentication
181+
headers, err := ExtractHeaders(service, serviceEnvVars)
182+
if err != nil {
183+
fmt.Fprintf(os.Stderr, "Error extracting headers for '%s': %v\n", name, err)
184+
os.Exit(1)
185+
}
186+
// Always set headers, even if empty (for servers with no auth)
187+
if headers == nil {
188+
headers = make(map[string]string)
189+
}
190+
mcpServer.Headers = headers
191+
} else {
192+
// OAuth-based authentication
193+
oauthConfig, err := ExtractOAuthConfig(service, serviceEnvVars)
194+
if err != nil {
195+
fmt.Fprintf(os.Stderr, "Error extracting OAuth config for '%s': %v\n", name, err)
196+
os.Exit(1)
197+
}
198+
199+
accessToken, err := AcquireAccessTokenWithFeedback(name, oauthConfig)
200+
if err != nil {
201+
fmt.Fprintf(os.Stderr, "Failed to acquire access token for '%s': %v\n", name, err)
202+
os.Exit(1)
203+
}
204+
205+
// Set Authorization header with Bearer token
206+
mcpServer.Headers = map[string]string{
207+
"Authorization": fmt.Sprintf("Bearer %s", accessToken),
208+
}
184209
}
185210
} else if service.Image != "" {
186211
// Container-based server

cmd/tool_shortcuts_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func TestToolCompatibilityWithRemoteServers(t *testing.T) {
145145
}
146146

147147
// Test unsupported tools
148-
unsupportedTools := []string{"cursor", "claude-desktop"}
148+
unsupportedTools := []string{"claude-desktop"}
149149
for _, tool := range unsupportedTools {
150150
err := ValidateToolSupport(tool, mixedServers)
151151
if err == nil {

0 commit comments

Comments
 (0)