Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c657625
feat(mcp): initial postgres mcp
gabrielcorado Apr 30, 2025
c823e69
test(postgres): fix missing mock function
gabrielcorado May 1, 2025
42d9402
fix(gomod): go mod tidy all
gabrielcorado May 1, 2025
a0d95ed
refactor: code review suggestions
gabrielcorado May 15, 2025
a5a24ee
Merge branch 'master' into gabrielcorado/pg-mcp-init
gabrielcorado May 15, 2025
2623b70
fix(tsh): mcp init missing logger
gabrielcorado May 15, 2025
c3c4cc5
chore(tsh): missing other route to database field
gabrielcorado May 15, 2025
1cfcf9c
refactor: use in-memory net listener
gabrielcorado May 15, 2025
f2dcd6e
test(tsh): add mcp db command test
gabrielcorado May 16, 2025
10f7bf5
chore: fix license
gabrielcorado May 16, 2025
db60260
refactor(tsh): move logger init
gabrielcorado May 16, 2025
ab10408
test(mcp): sort slices to avoid flakiness
gabrielcorado May 16, 2025
ed95575
chore: fix lint
gabrielcorado May 16, 2025
ec53029
test(mcp): sort the resources before assertion
gabrielcorado May 16, 2025
720fc0a
fix(mcp): update error handler for better message
gabrielcorado May 16, 2025
8114969
refactor: code review suggestions
gabrielcorado May 19, 2025
2ff9cf0
Merge branch 'master' into gabrielcorado/pg-mcp-init
gabrielcorado May 19, 2025
770d79b
feat: add external error retriever for more accurate error messages
gabrielcorado May 21, 2025
4044a39
refactor: use the same logger init for mcp purposes
gabrielcorado May 23, 2025
0a42ae0
refactor: code review suggestions
gabrielcorado May 23, 2025
1ab11e8
refactor(tsh): rename command to `tsh mcp db start`
gabrielcorado May 23, 2025
fdd113c
refactor(mcp): protect database resources with rw mutex
gabrielcorado May 23, 2025
1416943
chore: update server godocs
gabrielcorado May 23, 2025
64d76c2
Merge branch 'master' into gabrielcorado/pg-mcp-init
gabrielcorado May 26, 2025
f06c1bd
chore: go mod tidy
gabrielcorado May 26, 2025
8ffb7d8
refactor: update command to take list of databases
gabrielcorado May 28, 2025
fa9e26b
chore(mcp): license
gabrielcorado May 28, 2025
afff0d1
Merge branch 'master' into gabrielcorado/pg-mcp-init
gabrielcorado May 28, 2025
c6237b3
chore(tsh): remove unused function
gabrielcorado May 28, 2025
5c8924d
refactor: code review suggestions
gabrielcorado Jun 4, 2025
4d3775b
refactor(tsh): validate duplicated databases in MCP configuration
gabrielcorado Jun 4, 2025
ac5716b
refactor(tsh): rename files to mcp_db
gabrielcorado Jun 4, 2025
7888d75
Merge branch 'master' into gabrielcorado/pg-mcp-init
gabrielcorado Jun 4, 2025
4662050
feat(mcp): add cluster name to the database resource
gabrielcorado Jun 4, 2025
fed7970
Merge branch 'master' into gabrielcorado/pg-mcp-init
gabrielcorado Jun 4, 2025
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
87 changes: 87 additions & 0 deletions lib/client/db/mcp/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package mcp

import (
"errors"
"io"
"strings"

"github.com/gravitational/trace"

apiclient "github.com/gravitational/teleport/api/client"
"github.com/gravitational/teleport/lib/client/mcp"
)

// ExtenralErrorRetriever returns an external error that might have happened.
//
// MCP servers don't have knowledge of other processes that might fail during
// their execution, such as authentication failures. This provider can be used
// to give them the necessary context to provide more accurate user messages.
type ExternalErrorRetriever interface {
// RetrieveError retrieves the error if any.
RetrieveError() error
}

// FormatErrorMessage formats the database MCP error messages.
// format.
func FormatErrorMessage(retreiver ExternalErrorRetriever, err error) error {
if retreiver != nil {
err = trace.NewAggregate(retreiver.RetrieveError(), err)
}

switch {
case errors.Is(err, apiclient.ErrClientCredentialsHaveExpired):
return trace.BadParameter(ReloginRequiredErrorMessage)
case strings.Contains(err.Error(), "connection reset by peer") || errors.Is(err, io.ErrClosedPipe):
return trace.BadParameter(LocalProxyConnectionErrorMessage)
}

return err
}

