Skip to content

Commit 31e555b

Browse files
simplesagarclaudegithub-actions[bot]chase-crumbaughdisintegrator
authored
feat: add gram install command for MCP server configuration & support common clients (#776)
## Summary Adds new `gram install` command with support for configuring Gram toolsets as MCP servers across multiple AI clients. The implementation uses a shared resolver architecture with client-specific configuration writers. ## Supported Clients - **Claude Code**: Native HTTP transport with `.mcp.json` configuration - **Claude Desktop**: `.dxt` file generation for one-click installation - **Cursor**: Browser-based installation flow (gracefully falls back to manual URL if browser fails) - **Gemini CLI**: Configuration file generation ## Key Features ### Automatic Configuration ``` gram install claude-code --toolset speakeasy-admin ``` - Fetches toolset metadata from Gram API - Automatically derives MCP URL from organization/project/environment or custom MCP slug - Extracts environment variable names from toolset security config - Uses standard `Authorization` header for HTTP authentication - Uses toolset name as the MCP server name ### Manual Configuration ``` gram install claude-code \ --mcp-url https://mcp.getgram.ai/org/project/environment \ --api-key your-api-key \ --header Custom-Auth-Header \ --env-var MY_API_KEY ``` - Supports custom MCP URLs for any MCP server - Configurable authentication headers (defaults to `Authorization`) - Environment variable substitution for secure API key storage - Automatic detection of locally set environment variables (uses actual value if available) ### Shared Architecture All install commands share: - Common toolset resolver (\`cli/internal/mcp/resolver.go\`) for fetching and deriving configuration - Consistent flag interface across all clients - Client-specific writers for different configuration formats: - \`.mcp.json\` for Claude Code (HTTP transport) - \`.dxt\` files for Claude Desktop - Browser URLs for Cursor and Gemini CLI - \`.claude\` config for Claude CLI ## Implementation Highlights ### Native HTTP Transport Switched from \`mcp-remote\` npm package to native HTTP transport for Claude Code: - Eliminates external npm dependency - Better control over transport configuration - Direct HTTP communication with MCP servers - Simpler configuration format ### Smart Environment Variable Handling \`\`\`bash # If MY_KEY is already set locally, uses the actual value export MY_KEY=secret123 gram install claude-code --toolset my-toolset --env-var MY_KEY # If MY_KEY is not set, uses substitution syntax \${MY_KEY} unset MY_KEY gram install claude-code --toolset my-toolset --env-var MY_KEY \`\`\` This allows flexibility for both: 1. Development: Use actual values when env vars are set 2. Distribution: Use substitution when sharing configs ### Cross-Platform Compatibility - **Linux Support**: Downloads directory detection with fallback to current directory - **Graceful Degradation**: Browser opening failures don't block Cursor installation (shows manual URL) - **Standard Headers**: Uses `Authorization` header following HTTP conventions ## Command-Line Interface ### Common Flags - \`--toolset\` - Automatic configuration via Gram API lookup - \`--mcp-url\` - Manual MCP server URL - \`--name\` - Custom server name (optional) - \`--header\` - HTTP header name (defaults to `Authorization`) - \`--env-var\` - Environment variable for API key substitution ### Client-Specific Flags - \`--output-dir\` (Claude Desktop) - Where to save .dxt file ## Files Changed ### CLI Implementation - \`cli/internal/app/install.go\` - Main install command with shared resolver - \`cli/internal/app/install_claude_code.go\` - Claude Code specific installer - \`cli/internal/app/install_claude_desktop.go\` - Claude Desktop specific installer - \`cli/internal/app/install_cursor.go\` - Cursor specific installer - \`cli/internal/app/install_gemini_cli.go\` - Gemini CLI specific installer ### MCP Configuration - \`cli/internal/mcp/resolver.go\` - Shared toolset resolution logic - \`cli/internal/mcp/config.go\` - Common MCP configuration types - \`cli/internal/mcp/browser.go\` - Browser-based installation helper - \`cli/internal/mcp/claude_cli.go\` - Claude CLI config writer - \`cli/internal/mcp/dxt.go\` - DXT file generation for Claude Desktop - \`cli/internal/mcp/gemini_cli.go\` - Gemini CLI config writer ### Legacy (Claude Code specific) - \`cli/internal/claudecode/config.go\` - Claude Code \`.mcp.json\` utilities ### API Clients - \`cli/internal/api/toolsets.go\` - Toolset API client - \`cli/internal/api/assets.go\` - Added missing ServeFunction endpoint ### Server Changes - \`server/design/toolsets/design.go\` - Added support for API key auth on toolset endpoints - Various server files - API key authentication support for toolset serving ## Code Review Feedback Addressed ### From @disintegrator: - ✅ Renamed \`--header-name\` to \`--header\` for simplicity - ✅ Changed default from \`Gram-Apikey\` to \`Authorization\` (HTTP standard) - ✅ Fixed Downloads directory handling for Linux (checks existence, falls back to \`.\`) - ✅ Made browser opening non-fatal in Cursor (shows manual URL on failure) - ✅ Removed header name derivation logic (was making unfounded assumptions) - ✅ Reverted out-of-scope \`status.go\` changes ### From @qstearns: - 📝 Note: Support for non-authentication headers can be added in a follow-up PR if needed ## Test Plan - [x] Build CLI: \`mise build:cli\` - [x] Run linters: \`mise lint:cli\` - [x] Test automatic lookup: \`gram install claude-code --toolset <toolset-slug>\` - [x] Test manual URL: \`gram install claude-code --mcp-url https://mcp.getgram.ai/org/proj/env --api-key test-key\` - [x] Test env var substitution: \`gram install claude-code --toolset <slug> --env-var MY_VAR\` - [x] Test local env var detection: Set env var locally and verify actual value is used - [x] Test Linux compatibility: Verify Downloads directory fallback - [x] Test Cursor graceful degradation: Verify manual URL shown on browser failure - [x] Verify generated configs have correct format for each client - [x] Test with custom \`--name\` flag - [x] Test custom \`--header\` flag - [x] Verify Claude Code config works by restarting and checking MCP servers load - [x] Test Claude Desktop \`.dxt\` file installation - [x] Test Cursor browser-based installation - [x] Test Gemini CLI configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Sagar Batchu <simplesagar@users.noreply.github.com> Co-authored-by: chasecrumbaugh <chasecrumbaugh4@gmail.com> Co-authored-by: Georges Haidar <ghaidar0@gmail.com>
1 parent 69a36c0 commit 31e555b

File tree

30 files changed

+9648
-7027
lines changed

30 files changed

+9648
-7027
lines changed

.changeset/brown-readers-sneeze.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"server": patch
3+
"cli": minor
4+
---
5+
6+
feat: Add gram install command for MCP server configuration & support common clients
7+
8+
**Automatic Configuration**
9+
10+
```bash
11+
gram install claude-code --toolset speakeasy-admin
12+
```
13+
14+
- Fetches toolset metadata from Gram API
15+
- Automatically derives MCP URL from organization, project & environment or custom MCP slug
16+
- Intelligently determines authentication headers and environment variables from toolset security config
17+
- Uses toolset name as the MCP server name
18+
19+
**Manual Configuration**
20+
21+
```bash
22+
gram install claude-code
23+
--mcp-url https://mcp.getgram.ai/org/project/environment
24+
--api-key your-api-key
25+
--header-name Custom-Auth-Header
26+
--env-var MY_API_KEY
27+
```
28+
29+
- Supports custom MCP URLs for non-Gram servers
30+
- Configurable authentication headers
31+
- Environment variable substitution for secure API key storage
32+
- Automatic detection of locally set environment variables (uses actual value if available)

cli/internal/api/toolsets.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/speakeasy-api/gram/cli/internal/secret"
8+
toolsets_client "github.com/speakeasy-api/gram/server/gen/http/toolsets/client"
9+
"github.com/speakeasy-api/gram/server/gen/toolsets"
10+
"github.com/speakeasy-api/gram/server/gen/types"
11+
goahttp "goa.design/goa/v3/http"
12+
)
13+
14+
type ToolsetsClientOptions struct {
15+
Scheme string
16+
Host string
17+
}
18+
19+
type ToolsetsClient struct {
20+
client *toolsets.Client
21+
}
22+
23+
func NewToolsetsClient(options *ToolsetsClientOptions) *ToolsetsClient {
24+
doer := goaSharedHTTPClient
25+
26+
enc := goahttp.RequestEncoder
27+
dec := goahttp.ResponseDecoder
28+
restoreBody := false
29+
30+
h := toolsets_client.NewClient(options.Scheme, options.Host, doer, enc, dec, restoreBody)
31+
32+
client := toolsets.NewClient(
33+
h.CreateToolset(),
34+
h.ListToolsets(),
35+
h.UpdateToolset(),
36+
h.DeleteToolset(),
37+
h.GetToolset(),
38+
h.CheckMCPSlugAvailability(),
39+
h.CloneToolset(),
40+
h.AddExternalOAuthServer(),
41+
h.RemoveOAuthServer(),
42+
)
43+
44+
return &ToolsetsClient{client: client}
45+
}
46+
47+
func (c *ToolsetsClient) GetToolset(
48+
ctx context.Context,
49+
apiKey secret.Secret,
50+
projectSlug string,
51+
toolsetSlug string,
52+
) (*types.Toolset, error) {
53+
slug := types.Slug(toolsetSlug)
54+
key := apiKey.Reveal()
55+
payload := &toolsets.GetToolsetPayload{
56+
ApikeyToken: &key,
57+
SessionToken: nil,
58+
ProjectSlugInput: &projectSlug,
59+
Slug: slug,
60+
}
61+
62+
result, err := c.client.GetToolset(ctx, payload)
63+
if err != nil {
64+
return nil, fmt.Errorf("failed to get toolset: %w", err)
65+
}
66+
67+
return result, nil
68+
}
69+
70+
func (c *ToolsetsClient) ListToolsets(ctx context.Context, apiKey secret.Secret, projectSlug string) ([]*types.ToolsetEntry, error) {
71+
key := apiKey.Reveal()
72+
payload := &toolsets.ListToolsetsPayload{
73+
ApikeyToken: &key,
74+
SessionToken: nil,
75+
ProjectSlugInput: &projectSlug,
76+
}
77+
78+
result, err := c.client.ListToolsets(ctx, payload)
79+
if err != nil {
80+
return nil, fmt.Errorf("failed to list toolsets: %w", err)
81+
}
82+
83+
return result.Toolsets, nil
84+
}

