Skip to content

Commit b55053e

Browse files
josegironnclaude
andauthored
Add CLI MCP servers and Claude Code plugin infrastructure (#87)
* feat: add hidden 'major user token' command * feat: add hidden 'major org id' command * feat: add resource_api_url to CLI config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add hidden 'major mcp check-readonly' command * feat: add headersHelper MCP config and auto-approve hooks to plugin - get-headers.sh outputs Bearer token + org ID for MCP auth - .mcp.json configures major-resources and major-platform MCP servers - hooks.json auto-approves read-only resource MCP tools via PreToolUse hook - check-readonly.sh strips MCP prefix and delegates to CLI command Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * deprecate: mark GenerateMcpConfig as deprecated The major Claude Code plugin now provides org-level MCP servers via headersHelper. The old per-project .mcp.json generation is kept for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: headersHelper and plugin fixes from testing - Use fmt.Fprint(os.Stdout) instead of cmd.Print for pipe-capturable output - Use inline bash -c for headersHelper (CLAUDE_PLUGIN_ROOT doesn't resolve in .mcp.json) - Rename hooks/hooks.json to hooks/auto-approve.json (standard path auto-loaded by convention causes duplicate) - Use flat .mcp.json format (no mcpServers wrapper) for plugin config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: auto approve calling the wrong url locally * feat: add app list command and persistent environment switching Add hidden 'major app list' for agent discovery of applications (JSON output). Add hidden 'major config set-env/get-env' to persist environment selection (local/staging/prod) across CLI invocations via ~/.major/env. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update plugin MCP URLs to production endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5fbba05 commit b55053e

26 files changed

Lines changed: 2461 additions & 3 deletions

File tree

clients/config/config.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
// Config represents the application configuration
1111
type Config struct {
1212
APIURL string `mapstructure:"api_url"`
13+
ResourceAPIURL string `mapstructure:"resource_api_url"`
1314
FrontendURI string `mapstructure:"frontend_uri"`
1415
AppURLSuffix string `mapstructure:"app_url_suffix"`
1516
AppURLFEOnlySuffix string `mapstructure:"app_url_fe_only_suffix"`
@@ -25,9 +26,12 @@ func Load(configFile string) (*Config, error) {
2526
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
2627

2728
var configData []byte
28-
if configFile == "configs/prod.json" {
29+
switch configFile {
30+
case "configs/prod.json":
2931
configData = configs.ProdConfig
30-
} else {
32+
case "configs/staging.json":
33+
configData = configs.StagingConfig
34+
default:
3135
configData = configs.LocalConfig
3236
}
3337

cmd/app/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ func init() {
3030
Cmd.AddCommand(createCmd)
3131
Cmd.AddCommand(deployCmd)
3232
Cmd.AddCommand(infoCmd)
33+
Cmd.AddCommand(listCmd)
3334
Cmd.AddCommand(startCmd)
3435
}

cmd/app/list.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package app
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
7+
mjrToken "github.com/major-technology/cli/clients/token"
8+
"github.com/major-technology/cli/singletons"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var listCmd = &cobra.Command{
13+
Use: "list",
14+
Short: "List all applications in the current organization",
15+
Hidden: true,
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
return runList()
18+
},
19+
}
20+
21+
type appListItem struct {
22+
ID string `json:"id"`
23+
Name string `json:"name"`
24+
}
25+
26+
func runList() error {
27+
orgID, _, err := mjrToken.GetDefaultOrg()
28+
if err != nil {
29+
return err
30+
}
31+
32+
apiClient := singletons.GetAPIClient()
33+
34+
resp, err := apiClient.GetOrganizationApplications(orgID)
35+
if err != nil {
36+
return err
37+
}
38+
39+
items := make([]appListItem, len(resp.Applications))
40+
for i, app := range resp.Applications {
41+
items[i] = appListItem{
42+
ID: app.ID,
43+
Name: app.Name,
44+
}
45+
}
46+
47+
return json.NewEncoder(os.Stdout).Encode(items)
48+
}

cmd/config/config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package config
2+
3+
import (
4+
"github.com/major-technology/cli/utils"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
var Cmd = &cobra.Command{
9+
Use: "config",
10+
Short: "CLI configuration",
11+
Hidden: true,
12+
Args: utils.NoArgs,
13+
Run: func(cmd *cobra.Command, args []string) {
14+
cmd.Help()
15+
},
16+
}
17+
18+
func init() {
19+
Cmd.AddCommand(setEnvCmd)
20+
Cmd.AddCommand(getEnvCmd)
21+
}

cmd/config/get_env.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var getEnvCmd = &cobra.Command{
12+
Use: "get-env",
13+
Short: "Print the current CLI environment",
14+
Args: cobra.NoArgs,
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
return runGetEnv(cmd)
17+
},
18+
}
19+
20+
func runGetEnv(cmd *cobra.Command) error {
21+
envFile, err := envFilePath()
22+
if err != nil {
23+
return err
24+
}
25+
26+
data, err := os.ReadFile(envFile)
27+
if err != nil {
28+
cmd.Println("prod")
29+
return nil
30+
}
31+
32+
configPath := strings.TrimSpace(string(data))
33+
for name, path := range validEnvs {
34+
if path == configPath {
35+
fmt.Fprintln(cmd.OutOrStdout(), name)
36+
return nil
37+
}
38+
}
39+
40+
cmd.Println("prod")
41+
return nil
42+
}

cmd/config/set_env.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var validEnvs = map[string]string{
12+
"local": "configs/local.json",
13+
"staging": "configs/staging.json",
14+
"prod": "configs/prod.json",
15+
}
16+
17+
var setEnvCmd = &cobra.Command{
18+
Use: "set-env <local|staging|prod>",
19+
Short: "Set the CLI environment",
20+
Args: cobra.ExactArgs(1),
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
return runSetEnv(cmd, args[0])
23+
},
24+
}
25+
26+
func runSetEnv(cmd *cobra.Command, env string) error {
27+
configPath, ok := validEnvs[env]
28+
if !ok {
29+
return fmt.Errorf("invalid environment %q, must be one of: local, staging, prod", env)
30+
}
31+
32+
envFilePath, err := envFilePath()
33+
if err != nil {
34+
return err
35+
}
36+
37+
if err := os.MkdirAll(filepath.Dir(envFilePath), 0755); err != nil {
38+
return fmt.Errorf("failed to create config directory: %w", err)
39+
}
40+
41+
if err := os.WriteFile(envFilePath, []byte(configPath), 0644); err != nil {
42+
return fmt.Errorf("failed to write environment file: %w", err)
43+
}
44+
45+
cmd.Printf("Environment set to %s\n", env)
46+
return nil
47+
}
48+
49+
func envFilePath() (string, error) {
50+
homeDir, err := os.UserHomeDir()
51+
if err != nil {
52+
return "", err
53+
}
54+
return filepath.Join(homeDir, ".major", "env"), nil
55+
}

cmd/mcp/check_readonly.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
11+
mjrToken "github.com/major-technology/cli/clients/token"
12+
"github.com/major-technology/cli/singletons"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var checkReadonlyCmd = &cobra.Command{
17+
Use: "check-readonly [tool-name]",
18+
Short: "Check if an MCP tool is read-only",
19+
Hidden: true,
20+
Args: cobra.ExactArgs(1),
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
return runCheckReadonly(args[0])
23+
},
24+
}
25+
26+
type toolMetadataItem struct {
27+
Name string `json:"name"`
28+
Description string `json:"description"`
29+
ReadOnly bool `json:"readOnly"`
30+
ResourceType string `json:"resourceType"`
31+
}
32+
33+
type toolCache struct {
34+
Tools []toolMetadataItem `json:"tools"`
35+
FetchedAt time.Time `json:"fetchedAt"`
36+
}
37+
38+
func runCheckReadonly(toolName string) error {
39+
tools, err := getCachedToolMetadata()
40+
if err != nil {
41+
// If we can't get metadata, don't block — let Claude Code prompt the user
42+
return nil
43+
}
44+
45+
for _, t := range tools {
46+
if t.Name == toolName && t.ReadOnly {
47+
result := map[string]any{
48+
"hookSpecificOutput": map[string]any{
49+
"hookEventName": "PreToolUse",
50+
"permissionDecision": "allow",
51+
},
52+
}
53+
54+
return json.NewEncoder(os.Stdout).Encode(result)
55+
}
56+
}
57+
58+
// Not read-only or not found — exit silently, Claude Code will prompt the user
59+
return nil
60+
}
61+
62+
func getCachedToolMetadata() ([]toolMetadataItem, error) {
63+
homeDir, err := os.UserHomeDir()
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
cachePath := filepath.Join(homeDir, ".major", "cache", "tool-metadata.json")
69+
70+
// Try to read cache
71+
data, err := os.ReadFile(cachePath)
72+
if err == nil {
73+
var cache toolCache
74+
75+
if json.Unmarshal(data, &cache) == nil {
76+
if time.Since(cache.FetchedAt) < 24*time.Hour {
77+
return cache.Tools, nil
78+
}
79+
}
80+
}
81+
82+
// Fetch fresh metadata from resource API
83+
cfg := singletons.GetConfig()
84+
if cfg == nil || cfg.ResourceAPIURL == "" {
85+
return nil, fmt.Errorf("config not loaded")
86+
}
87+
88+
url := cfg.ResourceAPIURL + "/cli/v1/mcp/tools"
89+
90+
// Get auth headers
91+
token, err := mjrToken.GetToken()
92+
if err != nil {
93+
return nil, err
94+
}
95+
96+
orgID, _, err := mjrToken.GetDefaultOrg()
97+
if err != nil {
98+
return nil, err
99+
}
100+
101+
req, err := http.NewRequest("GET", url, nil)
102+
if err != nil {
103+
return nil, err
104+
}
105+
106+
req.Header.Set("Authorization", "Bearer "+token)
107+
req.Header.Set("x-major-org-id", orgID)
108+
109+
client := &http.Client{Timeout: 10 * time.Second}
110+
resp, err := client.Do(req)
111+
if err != nil {
112+
return nil, err
113+
}
114+
defer resp.Body.Close()
115+
116+
if resp.StatusCode != http.StatusOK {
117+
return nil, fmt.Errorf("tools endpoint returned %d", resp.StatusCode)
118+
}
119+
120+
var toolsResp struct {
121+
Tools []toolMetadataItem `json:"tools"`
122+
}
123+
124+
if err := json.NewDecoder(resp.Body).Decode(&toolsResp); err != nil {
125+
return nil, err
126+
}
127+
128+
// Cache it
129+
cache := toolCache{
130+
Tools: toolsResp.Tools,
131+
FetchedAt: time.Now(),
132+
}
133+
134+
cacheData, _ := json.Marshal(cache)
135+
os.MkdirAll(filepath.Dir(cachePath), 0755)
136+
os.WriteFile(cachePath, cacheData, 0644)
137+
138+
return toolsResp.Tools, nil
139+
}

cmd/mcp/mcp.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package mcp
2+
3+
import (
4+
"github.com/major-technology/cli/utils"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
var Cmd = &cobra.Command{
9+
Use: "mcp",
10+
Short: "MCP server utilities",
11+
Hidden: true,
12+
Args: utils.NoArgs,
13+
Run: func(cmd *cobra.Command, args []string) {
14+
cmd.Help()
15+
},
16+
}
17+
18+
func init() {
19+
Cmd.AddCommand(checkReadonlyCmd)
20+
}

cmd/org/id.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package org
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
mjrToken "github.com/major-technology/cli/clients/token"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var idCmd = &cobra.Command{
12+
Use: "id",
13+
Short: "Print the default organization ID",
14+
Hidden: true,
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
return runID()
17+
},
18+
}
19+
20+
func runID() error {
21+
orgID, _, err := mjrToken.GetDefaultOrg()
22+
if err != nil {
23+
return err
24+
}
25+
26+
fmt.Fprint(os.Stdout, orgID)
27+
return nil
28+
}

cmd/org/org.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ func init() {
2121
Cmd.AddCommand(selectCmd)
2222
Cmd.AddCommand(whoamiCmd)
2323
Cmd.AddCommand(listCmd)
24+
Cmd.AddCommand(idCmd)
2425
}

0 commit comments

Comments
 (0)