const (
// ReloginRequiredErrorMessage is the message returned to the MCP client
// when the tsh session expired.
ReloginRequiredErrorMessage = `It looks like your Teleport session expired,
you must relogin (using "tsh login" on a terminal) before continue using this
tool. After that, there is no need to update or relaunch the MCP client - just
try using it again.`
// LocalProxyConnectionErrorMessage is the message returned to the MCP client when
// the database client cannot connect to the local proxy.
LocalProxyConnectionErrorMessage = `Teleport MCP server is having issue while
establishing the database connection. You can verify the MCP logs for more
details on what is causing this issue. After identifying and fixing the issue
a restart on the MCP client might be necessary.`
// EmptyDatabasesListErrorMessage is the message returned to the MCP client when
// the started database server is serving no databases.
EmptyDatabasesListErrorMessage = `There are no active Teleport databases available
for use on the MCP server. You can check the MCP server logs to see if any
database was not included due to an error. You can also verify that the list
of databases on the MCP command is correct.`
)

var (
// WrongDatabaseURIFormatError is the message returned to the MCP client
// when it sends a malformed database resource URI.
WrongDatabaseURIFormatError = trace.BadParameter("Malformed database resource URI. Database resources must follow the format: %q", mcp.SampleDatabaseResource)
// DatabaseNotFoundError is the message returned to the MCP client when the
// requested database is not available as MCP resource.
DatabaseNotFoundError = trace.NotFound(`Database not found. Only registered databases
can be used. Ask the user to attach the database resource or list the available
resources with %q tool`, listDatabasesToolName)
)
107 changes: 107 additions & 0 deletions lib/client/db/mcp/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package mcp

import (
"context"
"log/slog"
"net"

"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/client/mcp"
)

// NewServerConfig configuration passed to the server constructors.
type NewServerConfig struct {
Logger *slog.Logger
RootServer *RootServer
Databases []*Database
}

// NewServerFunc the MCP server constructor function definition.
type NewServerFunc func(context.Context, *NewServerConfig) (Server, error)

// Server represents a MCP server.
type Server interface {
// Close closes the server.
Close(context.Context) error
}

// Registry represents the available databases MCP servers per protocol and
// their constructors.
type Registry map[string]NewServerFunc

// IsSupported returns if the database protocol is supported by any MCP server
// available.
func (m Registry) IsSupported(protocol string) bool {
_, ok := m[protocol]
return ok
}

// LookupFunc is the function used to resolve database address. Follows the
// net.Resolver.LookupAddr format.
type LookupFunc func(ctx context.Context, host string) (addrs []string, err error)

// DialContextFunc is a function used to dial the database. Follows the
// net.Dialer.DialContext format.
type DialContextFunc func(ctx context.Context, network string, addr string) (net.Conn, error)

// Database the database served by an MCP server.
type Database struct {
// DB contains all information from the database.
DB types.Database
// ClusterName is the cluster name where the database is located.
ClusterName string
// Addr is the address the MCP server used to create a new database
// connection.
Addr string
// DatabaseUser is the database username used on the connections.
DatabaseUser string
// DatabaseName is the database name used on the connections.
DatabaseName string
// ExternalErrorRetriever used to retrieve any external error that might
// have happened while connecting/communicating with the database.
ExternalErrorRetriever ExternalErrorRetriever
// LookupFunc is the lookup function to resolve database address.
LookupFunc LookupFunc
// DialContextFunc is the dial function used to connect to the database.
DialContextFunc DialContextFunc
}

// ResourceURI returns the database MCP resource URI.
func (d Database) ResourceURI() mcp.ResourceURI {
return mcp.NewDatabaseResourceURI(d.ClusterName, d.DB.GetName())
}

// DatabaseResource MCP resource representation of a Teleport database.
type DatabaseResource struct {
types.Metadata
// URI is the MCP URI resource.
URI string `json:"uri"`
// Protocol is the database protocol.
Protocol string `json:"protocol"`
// ClusterName is the cluster the database is.
ClusterName string `json:"cluster_name"`
}

// ToolName generates a database access tool name.
func ToolName(protocol, name string) string {
return ToolPrefix + protocol + "_" + name
}

// ToolPrefix is the default tool prefix for every MCP tool.
const ToolPrefix = "teleport_"
154 changes: 154 additions & 0 deletions lib/client/db/mcp/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package mcp

import (
"context"
"fmt"
"io"
"log/slog"
"sync"

"github.com/ghodss/yaml"
"github.com/gravitational/trace"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"

"github.com/gravitational/teleport"
)

// listDatabasesTool is the MCP tool that list all databases being served
// (from all protocols).
var listDatabasesTool = mcp.NewTool(listDatabasesToolName,
mcp.WithDescription("List database resources available to be used with Teleport tools."),
)

