Skip to content

Commit 86fbfa6

Browse files
authored
Merge pull request #1025 from planetscale/piki/mcp
Add an experimental MCP server
2 parents e1470f9 + 7e48562 commit 86fbfa6

File tree

8 files changed

+913
-0
lines changed

8 files changed

+913
-0
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/hashicorp/go-cleanhttp v0.5.2
1919
github.com/hashicorp/go-version v1.7.0
2020
github.com/lensesio/tableprinter v0.0.0-20201125135848-89e81fc956e7
21+
github.com/mark3labs/mcp-go v0.19.0
2122
github.com/matoous/go-nanoid/v2 v2.1.0
2223
github.com/mattn/go-isatty v0.0.20
2324
github.com/mattn/go-shellwords v1.0.12
@@ -65,6 +66,7 @@ require (
6566
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
6667
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
6768
github.com/golang/glog v1.2.2 // indirect
69+
github.com/google/uuid v1.6.0 // indirect
6870
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
6971
github.com/inconshreveable/mousetrap v1.1.0 // indirect
7072
github.com/kataras/tablewriter v0.0.0-20180708051242-e063d29b7c23 // indirect
@@ -97,6 +99,7 @@ require (
9799
github.com/spf13/afero v1.12.0 // indirect
98100
github.com/spf13/cast v1.7.1 // indirect
99101
github.com/subosito/gotenv v1.6.0 // indirect
102+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
100103
go.uber.org/multierr v1.11.0 // indirect
101104
golang.org/x/term v0.24.0 // indirect
102105
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6
7373
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
7474
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
7575
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
76+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
77+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7678
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
7779
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
7880
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -102,6 +104,8 @@ github.com/lensesio/tableprinter v0.0.0-20201125135848-89e81fc956e7 h1:k/1ku0yeh
102104
github.com/lensesio/tableprinter v0.0.0-20201125135848-89e81fc956e7/go.mod h1:YR/zYthNdWfO8+0IOyHDcIDBBBS2JMnYUIwSsnwmRqU=
103105
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
104106
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
107+
github.com/mark3labs/mcp-go v0.19.0 h1:cYKBPFD+fge273/TV6f5+TZYBSTnxV6GCJAO08D2wvA=
108+
github.com/mark3labs/mcp-go v0.19.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
105109
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
106110
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
107111
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@@ -194,6 +198,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
194198
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
195199
github.com/xelabs/go-mysqlstack v1.0.0 h1:go/UqwlxKRNh9df+AQ/pAAgcCCHCaeyv0PYZ/quRbbw=
196200
github.com/xelabs/go-mysqlstack v1.0.0/go.mod h1:xw+rgelmcSTN/55nk7EcfriA9EeblS8w3nMSbad2yTc=
201+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
202+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
197203
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
198204
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
199205
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=

internal/cmd/mcp/install.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
10+
"github.com/planetscale/cli/internal/cmdutil"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// ClaudeConfig represents the structure of the Claude Desktop config file
15+
type ClaudeConfig map[string]interface{}
16+
17+
// getClaudeConfigDir returns the path to the Claude Desktop config directory based on the OS
18+
func getClaudeConfigDir() (string, error) {
19+
switch runtime.GOOS {
20+
case "darwin":
21+
// macOS path: ~/Library/Application Support/Claude/
22+
homeDir, err := os.UserHomeDir()
23+
if err != nil {
24+
return "", fmt.Errorf("could not determine user home directory: %w", err)
25+
}
26+
return filepath.Join(homeDir, "Library", "Application Support", "Claude"), nil
27+
case "windows":
28+
// Windows path: %APPDATA%\Claude\
29+
return filepath.Join(os.Getenv("APPDATA"), "Claude"), nil
30+
default:
31+
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
32+
}
33+
}
34+
35+
// InstallCmd returns a new cobra.Command for the mcp install command.
36+
func InstallCmd(ch *cmdutil.Helper) *cobra.Command {
37+
var target string
38+
39+
cmd := &cobra.Command{
40+
Use: "install",
41+
Short: "Install the MCP server",
42+
Long: `Install the PlanetScale model context protocol (MCP) server.`,
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
if target != "claude" {
45+
return fmt.Errorf("invalid target vendor: %s (only 'claude' is supported)", target)
46+
}
47+
48+
configDir, err := getClaudeConfigDir()
49+
if err != nil {
50+
return fmt.Errorf("failed to determine Claude config directory: %w", err)
51+
}
52+
53+
// Check if the directory exists
54+
if _, err := os.Stat(configDir); os.IsNotExist(err) {
55+
return fmt.Errorf("no Claude Desktop installation: path %s not found", configDir)
56+
}
57+
58+
configPath := filepath.Join(configDir, "claude_desktop_config.json")
59+
config := make(ClaudeConfig)
60+
61+
// Check if the file exists
62+
if _, err := os.Stat(configPath); err == nil {
63+
// File exists, read it
64+
configData, err := os.ReadFile(configPath)
65+
if err != nil {
66+
return fmt.Errorf("failed to read Claude config file: %w", err)
67+
}
68+
69+
if err := json.Unmarshal(configData, &config); err != nil {
70+
return fmt.Errorf("failed to parse Claude config file: %w", err)
71+
}
72+
}
73+
74+
// Get or initialize the mcpServers map
75+
var mcpServers map[string]interface{}
76+
if existingServers, ok := config["mcpServers"].(map[string]interface{}); ok {
77+
mcpServers = existingServers
78+
} else {
79+
mcpServers = make(map[string]interface{})
80+
}
81+
82+
// Add or update the planetscale server configuration
83+
mcpServers["planetscale"] = map[string]interface{}{
84+
"command": "pscale",
85+
"args": []string{"mcp", "server"},
86+
}
87+
88+
// Update the config with the new mcpServers
89+
config["mcpServers"] = mcpServers
90+
91+
// Write the updated config back to file
92+
configJSON, err := json.MarshalIndent(config, "", " ")
93+
if err != nil {
94+
return fmt.Errorf("failed to marshal Claude config: %w", err)
95+
}
96+
97+
if err := os.WriteFile(configPath, configJSON, 0644); err != nil {
98+
return fmt.Errorf("failed to write Claude config file: %w", err)
99+
}
100+
101+
fmt.Printf("MCP server successfully configured for %s at %s\n", target, configPath)
102+
return nil
103+
},
104+
}
105+
106+
cmd.Flags().StringVar(&target, "target", "", "Target vendor for MCP installation (required). Possible values: [claude]")
107+
cmd.MarkFlagRequired("target")
108+
cmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
109+
return []string{"claude"}, cobra.ShellCompDirectiveDefault
110+
})
111+
112+
return cmd
113+
}

