Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/client/db/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func (s *RootServer) RegisterDatabase(db *Database) {
s.mu.Lock()
defer s.mu.Unlock()

uri := db.ResourceURI().String()
uri := db.ResourceURI().WithoutParams().String()
s.availableDatabases[uri] = db
s.AddResource(mcp.NewResource(uri, fmt.Sprintf("%s Database", db.DB.GetName()), mcp.WithMIMEType(databaseResourceMIMEType)), s.GetDatabaseResource)
}
Expand All @@ -124,7 +124,7 @@ func (s *RootServer) ServeStdio(ctx context.Context, in io.Reader, out io.Writer
func buildDatabaseResource(db *Database) DatabaseResource {
return DatabaseResource{
Metadata: db.DB.GetMetadata(),
URI: db.ResourceURI().String(),
URI: db.ResourceURI().WithoutParams().String(),
Protocol: db.DB.GetProtocol(),
ClusterName: db.ClusterName,
}
Expand Down
2 changes: 1 addition & 1 deletion lib/client/db/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestRegisterDatabase(t *testing.T) {
}
// sort databases by name to ensure the same order every test.
slices.SortFunc(databases, func(a, b *Database) int {
return strings.Compare(a.ResourceURI().String(), b.ResourceURI().String())
return strings.Compare(a.ResourceURI().WithoutParams().String(), b.ResourceURI().WithoutParams().String())
})

for _, db := range databases {
Expand Down
2 changes: 1 addition & 1 deletion lib/client/db/postgres/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func NewServer(ctx context.Context, cfg *dbmcp.NewServerConfig) (dbmcp.Server, e
return nil, trace.BadParameter("failed to parse database %q connection config: %s", db.DB.GetName(), err)
}

s.databases[db.ResourceURI().String()] = &database{
s.databases[db.ResourceURI().WithoutParams().String()] = &database{
Database: db,
pool: pool,
}
Expand Down
2 changes: 1 addition & 1 deletion lib/client/db/postgres/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestFormatErrors(t *testing.T) {
URI: "localhost:5432",
})
require.NoError(t, err)
dbURI := clientmcp.NewDatabaseResourceURI("root", dbName).String()
dbURI := clientmcp.NewDatabaseResourceURI("root", dbName).WithoutParams().String()

for name, tc := range map[string]struct {
databaseURI string
Expand Down
14 changes: 12 additions & 2 deletions lib/client/mcp/claude/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,20 @@ func (c *Config) GetMCPServers() map[string]MCPServer {
return maps.Clone(c.mcpServers)
}

// PutMCPServer adds a new MCP server or replace an existing one.
// PutMCPServer adds a new MCP server or replaces an existing one.
func (c *Config) PutMCPServer(serverName string, server MCPServer) (err error) {
c.mcpServers[serverName] = server
c.configData, err = sjson.SetBytes(c.configData, c.mcpServerJSONPath(serverName), server)

Comment thread
gabrielcorado marked this conversation as resolved.
// We require a custom marshal to improve MCP Resources URI readability when
// it includes query params. By default the encoding/json escapes some
// characters like `&` causing the final URI to be harder to read.
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow, I didn't know that. Seems surprising that this is the default.

if err := enc.Encode(server); err != nil {
return trace.Wrap(err)
}
c.configData, err = sjson.SetRawBytes(c.configData, c.mcpServerJSONPath(serverName), b.Bytes())
return trace.Wrap(err)
}

Expand Down
23 changes: 23 additions & 0 deletions lib/client/mcp/claude/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,29 @@ func Test_formatJSON(t *testing.T) {
}
}

// TestPrettyResourceURIs given a MCP server that includes a Resource URI as
// arguments it must encode and output those URIs in a readable format.
func TestReadableResourceURIs(t *testing.T) {
for name, uri := range map[string]string{
"uri with query params": "teleport://clusters/root/databases/pg",
"uri without query params": "teleport://clusters/root/databases/pg?dbName=postgres&dbUser=readonly",
"random uri with params": "teleport://random?hello=world&random=resource",
} {
t.Run(name, func(t *testing.T) {
config := NewConfig()
mcpServer := MCPServer{
Command: "command",
Args: []string{uri},
}
require.NoError(t, config.PutMCPServer("test", mcpServer))

var buf bytes.Buffer
require.NoError(t, config.Write(&buf, FormatJSONCompact))
require.Contains(t, buf.String(), uri)
})
}
}

func requireFileWithData(t *testing.T, path string, want string) {
t.Helper()
read, err := os.ReadFile(path)
Expand Down
67 changes: 58 additions & 9 deletions lib/client/mcp/uri.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,59 @@ func ParseResourceURI(uri string) (*ResourceURI, error) {
return &ResourceURI{url: *parsedURL}, nil
}

// NewDatabaseResourceURI creates a new database resource URI.
func NewDatabaseResourceURI(cluster, databaseName string) ResourceURI {
// databaseParams represents the connect params for the database resource.
type databaseParams struct {
// user is the user to log in as.
user string
// name is the name to log in to.
name string
}

// databaseParam is a param function used for setting database connect params.
type databaseParam func(*databaseParams)

// WithDatabaseUser configures database params with database user.
func WithDatabaseUser(user string) databaseParam {
return func(dp *databaseParams) {
dp.user = user
}
}

// WithDatabaseUser configures database params with database name.
func WithDatabaseName(name string) databaseParam {
return func(dp *databaseParams) {
dp.name = name
}
}

// NewDatabaseResourceURI creates a new database resource URI with connect
// params.
func NewDatabaseResourceURI(cluster string, databaseName string, opts ...databaseParam) ResourceURI {
params := &databaseParams{}
for _, opt := range opts {
opt(params)
}

pathWithHost, _ := databaseURITemplate.Build(urlpath.Match{
Params: map[string]string{
"cluster": cluster,
"dbName": databaseName,
},
})

values := url.Values{}
if params.user != "" {
values.Add(databaseUserQueryParamName, params.user)
}
if params.name != "" {
values.Add(databaseNameQueryParamName, params.name)
}

return ResourceURI{
url: url.URL{
Scheme: resourceScheme,
Path: strings.TrimPrefix(pathWithHost, "/"),
Scheme: resourceScheme,
Path: strings.TrimPrefix(pathWithHost, "/"),
RawQuery: values.Encode(),
},
}
}
Expand Down Expand Up @@ -122,12 +162,21 @@ func (u ResourceURI) IsDatabase() bool {
return u.GetDatabaseServiceName() != ""
}

// String returns the string representation of the resource URI (excluding the
// query params).
// String returns the string representation of the resource URI.
func (u ResourceURI) String() string {
c := u.url
c.RawQuery = ""
return c.String()
return u.url.String()
}

// WithoutParams returns a copy of the resource without additional parameters.
func (u ResourceURI) WithoutParams() ResourceURI {
copyURL := u.url
copyURL.RawQuery = ""
return ResourceURI{url: copyURL}
}

// Equal returns true if both resources represent the same Teleport resource.
func (u ResourceURI) Equal(b ResourceURI) bool {
return u.String() == b.String()
}

// path returns the resource URI full path. We must include the hostname as the
Expand Down
54 changes: 52 additions & 2 deletions lib/client/mcp/uri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,16 @@ func TestDatabaseResourceURI(t *testing.T) {
expectedDatabaseUser: "",
expectedClusterName: "default",
},
"generated uri": {
uri: NewDatabaseResourceURI("default", "db").String(),
"generated uri with params": {
uri: NewDatabaseResourceURI("default", "db", WithDatabaseUser("user"), WithDatabaseName("name")).String(),
expectedDatabase: true,
expectedServiceName: "db",
expectedDatabaseName: "name",
expectedDatabaseUser: "user",
expectedClusterName: "default",
},
"generated uri without params": {
uri: NewDatabaseResourceURI("default", "db", WithDatabaseUser("user"), WithDatabaseName("name")).WithoutParams().String(),
expectedDatabase: true,
expectedServiceName: "db",
expectedDatabaseName: "",
Expand Down Expand Up @@ -92,3 +100,45 @@ func TestDatabaseResourceURI(t *testing.T) {
})
}
}

func TestEqualResourceURI(t *testing.T) {
randomType, err := ParseResourceURI("teleport://random/name")
require.NoError(t, err)

for name, tc := range map[string]struct {
a ResourceURI
b ResourceURI
expectedResult bool
}{
"same resources": {
a: NewDatabaseResourceURI("cluster", "pg"),
b: NewDatabaseResourceURI("cluster", "pg"),
expectedResult: true,
},
"same resources, different params": {
a: NewDatabaseResourceURI("cluster", "pg", WithDatabaseUser("readonly"), WithDatabaseName("postgres")).WithoutParams(),
b: NewDatabaseResourceURI("cluster", "pg", WithDatabaseUser("rw"), WithDatabaseName("random")).WithoutParams(),
expectedResult: true,
},
"same resource type, different resources": {
a: NewDatabaseResourceURI("cluster", "pg"),
b: NewDatabaseResourceURI("cluster", "random"),
expectedResult: false,
},
"different resource type, same name": {
a: *randomType,
b: NewDatabaseResourceURI("cluster", "pg"),
expectedResult: false,
},
"same resources compare params": {
a: NewDatabaseResourceURI("cluster", "pg", WithDatabaseUser("rw"), WithDatabaseName("postgres")),
b: NewDatabaseResourceURI("cluster", "pg", WithDatabaseUser("rw"), WithDatabaseName("postgres")),
expectedResult: true,
},
} {
t.Run(name, func(t *testing.T) {
require.Equal(t, tc.expectedResult, tc.a.Equal(tc.b))
require.Equal(t, tc.expectedResult, tc.b.Equal(tc.a))
})
}
}
12 changes: 12 additions & 0 deletions tool/tsh/common/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,16 @@ Examples:

Search MCP servers with labels and add to the specified JSON file
$ tsh mcp config --labels env=dev --client-config=my-config.json`

mcpDBConfigHelp = `
Examples:
Print sample configuration for exposing database as MCP server
$ tsh mcp db config --db-user=mydbuser --db-name=mydbname my-db-resource
Comment thread
gabrielcorado marked this conversation as resolved.

Add the database configuration to Claude Desktop
$ tsh mcp db config --db-user=mydbuser --db-name=mydbname --client-config=claude my-db-resource

Add the database configuration to the specified JSON file
$ tsh mcp db config --db-user=mydbuser --db-name=mydbname --client-config=my-config.json my-db-resource
`
)
7 changes: 5 additions & 2 deletions tool/tsh/common/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import (
)

type mcpCommands struct {
dbStart *mcpDBStartCommand
dbStart *mcpDBStartCommand
dbConfig *mcpDBConfigCommand

config *mcpConfigCommand
list *mcpListCommand
Expand All @@ -41,7 +42,8 @@ func newMCPCommands(app *kingpin.Application, cf *CLIConf) *mcpCommands {
mcp := app.Command("mcp", "View and control proxied MCP servers.")
db := mcp.Command("db", "Database access for MCP servers.")
return &mcpCommands{
dbStart: newMCPDBCommand(db),
dbStart: newMCPDBCommand(db, cf),
dbConfig: newMCPDBconfigCommand(db, cf),

list: newMCPListCommand(mcp, cf),
config: newMCPConfigCommand(mcp, cf),
Expand Down Expand Up @@ -114,6 +116,7 @@ a config file compatible with the "mcpServer" mapping.`)
// claudeConfig defines a subset of functions from claude.Config.
type claudeConfig interface {
PutMCPServer(string, claude.MCPServer) error
GetMCPServers() map[string]claude.MCPServer
}

func makeLocalMCPServer(cf *CLIConf, args []string) claude.MCPServer {
Expand Down
Loading
Loading