mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
7f366eab84
* cmd: add --no-skills, --skill, and --skills-dir CLI flags
The pkg/kit Options struct already had full backend support for skills
control (NoSkills, Skills []string, SkillsDir) wired into loadSkills()
in pkg/kit/kit.go, but there were no corresponding CLI flags to drive
them. This commit closes that gap.
Changes in cmd/root.go:
- Add three package-level flag variables alongside the existing
noExtensionsFlag/extensionPaths group:
noSkillsFlag bool
skillsPaths []string
skillsDir string
- Register three persistent cobra flags in init():
--no-skills disable skill loading (auto-discovery and explicit)
--skill <path> load a skill file or directory (repeatable)
--skills-dir <dir> override the project-local skills directory
used for auto-discovery
- Wire all three into the kitOpts struct literal in runNormalMode()
so they flow directly into kit.New() -> loadSkills().
No changes to pkg/kit or internal/skills -- the backend was already
complete. No viper binding is needed because kit.go reads these fields
directly from opts rather than from viper (unlike NoExtensions which
uses the viper fallback path).
Example usage:
kit --no-skills "prompt"
kit --skill ./my-skill.md --skill ./other-skill.md "prompt"
kit --skills-dir /path/to/skills "prompt"
Co-authored-by: Claude <claude@anthropic.com>
* docs: document --no-skills, --skill, and --skills-dir CLI flags
Add the three new skills CLI flags to all relevant documentation:
- README.md: add Skills section under Global Flags CLI reference
- www/pages/cli/flags.md: add Skills table (mirrors Extensions section pattern)
- www/pages/cli/commands.md: expand the Skills section with usage examples
and a description of auto-discovery vs explicit loading vs --no-skills
Co-authored-by: Claude <claude@anthropic.com>
* feat: add config file support for skills options
Skills could previously only be controlled via CLI flags or SDK Options
fields. This commit wires all three skills settings into viper so they
can also be set in .kit.yml / .kit.yaml / .kit.json and via KIT_*
environment variables — matching the pattern used by no-extensions,
no-core-tools, and prompt-template.
cmd/root.go:
- Bind --no-skills, --skill, and --skills-dir flags to viper keys
(no-skills, skill, skills-dir) so config file values flow through.
pkg/kit/kit.go:
- At skill-load time, merge opts fields with viper values:
- noSkills = opts.NoSkills || v.GetBool("no-skills")
- skillPaths: opts.Skills if non-empty, else v.GetStringSlice("skill")
- skillsDir: opts.SkillsDir if non-empty, else v.GetString("skills-dir")
- Build a shallow-copied mergedOpts so loadSkills() picks up the
resolved values without mutating the original Options struct.
docs:
- README.md: add skills keys to the Basic Configuration YAML example
- www/pages/configuration.md: add no-skills, skill, skills-dir rows to
the All configuration keys table
Config file example (.kit.yml):
no-skills: false
skill:
- /path/to/skill.md
skills-dir: /path/to/skills/
Co-authored-by: Claude <claude@anthropic.com>
* config: add skills keys to default .kit.yml template
Add no-skills, skill, and skills-dir as commented-out examples in the
default config file generated by EnsureConfigExists(), alongside the
existing application settings block.
Co-authored-by: Claude <claude@anthropic.com>
* test: add test coverage for skills CLI flags and config keys
Four test locations updated:
pkg/kit/export_test.go:
- Add ConfigStringSliceForTest() helper to expose v.GetStringSlice()
from the Kit's isolated viper store, needed to assert skill list values.
pkg/kit/kit_test.go (TestNewWithSkillsOptions):
- NoSkills=true: GetSkills() returns empty slice
- SkillsDir=<empty dir>: kit.New() succeeds with zero skills
- Skills=[file]: single explicit skill file is loaded and name parsed correctly
pkg/kit/viper_isolation_test.go:
- TestSkillsViperKeys: no-API-key struct-level checks for NoSkills, Skills,
and SkillsDir fields on Options
- TestSkillsConfigFileKeys: full kit.New() round-trips via a written .kit.yml
for each of the three config keys:
no-skills: true → GetSkills() returns empty
skill: [path] → named skill loaded from config file path
skills-dir: dir → custom discovery root accepted without error
internal/config/config_test.go (TestEnsureConfigExists):
- Assert generated ~/.kit.yml template contains '# Skills configuration',
'no-skills:', and 'skills-dir:' comment blocks.
Co-authored-by: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
722 lines
20 KiB
Go
722 lines
20 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestMCPServerConfig_NewFormat(t *testing.T) {
|
|
// Test new simplified format
|
|
jsonData := `{
|
|
"type": "local",
|
|
"command": ["bun", "x", "my-mcp-command"],
|
|
"environment": {
|
|
"MY_ENV_VAR": "my_env_var_value"
|
|
}
|
|
}`
|
|
|
|
var config MCPServerConfig
|
|
err := json.Unmarshal([]byte(jsonData), &config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal new format: %v", err)
|
|
}
|
|
|
|
if config.Type != "local" {
|
|
t.Errorf("Expected type 'local', got '%s'", config.Type)
|
|
}
|
|
|
|
if len(config.Command) != 3 {
|
|
t.Errorf("Expected 3 command parts, got %d", len(config.Command))
|
|
}
|
|
|
|
if config.Command[0] != "bun" || config.Command[1] != "x" || config.Command[2] != "my-mcp-command" {
|
|
t.Errorf("Command parts incorrect: %v", config.Command)
|
|
}
|
|
|
|
if config.Environment["MY_ENV_VAR"] != "my_env_var_value" {
|
|
t.Errorf("Environment variable not set correctly")
|
|
}
|
|
|
|
// Test transport type detection
|
|
transportType := config.GetTransportType()
|
|
if transportType != "stdio" {
|
|
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_RemoteFormat(t *testing.T) {
|
|
// Test remote format
|
|
jsonData := `{
|
|
"type": "remote",
|
|
"url": "https://my-mcp-server.com"
|
|
}`
|
|
|
|
var config MCPServerConfig
|
|
err := json.Unmarshal([]byte(jsonData), &config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal remote format: %v", err)
|
|
}
|
|
|
|
if config.Type != "remote" {
|
|
t.Errorf("Expected type 'remote', got '%s'", config.Type)
|
|
}
|
|
|
|
if config.URL != "https://my-mcp-server.com" {
|
|
t.Errorf("Expected URL 'https://my-mcp-server.com', got '%s'", config.URL)
|
|
}
|
|
|
|
// Test transport type detection
|
|
transportType := config.GetTransportType()
|
|
if transportType != "streamable" {
|
|
t.Errorf("Expected transport type 'streamable', got '%s'", transportType)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_LegacyFormat(t *testing.T) {
|
|
// Test legacy format still works
|
|
jsonData := `{
|
|
"command": "npx",
|
|
"args": ["@modelcontextprotocol/server-filesystem", "/path"],
|
|
"env": {
|
|
"MY_VAR": "value"
|
|
}
|
|
}`
|
|
|
|
var config MCPServerConfig
|
|
err := json.Unmarshal([]byte(jsonData), &config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal legacy format: %v", err)
|
|
}
|
|
|
|
// Verify Type field is now set correctly for legacy format
|
|
if config.Type != "local" {
|
|
t.Errorf("Expected type 'local' for legacy command format, got '%s'", config.Type)
|
|
}
|
|
|
|
if len(config.Command) != 3 {
|
|
t.Errorf("Expected 3 command parts, got %d", len(config.Command))
|
|
}
|
|
|
|
if config.Command[0] != "npx" || config.Command[1] != "@modelcontextprotocol/server-filesystem" || config.Command[2] != "/path" {
|
|
t.Errorf("Command parts incorrect: %v", config.Command)
|
|
}
|
|
|
|
if config.Env["MY_VAR"] != "value" {
|
|
t.Errorf("Legacy environment variable not set correctly")
|
|
}
|
|
|
|
// Test transport type detection
|
|
transportType := config.GetTransportType()
|
|
if transportType != "stdio" {
|
|
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_LocalFormat(t *testing.T) {
|
|
jsonData := `{
|
|
"type": "local",
|
|
"command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
}`
|
|
|
|
var config MCPServerConfig
|
|
err := json.Unmarshal([]byte(jsonData), &config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal local format: %v", err)
|
|
}
|
|
|
|
if config.Type != "local" {
|
|
t.Errorf("Expected type 'local', got '%s'", config.Type)
|
|
}
|
|
|
|
transportType := config.GetTransportType()
|
|
if transportType != "stdio" {
|
|
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
|
|
}
|
|
}
|
|
|
|
func TestConfig_Validate(t *testing.T) {
|
|
config := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"local-server": {
|
|
Type: "local",
|
|
Command: []string{"echo", "hello"},
|
|
},
|
|
"remote-server": {
|
|
Type: "remote",
|
|
URL: "https://example.com",
|
|
},
|
|
"another-local": {
|
|
Type: "local",
|
|
Command: []string{"echo", "world"},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := config.Validate()
|
|
if err != nil {
|
|
t.Errorf("Validation failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureConfigExists(t *testing.T) {
|
|
// Create a temporary directory for testing
|
|
tempDir, err := os.MkdirTemp("", "kit_config_test")
|
|
if err != nil {
|
|
t.Fatalf("Error creating temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
// Set HOME to temp directory
|
|
oldHome := os.Getenv("HOME")
|
|
_ = os.Setenv("HOME", tempDir)
|
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
|
|
|
// Test config creation
|
|
err = EnsureConfigExists()
|
|
if err != nil {
|
|
t.Fatalf("Error creating config: %v", err)
|
|
}
|
|
|
|
// Verify the config file was created
|
|
configPath := filepath.Join(tempDir, ".kit.yml")
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
t.Fatalf("Config file was not created at %s", configPath)
|
|
}
|
|
|
|
// Read and verify the content
|
|
content, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Error reading config: %v", err)
|
|
}
|
|
|
|
contentStr := string(content)
|
|
|
|
// Verify it contains the expected sections
|
|
expectedSections := []string{
|
|
"# KIT Configuration File",
|
|
"mcpServers:",
|
|
"# Local MCP servers",
|
|
"# Remote MCP servers",
|
|
"type: \"local\"",
|
|
"type: \"remote\"",
|
|
"Core tools",
|
|
"# Skills configuration",
|
|
"no-skills:",
|
|
"skills-dir:",
|
|
}
|
|
|
|
for _, expected := range expectedSections {
|
|
if !strings.Contains(contentStr, expected) {
|
|
t.Errorf("Config content missing expected section: %s", expected)
|
|
}
|
|
}
|
|
|
|
// Verify it's valid YAML structure (basic check)
|
|
if !strings.Contains(contentStr, "mcpServers:") {
|
|
t.Error("Config should contain mcpServers section")
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_LegacyFormatTypeInference(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
jsonData string
|
|
expectedType string
|
|
expectedTransport string
|
|
}{
|
|
{
|
|
name: "Legacy command format should infer local type",
|
|
jsonData: `{
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
}`,
|
|
expectedType: "local",
|
|
expectedTransport: "stdio",
|
|
},
|
|
{
|
|
name: "Legacy URL format should preserve legacy behavior",
|
|
jsonData: `{
|
|
"url": "https://api.example.com/mcp"
|
|
}`,
|
|
expectedType: "", // Don't set Type to preserve legacy transport behavior
|
|
expectedTransport: "sse",
|
|
},
|
|
{
|
|
name: "Legacy format with explicit transport should still infer type",
|
|
jsonData: `{
|
|
"command": "python",
|
|
"args": ["-m", "my_mcp_server"],
|
|
"transport": "stdio"
|
|
}`,
|
|
expectedType: "local",
|
|
expectedTransport: "stdio",
|
|
},
|
|
{
|
|
name: "Legacy format with URL and explicit transport should preserve explicit transport",
|
|
jsonData: `{
|
|
"url": "https://remote-server.com",
|
|
"transport": "sse"
|
|
}`,
|
|
expectedType: "", // Don't set Type to preserve legacy behavior
|
|
expectedTransport: "sse",
|
|
},
|
|
{
|
|
name: "Empty legacy format should not set type",
|
|
jsonData: `{
|
|
"env": {"VAR": "value"}
|
|
}`,
|
|
expectedType: "",
|
|
expectedTransport: "stdio",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var config MCPServerConfig
|
|
err := json.Unmarshal([]byte(tt.jsonData), &config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if config.Type != tt.expectedType {
|
|
t.Errorf("Expected type '%s', got '%s'", tt.expectedType, config.Type)
|
|
}
|
|
|
|
transportType := config.GetTransportType()
|
|
if transportType != tt.expectedTransport {
|
|
t.Errorf("Expected transport type '%s', got '%s'", tt.expectedTransport, transportType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIssue76_ExactReproduction(t *testing.T) {
|
|
// Test the exact config from GitHub issue #76
|
|
jsonData := `{
|
|
"mcpServers": {
|
|
"filesystem": {
|
|
"command": "npx",
|
|
"args": [
|
|
"-y",
|
|
"@modelcontextprotocol/server-filesystem",
|
|
"C:\\test"
|
|
]
|
|
}
|
|
}
|
|
}`
|
|
|
|
var cfg Config
|
|
err := json.Unmarshal([]byte(jsonData), &cfg)
|
|
if err != nil {
|
|
t.Fatalf("Error unmarshaling config: %v", err)
|
|
}
|
|
|
|
// Verify the server config was parsed correctly
|
|
if len(cfg.MCPServers) != 1 {
|
|
t.Fatalf("Expected 1 server, got %d", len(cfg.MCPServers))
|
|
}
|
|
|
|
serverConfig, exists := cfg.MCPServers["filesystem"]
|
|
if !exists {
|
|
t.Fatal("Expected 'filesystem' server to exist")
|
|
}
|
|
|
|
// Verify Type field is now set correctly
|
|
if serverConfig.Type != "local" {
|
|
t.Errorf("Expected type 'local', got '%s'", serverConfig.Type)
|
|
}
|
|
|
|
// Verify command was parsed correctly
|
|
expectedCommand := []string{"npx", "-y", "@modelcontextprotocol/server-filesystem", "C:\\test"}
|
|
if len(serverConfig.Command) != len(expectedCommand) {
|
|
t.Errorf("Expected %d command parts, got %d", len(expectedCommand), len(serverConfig.Command))
|
|
}
|
|
for i, expected := range expectedCommand {
|
|
if i >= len(serverConfig.Command) || serverConfig.Command[i] != expected {
|
|
t.Errorf("Command part %d: expected '%s', got '%s'", i, expected, serverConfig.Command[i])
|
|
}
|
|
}
|
|
|
|
// Verify transport type detection works
|
|
transportType := serverConfig.GetTransportType()
|
|
if transportType != "stdio" {
|
|
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
|
|
}
|
|
|
|
// Most importantly, verify validation passes
|
|
err = cfg.Validate()
|
|
if err != nil {
|
|
t.Errorf("Validation should pass but failed with: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_RemoteFormatWithHeaders(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
jsonData string
|
|
expectedType string
|
|
expectedURL string
|
|
expectedHeaders []string
|
|
expectedTransport string
|
|
}{
|
|
{
|
|
name: "Remote server with headers",
|
|
jsonData: `{
|
|
"type": "remote",
|
|
"url": "https://api.example.com/mcp",
|
|
"headers": ["Authorization: Bearer token123", "X-API-Key: key456"]
|
|
}`,
|
|
expectedType: "remote",
|
|
expectedURL: "https://api.example.com/mcp",
|
|
expectedHeaders: []string{"Authorization: Bearer token123", "X-API-Key: key456"},
|
|
expectedTransport: "streamable",
|
|
},
|
|
{
|
|
name: "Remote server without headers",
|
|
jsonData: `{
|
|
"type": "remote",
|
|
"url": "https://api.example.com/mcp"
|
|
}`,
|
|
expectedType: "remote",
|
|
expectedURL: "https://api.example.com/mcp",
|
|
expectedHeaders: nil,
|
|
expectedTransport: "streamable",
|
|
},
|
|
{
|
|
name: "Legacy remote server with headers",
|
|
jsonData: `{
|
|
"url": "https://legacy.example.com/mcp",
|
|
"headers": ["Content-Type: application/json", "User-Agent: KIT/1.0"]
|
|
}`,
|
|
expectedType: "",
|
|
expectedURL: "https://legacy.example.com/mcp",
|
|
expectedHeaders: []string{"Content-Type: application/json", "User-Agent: KIT/1.0"},
|
|
expectedTransport: "sse",
|
|
},
|
|
{
|
|
name: "Legacy remote server with explicit transport and headers",
|
|
jsonData: `{
|
|
"url": "https://legacy.example.com/mcp",
|
|
"transport": "sse",
|
|
"headers": ["Authorization: Basic dXNlcjpwYXNz"]
|
|
}`,
|
|
expectedType: "",
|
|
expectedURL: "https://legacy.example.com/mcp",
|
|
expectedHeaders: []string{"Authorization: Basic dXNlcjpwYXNz"},
|
|
expectedTransport: "sse",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var config MCPServerConfig
|
|
err := json.Unmarshal([]byte(tt.jsonData), &config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if config.Type != tt.expectedType {
|
|
t.Errorf("Expected type '%s', got '%s'", tt.expectedType, config.Type)
|
|
}
|
|
|
|
if config.URL != tt.expectedURL {
|
|
t.Errorf("Expected URL '%s', got '%s'", tt.expectedURL, config.URL)
|
|
}
|
|
|
|
if len(config.Headers) != len(tt.expectedHeaders) {
|
|
t.Errorf("Expected %d headers, got %d", len(tt.expectedHeaders), len(config.Headers))
|
|
}
|
|
|
|
for i, expectedHeader := range tt.expectedHeaders {
|
|
if i >= len(config.Headers) || config.Headers[i] != expectedHeader {
|
|
t.Errorf("Header %d: expected '%s', got '%s'", i, expectedHeader, config.Headers[i])
|
|
}
|
|
}
|
|
|
|
transportType := config.GetTransportType()
|
|
if transportType != tt.expectedTransport {
|
|
t.Errorf("Expected transport type '%s', got '%s'", tt.expectedTransport, transportType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_HeadersParsing(t *testing.T) {
|
|
// Test that headers are properly parsed in both new and legacy formats
|
|
tests := []struct {
|
|
name string
|
|
jsonData string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "New format with multiple headers",
|
|
jsonData: `{
|
|
"type": "remote",
|
|
"url": "https://api.example.com",
|
|
"headers": [
|
|
"Authorization: Bearer abc123",
|
|
"Content-Type: application/json",
|
|
"X-Custom-Header: custom-value"
|
|
]
|
|
}`,
|
|
expected: []string{
|
|
"Authorization: Bearer abc123",
|
|
"Content-Type: application/json",
|
|
"X-Custom-Header: custom-value",
|
|
},
|
|
},
|
|
{
|
|
name: "Legacy format with headers",
|
|
jsonData: `{
|
|
"url": "https://legacy.example.com",
|
|
"headers": ["API-Key: secret123", "Accept: application/json"]
|
|
}`,
|
|
expected: []string{"API-Key: secret123", "Accept: application/json"},
|
|
},
|
|
{
|
|
name: "Empty headers array",
|
|
jsonData: `{
|
|
"type": "remote",
|
|
"url": "https://api.example.com",
|
|
"headers": []
|
|
}`,
|
|
expected: []string{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var config MCPServerConfig
|
|
err := json.Unmarshal([]byte(tt.jsonData), &config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if len(config.Headers) != len(tt.expected) {
|
|
t.Errorf("Expected %d headers, got %d", len(tt.expected), len(config.Headers))
|
|
}
|
|
|
|
for i, expected := range tt.expected {
|
|
if i >= len(config.Headers) || config.Headers[i] != expected {
|
|
t.Errorf("Header %d: expected '%s', got '%s'", i, expected, config.Headers[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureConfigExistsWhenFileExists(t *testing.T) {
|
|
// Create a temporary directory for testing
|
|
tempDir, err := os.MkdirTemp("", "kit_config_test")
|
|
if err != nil {
|
|
t.Fatalf("Error creating temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
// Set HOME to temp directory
|
|
oldHome := os.Getenv("HOME")
|
|
_ = os.Setenv("HOME", tempDir)
|
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
|
|
|
// Create an existing config file
|
|
configPath := filepath.Join(tempDir, ".kit.yml")
|
|
existingContent := "# Existing config\nmcpServers:\n test: {}\n"
|
|
err = os.WriteFile(configPath, []byte(existingContent), 0644)
|
|
if err != nil {
|
|
t.Fatalf("Error creating existing config: %v", err)
|
|
}
|
|
|
|
// Test that EnsureConfigExists doesn't overwrite
|
|
err = EnsureConfigExists()
|
|
if err != nil {
|
|
t.Fatalf("Error in EnsureConfigExists: %v", err)
|
|
}
|
|
|
|
// Verify the content wasn't changed
|
|
content, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Error reading config: %v", err)
|
|
}
|
|
|
|
if string(content) != existingContent {
|
|
t.Error("Existing config file was modified when it shouldn't have been")
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_OAuthFields_JSON(t *testing.T) {
|
|
jsonData := `{
|
|
"type": "remote",
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"oauthClientId": "Ov23liXXXXXXXXXXXXXX",
|
|
"oauthClientSecret": "secret123",
|
|
"oauthScopes": ["read:user", "repo"]
|
|
}`
|
|
|
|
var cfg MCPServerConfig
|
|
err := json.Unmarshal([]byte(jsonData), &cfg)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if cfg.Type != "remote" {
|
|
t.Errorf("Expected type 'remote', got %q", cfg.Type)
|
|
}
|
|
if cfg.URL != "https://api.githubcopilot.com/mcp/" {
|
|
t.Errorf("Expected URL, got %q", cfg.URL)
|
|
}
|
|
if cfg.OAuthClientID != "Ov23liXXXXXXXXXXXXXX" {
|
|
t.Errorf("Expected OAuthClientID 'Ov23liXXXXXXXXXXXXXX', got %q", cfg.OAuthClientID)
|
|
}
|
|
if cfg.OAuthClientSecret != "secret123" {
|
|
t.Errorf("Expected OAuthClientSecret 'secret123', got %q", cfg.OAuthClientSecret)
|
|
}
|
|
if len(cfg.OAuthScopes) != 2 || cfg.OAuthScopes[0] != "read:user" || cfg.OAuthScopes[1] != "repo" {
|
|
t.Errorf("Expected OAuthScopes [read:user, repo], got %v", cfg.OAuthScopes)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_OAuthFields_YAML(t *testing.T) {
|
|
yamlData := `
|
|
type: remote
|
|
url: https://api.githubcopilot.com/mcp/
|
|
oauthClientId: "Ov23liXXXXXXXXXXXXXX"
|
|
oauthScopes:
|
|
- read:user
|
|
- repo
|
|
`
|
|
|
|
var cfg MCPServerConfig
|
|
err := yaml.Unmarshal([]byte(yamlData), &cfg)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal YAML: %v", err)
|
|
}
|
|
|
|
if cfg.Type != "remote" {
|
|
t.Errorf("Expected type 'remote', got %q", cfg.Type)
|
|
}
|
|
if cfg.OAuthClientID != "Ov23liXXXXXXXXXXXXXX" {
|
|
t.Errorf("Expected OAuthClientID 'Ov23liXXXXXXXXXXXXXX', got %q", cfg.OAuthClientID)
|
|
}
|
|
if len(cfg.OAuthScopes) != 2 || cfg.OAuthScopes[0] != "read:user" || cfg.OAuthScopes[1] != "repo" {
|
|
t.Errorf("Expected OAuthScopes [read:user, repo], got %v", cfg.OAuthScopes)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_OAuthFields_Omitted(t *testing.T) {
|
|
// Verify that omitting OAuth fields still works (backward compat).
|
|
jsonData := `{
|
|
"type": "remote",
|
|
"url": "https://example.com/mcp"
|
|
}`
|
|
|
|
var cfg MCPServerConfig
|
|
err := json.Unmarshal([]byte(jsonData), &cfg)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if cfg.OAuthClientID != "" {
|
|
t.Errorf("Expected empty OAuthClientID, got %q", cfg.OAuthClientID)
|
|
}
|
|
if cfg.OAuthClientSecret != "" {
|
|
t.Errorf("Expected empty OAuthClientSecret, got %q", cfg.OAuthClientSecret)
|
|
}
|
|
if len(cfg.OAuthScopes) != 0 {
|
|
t.Errorf("Expected empty OAuthScopes, got %v", cfg.OAuthScopes)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_TasksMode_NewFormat(t *testing.T) {
|
|
jsonData := `{
|
|
"type": "remote",
|
|
"url": "https://my-mcp-server.com",
|
|
"tasksMode": "always"
|
|
}`
|
|
var cfg MCPServerConfig
|
|
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
if cfg.TasksMode != "always" {
|
|
t.Errorf("expected TasksMode 'always', got %q", cfg.TasksMode)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_TasksMode_LegacyFormat(t *testing.T) {
|
|
// tasksMode also recognised in the legacy unmarshal path so users on
|
|
// the older command/args shape can opt in without migrating.
|
|
jsonData := `{
|
|
"command": "npx",
|
|
"args": ["@modelcontextprotocol/server-filesystem", "/path"],
|
|
"tasksMode": "never"
|
|
}`
|
|
var cfg MCPServerConfig
|
|
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
if cfg.TasksMode != "never" {
|
|
t.Errorf("expected TasksMode 'never', got %q", cfg.TasksMode)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfig_TasksMode_DefaultEmpty(t *testing.T) {
|
|
// When tasksMode is not set the field stays empty, which downstream
|
|
// resolves to "auto" via tools.ParseTaskMode.
|
|
jsonData := `{"type":"remote","url":"https://x.example"}`
|
|
var cfg MCPServerConfig
|
|
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
if cfg.TasksMode != "" {
|
|
t.Errorf("expected default TasksMode to be empty, got %q", cfg.TasksMode)
|
|
}
|
|
}
|
|
|
|
func TestConfig_Validate_TasksMode(t *testing.T) {
|
|
t.Run("empty is valid", func(t *testing.T) {
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"a": {Type: "remote", URL: "https://x.example"},
|
|
},
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Errorf("empty TasksMode should validate, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("known values are valid", func(t *testing.T) {
|
|
for _, mode := range []string{"auto", "never", "always", "AUTO", " always "} {
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"a": {Type: "remote", URL: "https://x.example", TasksMode: mode},
|
|
},
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Errorf("TasksMode=%q should validate, got %v", mode, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("typo is rejected with a clear error", func(t *testing.T) {
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"buildbot": {Type: "remote", URL: "https://x.example", TasksMode: "alwasy"},
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("expected validation error for invalid TasksMode")
|
|
}
|
|
// Error must mention the server name AND the bad value so the
|
|
// user knows where to look.
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "buildbot") || !strings.Contains(msg, `"alwasy"`) {
|
|
t.Errorf("error %q should mention both server name and bad value", msg)
|
|
}
|
|
})
|
|
}
|