internal/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/planetscale/cli/internal/cmdutil"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
// McpCmd returns a new cobra.Command for the mcp command.
9+
func McpCmd(ch *cmdutil.Helper) *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "mcp <command>",
12+
Short: "Manage and use the MCP server",
13+
Long: `Manage and use the PlanetScale model context protocol (MCP) server.`,
14+
}
15+
16+
cmd.AddCommand(InstallCmd(ch))
17+
cmd.AddCommand(ServerCmd(ch))
18+
19+
return cmd
20+
}

internal/cmd/mcp/server.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
_ "github.com/go-sql-driver/mysql"
8+
"github.com/mark3labs/mcp-go/mcp"
9+
"github.com/mark3labs/mcp-go/server"
10+
"github.com/planetscale/cli/internal/cmdutil"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// Tool handler function type
15+
type ToolHandler func(ctx context.Context, request mcp.CallToolRequest, ch *cmdutil.Helper) (*mcp.CallToolResult, error)
16+
17+
// Tool definition
18+
type ToolDef struct {
19+
tool mcp.Tool
20+
handler ToolHandler
21+
}
22+
23+
// getToolDefinitions returns the list of all available MCP tools
24+
func getToolDefinitions() []ToolDef {
25+
return []ToolDef{
26+
{
27+
tool: mcp.NewTool("list_orgs",
28+
mcp.WithDescription("List all available organizations"),
29+
),
30+
handler: HandleListOrgs,
31+
},
32+
{
33+
tool: mcp.NewTool("list_databases",
34+
mcp.WithDescription("List all databases in an organization"),
35+
mcp.WithString("org",
36+
mcp.Description("The organization name (uses default organization if not specified)"),
37+
),
38+
),
39+
handler: HandleListDatabases,
40+
},
41+
{
42+
tool: mcp.NewTool("list_branches",
43+
mcp.WithDescription("List all branches for a database"),
44+
mcp.WithString("database",
45+
mcp.Description("The database name"),
46+
mcp.Required(),
47+
),
48+
mcp.WithString("org",
49+
mcp.Description("The organization name (uses default organization if not specified)"),
50+
),
51+
),
52+
handler: HandleListBranches,
53+
},
54+
{
55+
tool: mcp.NewTool("list_keyspaces",
56+
mcp.WithDescription("List all keyspaces within a branch"),
57+
mcp.WithString("database",
58+
mcp.Description("The database name"),
59+
mcp.Required(),
60+
),
61+
mcp.WithString("branch",
62+
mcp.Description("The branch name"),
63+
mcp.Required(),
64+
),
65+
mcp.WithString("org",
66+
mcp.Description("The organization name (uses default organization if not specified)"),
67+
),
68+
),
69+
handler: HandleListKeyspaces,
70+
},
71+
{
72+
tool: mcp.NewTool("list_tables",
73+
mcp.WithDescription("List all tables in a keyspace"),
74+
mcp.WithString("database",
75+
mcp.Description("The database name"),
76+
mcp.Required(),
77+
),
78+
mcp.WithString("branch",
79+
mcp.Description("The branch name"),
80+
mcp.Required(),
81+
),
82+
mcp.WithString("keyspace",
83+
mcp.Description("The keyspace name"),
84+
mcp.Required(),
85+
),
86+
mcp.WithString("org",
87+
mcp.Description("The organization name (uses default organization if not specified)"),
88+
),
89+
),
90+
handler: HandleListTables,
91+
},
92+
{
93+
tool: mcp.NewTool("get_schema",
94+
mcp.WithDescription("Get the SQL schema for tables in a keyspace"),
95+
mcp.WithString("database",
96+
mcp.Description("The database name"),
97+
mcp.Required(),
98+
),
99+
mcp.WithString("branch",
100+
mcp.Description("The branch name"),
101+
mcp.Required(),
102+
),
103+
mcp.WithString("keyspace",
104+
mcp.Description("The keyspace name"),
105+
mcp.Required(),
106+
),
107+
mcp.WithString("tables",
108+
mcp.Description("Tables to get schemas for (single name, comma-separated list, or '*' for all tables)"),
109+
mcp.Required(),
110+
),
111+
mcp.WithString("org",
112+
mcp.Description("The organization name (uses default organization if not specified)"),
113+
),
114+
),
115+
handler: HandleGetSchema,
116+
},
117+
{
118+
tool: mcp.NewTool("run_query",
119+
mcp.WithDescription("Run a SQL query against a database branch keyspace"),
120+
mcp.WithString("database",
121+
mcp.Description("The database name"),
122+
mcp.Required(),
123+
),
124+
mcp.WithString("branch",
125+
mcp.Description("The branch name"),
126+
mcp.Required(),
127+
),
128+
mcp.WithString("keyspace",
129+
mcp.Description("The keyspace name"),
130+
mcp.Required(),
131+
),
132+
mcp.WithString("query",
133+
mcp.Description("The SQL query to run (read-only queries only)"),
134+
mcp.Required(),
135+
),
136+
mcp.WithString("org",
137+
mcp.Description("The organization name (uses default organization if not specified)"),
138+
),
139+
),
140+
handler: HandleRunQuery,
141+
},
142+
}
143+
}
144+
145+
// ServerCmd returns a new cobra.Command for the mcp server command.
146+
func ServerCmd(ch *cmdutil.Helper) *cobra.Command {
147+
cmd := &cobra.Command{
148+
Use: "server",
149+
Short: "Start the MCP server",
150+
Long: `Start the PlanetScale model context protocol (MCP) server.`,
151+
RunE: func(cmd *cobra.Command, args []string) error {
152+
// Create a new MCP server
153+
s := server.NewMCPServer(
154+
"PlanetScale MCP Server",
155+
"0.1.0",
156+
)
157+
158+
// Register all tools
159+
for _, toolDef := range getToolDefinitions() {
160+
// Create a tool-specific handler that will forward to our function
161+
// We need to create a local copy of the tool definition to avoid closure issues
162+
def := toolDef
163+
// AddTool expects the tool value directly
164+
s.AddTool(def.tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
165+
return def.handler(ctx, request, ch)
166+
})
167+
}
168+
169+
// Start the server
170+
if err := server.ServeStdio(s); err != nil {
171+
return fmt.Errorf("MCP server error: %v", err)
172+
}
173+
174+
return nil
175+
},
176+
}
177+
178+
return cmd
179+
}

0 commit comments

Comments
 (0)