// RootServer database access root MCP server. It includes common MCP tools and
// resources across different databases and serves as a root server where
// database-specific MCP servers register their tools.
type RootServer struct {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm wondering - as we want to start exposing more resources and APIs over MCP, would it make sense to have a common root MCP server that exposes all tools/functions? And have all these sub-packages be able to register new tools with it. Right now this is specific to database access.

Copy link
Copy Markdown
Contributor

@greedy52 greedy52 May 29, 2025

Choose a reason for hiding this comment

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

we had some discussions on this too but didn't come into any conclusion.

Cloudflare for example, does divide their servers by functionalities:
https://mcpservers.org/servers/cloudflare/mcp-server-cloudflare

personally i think all-in-one teleport mcp is very cluttered. but I may not necessarily separate them by resource type like db vs ssh. we could also do something similar to WebUI like "zero trust access" vs "identity security" (but could be cumbersome if some shared functionality).

*mcpserver.MCPServer

mu sync.RWMutex
logger *slog.Logger
availableDatabases map[string]*Database
}

// NewRootServer initializes a new root MCP server.
func NewRootServer(logger *slog.Logger) *RootServer {
server := &RootServer{
MCPServer: mcpserver.NewMCPServer(serverName, teleport.Version),
logger: logger,
availableDatabases: make(map[string]*Database),
}
server.AddTool(listDatabasesTool, server.ListDatabases)

return server
}

// ListDatabases tool function used to list all available/served databases.
func (s *RootServer) ListDatabases(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.mu.RLock()
defer s.mu.RUnlock()

if len(s.availableDatabases) == 0 {
return mcp.NewToolResultError(EmptyDatabasesListErrorMessage), nil
}

var res []mcp.Content
for _, db := range s.availableDatabases {
Comment thread
gabrielcorado marked this conversation as resolved.
contents, err := encodeDatabaseResource(db)
if err != nil {
s.logger.ErrorContext(ctx, "error while list databases", "error", err)
return mcp.NewToolResultError(FormatErrorMessage(nil, err).Error()), nil
}
res = append(res, mcp.EmbeddedResource{Type: "resource", Resource: contents})
}

return &mcp.CallToolResult{
Content: res,
}, nil
}

// GetDatabaseResource resource handler for databases.
func (s *RootServer) GetDatabaseResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
s.mu.RLock()
defer s.mu.RUnlock()

db, ok := s.availableDatabases[request.Params.URI]
if !ok {
return nil, trace.NotFound("Database is %q not available as MCP resource", request.Params.URI)
}

encodedDb, err := encodeDatabaseResource(db)
if err != nil {
return nil, trace.Wrap(err)
}

return []mcp.ResourceContents{encodedDb}, nil
}

// RegisterDatabase register a database on the root server. This make it
// available as a MCP resource.
Comment thread
gabrielcorado marked this conversation as resolved.
//
// TODO(gabrielcorado): support dynamically registering/deregistering databases
// after the server starts.
func (s *RootServer) RegisterDatabase(db *Database) {
s.mu.Lock()
defer s.mu.Unlock()

uri := db.ResourceURI().String()
s.availableDatabases[uri] = db
Comment thread
gabrielcorado marked this conversation as resolved.
s.AddResource(mcp.NewResource(uri, fmt.Sprintf("%s Datatabase", db.DB.GetName()), mcp.WithMIMEType(databaseResourceMIMEType)), s.GetDatabaseResource)
}

// ServeStdio starts serving the root MCP using STDIO transport.
func (s *RootServer) ServeStdio(ctx context.Context, in io.Reader, out io.Writer) error {
return trace.Wrap(mcpserver.NewStdioServer(s.MCPServer).Listen(ctx, in, out))
}

func buildDatabaseResource(db *Database) DatabaseResource {
return DatabaseResource{
Comment thread
greedy52 marked this conversation as resolved.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would it make sense to include the database type (and possibly more metadata) here as well? E.g. to let LLM better differentiate between, say, self-hosted and RDS databases. Not a blocker though, I think we can keep tweaking this post-release.

Metadata: db.DB.GetMetadata(),
URI: db.ResourceURI().String(),
Protocol: db.DB.GetProtocol(),
ClusterName: db.ClusterName,
}
}

func encodeDatabaseResource(db *Database) (mcp.ResourceContents, error) {
resource := buildDatabaseResource(db)
out, err := yaml.Marshal(resource)
if err != nil {
return nil, trace.Wrap(err)
}

return mcp.TextResourceContents{
URI: resource.URI,
MIMEType: databaseResourceMIMEType,
Text: string(out),
}, nil
}

const (
// serverName is the database MCP server name.
serverName = "teleport_databases"
// listDatabasesTool is the list databases tool name.
listDatabasesToolName = ToolPrefix + "list_databases"
// databaseResourceMIMEType is the MIME type of the database resources.
databaseResourceMIMEType = "application/yaml"
)
Loading
Loading