cli/internal/app/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func newApp() *cli.App {
3333
newStatusCommand(),
3434
newWhoAmICommand(),
3535
newStageCommand(),
36+
newInstallCommand(),
3637
},
3738
Flags: []cli.Flag{
3839
flags.APIKey(),

cli/internal/app/install.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package app
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
7+
"github.com/speakeasy-api/gram/cli/internal/app/logging"
8+
"github.com/speakeasy-api/gram/cli/internal/flags"
9+
"github.com/speakeasy-api/gram/cli/internal/mcp"
10+
"github.com/speakeasy-api/gram/cli/internal/profile"
11+
"github.com/speakeasy-api/gram/cli/internal/workflow"
12+
"github.com/urfave/cli/v2"
13+
)
14+
15+
var installFlags = []cli.Flag{
16+
flags.APIKey(),
17+
flags.Project(),
18+
&cli.StringFlag{
19+
Name: "toolset",
20+
Usage: "The slug of the Gram toolset to install (e.g., speakeasy-admin). Will automatically look up MCP configuration.",
21+
},
22+
&cli.StringFlag{
23+
Name: "mcp-url",
24+
Usage: "The MCP server URL (e.g., https://mcp.getgram.ai/org/project/environment). Use this for manual configuration.",
25+
},
26+
&cli.StringFlag{
27+
Name: "name",
28+
Usage: "The name to use for this MCP server in Cursor (defaults to toolset name or derived from URL)",
29+
},
30+
&cli.StringFlag{
31+
Name: "header",
32+
Usage: "The HTTP header name for the API key (defaults to Authorization)",
33+
Value: "Authorization",
34+
},
35+
&cli.StringFlag{
36+
Name: "env-var",
37+
Usage: "Environment variable name to use for API key substitution (e.g., MCP_API_KEY). If provided, uses ${VAR} syntax instead of hardcoding the key",
38+
},
39+
}
40+
41+
func newInstallCommand() *cli.Command {
42+
return &cli.Command{
43+
Name: "install",
44+
Usage: "Install Gram toolsets as MCP servers in various clients",
45+
Subcommands: []*cli.Command{
46+
newInstallClaudeCodeCommand(),
47+
newInstallClaudeDesktopCommand(),
48+
newInstallCursorCommand(),
49+
newInstallGeminiCLICommand(),
50+
},
51+
}
52+
}
53+
54+
func resolveToolsetInfo(c *cli.Context) (*mcp.ToolsetInfo, error) {
55+
ctx := c.Context
56+
logger := logging.PullLogger(ctx)
57+
prof := profile.FromContext(ctx)
58+
59+
toolsetSlug := c.String("toolset")
60+
mcpURL := c.String("mcp-url")
61+
62+
// Validate that either toolset or mcp-url is provided
63+
if toolsetSlug == "" && mcpURL == "" {
64+
return nil, fmt.Errorf("either --toolset or --mcp-url must be provided")
65+
}
66+
if toolsetSlug != "" && mcpURL != "" {
67+
return nil, fmt.Errorf("cannot provide both --toolset and --mcp-url")
68+
}
69+
70+
// Get API URL if needed
71+
var apiURL *url.URL
72+
if toolsetSlug != "" {
73+
var err error
74+
apiURL, err = workflow.ResolveURL(c, prof)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to resolve API URL: %w", err)
77+
}
78+
}
79+
80+
projectSlug := workflow.ResolveProject(c, prof)
81+
apiKey := workflow.ResolveKey(c, prof)
82+
83+
// Resolve toolset information using shared logic
84+
info, err := mcp.ResolveToolsetInfo(ctx, &mcp.ResolverOptions{
85+
ProjectSlug: projectSlug,
86+
ToolsetSlug: toolsetSlug,
87+
MCPURL: mcpURL,
88+
ServerName: c.String("name"),
89+
APIKey: apiKey,
90+
HeaderName: c.String("header"),
91+
EnvVar: c.String("env-var"),
92+
APIURL: apiURL,
93+
Logger: logger,
94+
IsHeaderNameSet: c.IsSet("header"),
95+
IsEnvVarSet: c.IsSet("env-var"),
96+
})
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to resolve toolset info: %w", err)
99+
}
100+
return info, nil
101+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package app
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
7+
"github.com/speakeasy-api/gram/cli/internal/app/logging"
8+
"github.com/speakeasy-api/gram/cli/internal/claudecode"
9+
"github.com/speakeasy-api/gram/cli/internal/mcp"
10+
"github.com/urfave/cli/v2"
11+
)
12+
13+
func newInstallClaudeCodeCommand() *cli.Command {
14+
return &cli.Command{
15+
Name: "claude-code",
16+
Usage: "Install a Gram toolset as an MCP server in Claude Code",
17+
Flags: installFlags,
18+
Action: doInstallClaudeCode,
19+
}
20+
}
21+
22+
func doInstallClaudeCode(c *cli.Context) error {
23+
ctx := c.Context
24+
logger := logging.PullLogger(ctx)
25+
26+
// Resolve toolset information using shared logic
27+
info, err := resolveToolsetInfo(c)
28+
if err != nil {
29+
return fmt.Errorf("failed to resolve toolset info: %w", err)
30+
}
31+
32+
useEnvVar := info.EnvVarName != ""
33+
if useEnvVar {
34+
logger.InfoContext(ctx, "using environment variable substitution",
35+
slog.String("var", info.EnvVarName),
36+
slog.String("header", info.HeaderName))
37+
}
38+
39+
// Try to use native claude CLI with HTTP transport first
40+
if mcp.IsClaudeCLIAvailable() {
41+
logger.InfoContext(ctx, "using claude CLI with native HTTP transport")
42+
43+
if err := mcp.InstallViaClaudeCLI(info, useEnvVar); err != nil {
44+
logger.WarnContext(ctx, "claude CLI installation failed, falling back to config file",
45+
slog.String("error", err.Error()))
46+
} else {
47+
// Success with claude CLI
48+
logger.InfoContext(ctx, "successfully installed via claude CLI",
49+
slog.String("name", info.Name),
50+
slog.String("url", info.URL))
51+
52+
fmt.Printf("\n✓ Successfully installed MCP server '%s' via claude CLI\n", info.Name)
53+
fmt.Printf(" URL: %s\n", info.URL)
54+
fmt.Printf(" Transport: HTTP (native)\n")
55+
56+
if useEnvVar {
57+
fmt.Printf("\n⚠ Remember to set the environment variable:\n")
58+
fmt.Printf(" export %s='your-api-key-value'\n", info.EnvVarName)
59+
}
60+
61+
return nil
62+
}
63+
} else {
64+
logger.InfoContext(ctx, "claude CLI not available, using .mcp.json config file")
65+
}
66+
67+
// Fallback: Write to .mcp.json config file
68+
locations, err := claudecode.GetConfigLocations()
69+
if err != nil {
70+
return fmt.Errorf("failed to get config locations: %w", err)
71+
}
72+
configPath := locations[0].Path
73+
logger.InfoContext(ctx, "using config location",
74+
slog.String("path", configPath),
75+
slog.String("type", locations[0].Description))
76+
77+
config, err := claudecode.ReadConfig(configPath)
78+
if err != nil {
79+
return fmt.Errorf("failed to read config: %w", err)
80+
}
81+
82+
if _, exists := config.MCPServers[info.Name]; exists {
83+
logger.WarnContext(ctx, "server with this name already exists, will be overwritten",
84+
slog.String("name", info.Name))
85+
}
86+
87+
mcpConfig := mcp.BuildMCPConfig(info, useEnvVar)
88+
serverConfig := claudecode.MCPServerConfig{
89+
Command: "",
90+
Args: nil,
91+
Env: nil,
92+
Type: mcpConfig.Type,
93+
URL: mcpConfig.URL,
94+
Headers: mcpConfig.Headers,
95+
}
96+
97+
config.AddOrUpdateServer(info.Name, serverConfig)
98+
99+
if err := claudecode.WriteConfig(configPath, config); err != nil {
100+
return fmt.Errorf("failed to write config: %w", err)
101+
}
102+
103+
logger.InfoContext(ctx, "successfully wrote MCP config",
104+
slog.String("name", info.Name),
105+
slog.String("url", info.URL),
106+
slog.String("config", configPath))
107+
108+
fmt.Printf("\n✓ Successfully installed MCP server '%s'\n", info.Name)
109+
fmt.Printf(" URL: %s\n", info.URL)
110+
fmt.Printf(" Config: %s\n", configPath)
111+
fmt.Printf(" Method: Config file (claude CLI not detected)\n")
112+
113+
if useEnvVar {
114+
fmt.Printf("\n⚠ Remember to set the environment variable:\n")
115+
fmt.Printf(" export %s='your-api-key-value'\n", info.EnvVarName)
116+
}
117+
118+
fmt.Printf("\nRestart Claude Code to load the new MCP server.\n")
119+
120+
return nil
121+
}

0 commit comments

Comments
 (0)