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
3 changes: 3 additions & 0 deletions constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ const (
// ComponentForwardingGit represents the SSH proxy that forwards Git commands.
ComponentForwardingGit = "git:forward"

// ComponentMCP represents the MCP server handler.
ComponentMCP = "mcp"

// VerboseLogsEnvVar forces all logs to be verbose (down to DEBUG level)
VerboseLogsEnvVar = "TELEPORT_DEBUG"

Expand Down
5 changes: 5 additions & 0 deletions integration/appaccess/appaccess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import (
"github.com/gravitational/teleport/lib/service"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/srv/app/common"
libmcp "github.com/gravitational/teleport/lib/srv/mcp"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/web/app"
)
Expand All @@ -57,6 +58,8 @@ import (
// It allows to make the entire cluster set up once, instead of per test,
// which speeds things up significantly.
func TestAppAccess(t *testing.T) {
t.Setenv(libmcp.InMemoryServerEnvVar, "true")

pack := Setup(t)

t.Run("Forward", bind(pack, testForward))
Expand All @@ -71,6 +74,8 @@ func TestAppAccess(t *testing.T) {
t.Run("NoHeaderOverrides", bind(pack, testNoHeaderOverrides))
t.Run("AuditEvents", bind(pack, testAuditEvents))

t.Run("MCP", bind(pack, testMCP))

// This test should go last because it stops/starts app servers.
t.Run("TestAppServersHA", bind(pack, testServersHA))
}
Expand Down
76 changes: 76 additions & 0 deletions integration/appaccess/mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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 appaccess

import (
"bytes"
"context"
"io"
"testing"

mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"

libmcp "github.com/gravitational/teleport/lib/srv/mcp"
)

func testMCP(pack *Pack, t *testing.T) {
t.Run("DialMCPServer stdio no server found", func(t *testing.T) {
testMCPDialStdioNoServerFound(t, pack)
})

t.Run("DialMCPSererver stdio success", func(t *testing.T) {
testMCPDialStdio(t, pack)
})
}

func testMCPDialStdioNoServerFound(t *testing.T, pack *Pack) {
require.NoError(t, pack.tc.SaveProfile(false))

_, err := pack.tc.DialMCPServer(context.Background(), "not-found")
require.Error(t, err)
}

func testMCPDialStdio(t *testing.T, pack *Pack) {
require.NoError(t, pack.tc.SaveProfile(false))

serverConn, err := pack.tc.DialMCPServer(context.Background(), libmcp.InMemoryServerName)
require.NoError(t, err)

ctx := context.Background()
clientTransport := transport.NewIO(serverConn, serverConn, io.NopCloser(bytes.NewReader(nil)))
stdioClient := mcpclient.NewClient(clientTransport)
defer stdioClient.Close()
require.NoError(t, stdioClient.Start(ctx))

initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{
Name: "test-client",
Version: "1.0.0",
}
_, err = stdioClient.Initialize(ctx, initReq)
require.NoError(t, err)

listTools, err := stdioClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.Len(t, listTools.Tools, 2)
}
8 changes: 8 additions & 0 deletions integration/appaccess/pack.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"net"
"net/http"
"net/url"
"os"
"testing"
"time"

Expand Down Expand Up @@ -57,6 +58,7 @@ import (
"github.com/gravitational/teleport/lib/srv/alpnproxy"
alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common"
"github.com/gravitational/teleport/lib/srv/app/common"
libmcp "github.com/gravitational/teleport/lib/srv/mcp"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/web"
"github.com/gravitational/teleport/lib/web/app"
Expand Down Expand Up @@ -1062,6 +1064,12 @@ func (p *Pack) startLeafAppServers(t *testing.T, count int, opts AppTestOptions)
}

func waitForAppRegInRemoteSiteCache(t *testing.T, tunnel reversetunnelclient.Server, clusterName string, cfgApps []servicecfg.App, hostUUID string) {
if os.Getenv(libmcp.InMemoryServerEnvVar) == "true" {
cfgApps = append(cfgApps, servicecfg.App{
Name: libmcp.InMemoryServerName,
})
}

require.EventuallyWithT(t, func(t *assert.CollectT) {
site, err := tunnel.GetSite(clusterName)
assert.NoError(t, err)
Expand Down
4 changes: 4 additions & 0 deletions integrations/terraform/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4=
github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
Expand Down Expand Up @@ -1310,6 +1312,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Expand Down
99 changes: 99 additions & 0 deletions lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import (
"github.com/gravitational/teleport/api/utils/grpc/interceptors"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
"github.com/gravitational/teleport/api/utils/pingconn"
"github.com/gravitational/teleport/api/utils/prompt"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/auth/touchid"
Expand Down Expand Up @@ -5462,3 +5463,101 @@ func (tc *TeleportClient) HeadlessApprove(ctx context.Context, headlessAuthentic
err = rootClient.UpdateHeadlessAuthenticationState(ctx, headlessAuthenticationID, types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_APPROVED, mfaResp)
return trace.Wrap(err)
}

// DialALPN dials the Proxy with provided client certificate and ALPN protocol.
func (tc *TeleportClient) DialALPN(ctx context.Context, clientCert tls.Certificate, protocol alpncommon.Protocol) (net.Conn, error) {
ctx, span := tc.Tracer.Start(
ctx,
"teleportClient/DialALPN",
oteltrace.WithSpanKind(oteltrace.SpanKindClient),
oteltrace.WithAttributes(
attribute.String("protocol", string(protocol)),
),
)
defer span.End()

dialConfig := client.ALPNDialerConfig{
ALPNConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired,
TLSConfig: &tls.Config{
NextProtos: alpncommon.ProtocolToStringsWithPing(protocol),
InsecureSkipVerify: tc.InsecureSkipVerify,
Certificates: []tls.Certificate{clientCert},
},
GetClusterCAs: tc.RootClusterCACertPool,
}

tlsConn, err := client.DialALPN(ctx, tc.WebProxyAddr, dialConfig)
if err != nil {
return nil, trace.Wrap(err)
}
if alpncommon.IsPingProtocol(alpncommon.Protocol(tlsConn.ConnectionState().NegotiatedProtocol)) {
return pingconn.NewTLS(tlsConn), nil
}
return tlsConn, nil
}

// DialMCPServer makes a connection to the remote MCP server.
func (tc *TeleportClient) DialMCPServer(ctx context.Context, appName string) (net.Conn, error) {
ctx, span := tc.Tracer.Start(
ctx,
"teleportClient/DialMCPServer",
oteltrace.WithSpanKind(oteltrace.SpanKindClient),
oteltrace.WithAttributes(
attribute.String("app", appName),
),
)
defer span.End()

apps, err := tc.ListApps(ctx, &proto.ListResourcesRequest{
ResourceType: types.KindAppServer,
Namespace: apidefaults.Namespace,
PredicateExpression: fmt.Sprintf("name == %q", strings.TrimSpace(appName)),
})
if err != nil {
return nil, trace.Wrap(err)
}
switch len(apps) {
case 0:
return nil, trace.NotFound("no MCP servers found")
case 1:
default:
log.WarnContext(ctx, "multiple apps found, using the first one")
}
if !apps[0].IsMCP() {
return nil, trace.BadParameter("app %q is not a MCP server", appName)
}

cert, err := tc.issueMCPCertWithMFA(ctx, apps[0])
if err != nil {
return nil, trace.Wrap(err)
}
return tc.DialALPN(ctx, cert, alpncommon.ProtocolMCP)
}

func (tc *TeleportClient) issueMCPCertWithMFA(ctx context.Context, mcpServer types.Application) (tls.Certificate, error) {
profile, err := tc.ProfileStatus()
if err != nil {
return tls.Certificate{}, trace.Wrap(err)
}

appCertParams := ReissueParams{
RouteToCluster: tc.SiteName,
RouteToApp: proto.RouteToApp{
Name: mcpServer.GetName(),
PublicAddr: mcpServer.GetPublicAddr(),
ClusterName: tc.SiteName,
URI: mcpServer.GetURI(),
},
AccessRequests: profile.ActiveRequests,
}

// Do NOT write the keyring to avoid race condition when AI clients run
// multiple tsh at the same time.
keyRing, err := tc.IssueUserCertsWithMFA(ctx, appCertParams)
if err != nil {
return tls.Certificate{}, trace.Wrap(err)
}

cert, err := keyRing.AppTLSCert(mcpServer.GetName())
return cert, trace.Wrap(err)
}
14 changes: 14 additions & 0 deletions lib/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ import (
"github.com/gravitational/teleport/lib/srv/db"
"github.com/gravitational/teleport/lib/srv/desktop"
"github.com/gravitational/teleport/lib/srv/ingress"
"github.com/gravitational/teleport/lib/srv/mcp"
"github.com/gravitational/teleport/lib/srv/regular"
"github.com/gravitational/teleport/lib/srv/transport/transportv1"
"github.com/gravitational/teleport/lib/sshutils"
Expand Down Expand Up @@ -5045,11 +5046,16 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error {

// Register ALPN handler that will be accepting connections for plain
// TCP applications.
// Use the same handler for MCP protocols, for now.
if alpnRouter != nil {
alpnRouter.Add(alpnproxy.HandlerDecs{
MatchFunc: alpnproxy.MatchByProtocol(alpncommon.ProtocolTCP),
Handler: webServer.HandleConnection,
})
alpnRouter.Add(alpnproxy.HandlerDecs{
MatchFunc: alpnproxy.MatchByProtocol(alpncommon.ProtocolMCP),
Handler: webServer.HandleConnection,
})
}

var peerAddrString string
Expand Down Expand Up @@ -6174,6 +6180,14 @@ func (process *TeleportProcess) initApps() {
applications = append(applications, a)
}

if os.Getenv(mcp.InMemoryServerEnvVar) == "true" {
if mcpInMemoryServer, err := mcp.NewInMemoryServerApp(); err != nil {
logger.ErrorContext(process.ExitContext(), "Failed to create in-memory MCP server app")
} else {
applications = append(applications, mcpInMemoryServer)
}
}

lockWatcher, err := services.NewLockWatcher(process.ExitContext(), services.LockWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Component: teleport.ComponentApp,
Expand Down
4 changes: 4 additions & 0 deletions lib/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ func TestSetupProxyTLSConfig(t *testing.T) {
"h2",
"acme-tls/1",
"teleport-tcp-ping",
"teleport-mcp-ping",
"teleport-postgres-ping",
"teleport-mysql-ping",
"teleport-mongodb-ping",
Expand All @@ -851,6 +852,7 @@ func TestSetupProxyTLSConfig(t *testing.T) {
"teleport-proxy-ssh-grpc",
"teleport-proxy-grpc",
"teleport-proxy-grpc-mtls",
"teleport-mcp",
"teleport-postgres",
"teleport-mysql",
"teleport-mongodb",
Expand All @@ -871,6 +873,7 @@ func TestSetupProxyTLSConfig(t *testing.T) {
acmeEnabled: false,
wantNextProtos: []string{
"teleport-tcp-ping",
"teleport-mcp-ping",
"teleport-postgres-ping",
"teleport-mysql-ping",
"teleport-mongodb-ping",
Expand All @@ -894,6 +897,7 @@ func TestSetupProxyTLSConfig(t *testing.T) {
"teleport-proxy-ssh-grpc",
"teleport-proxy-grpc",
"teleport-proxy-grpc-mtls",
"teleport-mcp",
"teleport-postgres",
"teleport-mysql",
"teleport-mongodb",
Expand Down
8 changes: 8 additions & 0 deletions lib/services/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ func RoleWithVersionForUser(u types.User, v string) types.Role {
KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseServiceLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
MCP: &types.MCPPermissions{
Tools: []string{types.Wildcard},
},
Rules: []types.Rule{
types.NewRule(types.KindRole, RW()),
types.NewRule(types.KindAuthConnector, RW()),
Expand Down Expand Up @@ -612,6 +615,11 @@ func ApplyTraits(r types.Role, traits map[string][]string) (types.Role, error) {
outCond.Roles = apiutils.Deduplicate(outCond.Roles)
outCond.Where = inCond.Where
r.SetImpersonateConditions(condition, outCond)

if mcp := r.GetMCPPermissions(condition); mcp != nil {
mcp.Tools = applyValueTraitsSlice(mcp.Tools, traits, "mcp.tools")
r.SetMCPPermissions(condition, mcp)
}
}

return r, nil
Expand Down
Loading
Loading