diff --git a/api/types/app.go b/api/types/app.go
index ae87481431006..0ddf8512f988f 100644
--- a/api/types/app.go
+++ b/api/types/app.go
@@ -525,6 +525,10 @@ func (a *AppV3) checkMCP() error {
}
func (a *AppV3) checkMCPStdio() error {
+ // Skip validation for internal demo resource.
+ if resourceType, _ := a.GetLabel(TeleportInternalResourceType); resourceType == DemoResource {
+ return nil
+ }
if a.Spec.MCP == nil {
return trace.BadParameter("MCP server %q is missing 'mcp' spec", a.GetName())
}
diff --git a/api/types/app_test.go b/api/types/app_test.go
index afb494f376082..3d47af4ad11b4 100644
--- a/api/types/app_test.go
+++ b/api/types/app_test.go
@@ -658,6 +658,34 @@ func TestNewAppV3(t *testing.T) {
},
wantErr: require.Error,
},
+ {
+ name: "mcp demo",
+ meta: Metadata{
+ Name: "teleport-mcp-demo",
+ Labels: map[string]string{
+ TeleportInternalResourceType: DemoResource,
+ },
+ },
+ spec: AppSpecV3{
+ URI: "mcp+stdio://teleport-mcp-demo",
+ },
+ want: &AppV3{
+ Kind: "app",
+ SubKind: "mcp",
+ Version: "v3",
+ Metadata: Metadata{
+ Name: "teleport-mcp-demo",
+ Namespace: "default",
+ Labels: map[string]string{
+ TeleportInternalResourceType: DemoResource,
+ },
+ },
+ Spec: AppSpecV3{
+ URI: "mcp+stdio://teleport-mcp-demo",
+ },
+ },
+ wantErr: require.NoError,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff --git a/api/types/constants.go b/api/types/constants.go
index a9b3b8dce22a9..4e63872b08fb7 100644
--- a/api/types/constants.go
+++ b/api/types/constants.go
@@ -1155,10 +1155,15 @@ const (
// should not change these resources.
SystemResource = "system"
- // PresetResource are resources resources will be created if they don't exist. Updates may be applied
+ // PresetResource are resources that will be created if they don't exist. Updates may be applied
// to them, but user changes to these resources will be preserved.
PresetResource = "preset"
+ // DemoResource are resources that demonstrates specific Teleport features.
+ // These resources are typically managed internally by Teleport and enabled
+ // via flags. Users should not change these resources.
+ DemoResource = "demo"
+
// ProxyGroupIDLabel is the internal-use label for proxy heartbeats that's
// used by reverse tunnel agents to keep track of multiple independent sets
// of proxies in proxy peering mode.
diff --git a/integration/appaccess/appaccess_test.go b/integration/appaccess/appaccess_test.go
index d29c46746342e..92d2c6d545264 100644
--- a/integration/appaccess/appaccess_test.go
+++ b/integration/appaccess/appaccess_test.go
@@ -48,7 +48,6 @@ 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"
)
@@ -58,8 +57,6 @@ 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))
diff --git a/integration/appaccess/mcp_test.go b/integration/appaccess/mcp_test.go
index edf1efc6b3438..782927e207bd8 100644
--- a/integration/appaccess/mcp_test.go
+++ b/integration/appaccess/mcp_test.go
@@ -34,7 +34,7 @@ func testMCP(pack *Pack, t *testing.T) {
testMCPDialStdioNoServerFound(t, pack)
})
- t.Run("DialMCPSererver stdio success", func(t *testing.T) {
+ t.Run("DialMCPServer stdio success", func(t *testing.T) {
testMCPDialStdio(t, pack)
})
}
@@ -49,7 +49,7 @@ func testMCPDialStdioNoServerFound(t *testing.T, pack *Pack) {
func testMCPDialStdio(t *testing.T, pack *Pack) {
require.NoError(t, pack.tc.SaveProfile(false))
- serverConn, err := pack.tc.DialMCPServer(context.Background(), libmcp.InMemoryServerName)
+ serverConn, err := pack.tc.DialMCPServer(context.Background(), libmcp.DemoServerName)
require.NoError(t, err)
ctx := context.Background()
@@ -60,5 +60,5 @@ func testMCPDialStdio(t *testing.T, pack *Pack) {
listTools, err := stdioClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
- require.Len(t, listTools.Tools, 2)
+ require.Len(t, listTools.Tools, 3)
}
diff --git a/integration/appaccess/pack.go b/integration/appaccess/pack.go
index a761b6576c0d9..a4eab090fa2b1 100644
--- a/integration/appaccess/pack.go
+++ b/integration/appaccess/pack.go
@@ -29,7 +29,7 @@ import (
"net"
"net/http"
"net/url"
- "os"
+ "slices"
"testing"
"time"
@@ -58,8 +58,8 @@ 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"
+ sliceutils "github.com/gravitational/teleport/lib/utils/slices"
"github.com/gravitational/teleport/lib/web"
"github.com/gravitational/teleport/lib/web/app"
websession "github.com/gravitational/teleport/lib/web/session"
@@ -772,6 +772,7 @@ func (p *Pack) startRootAppServers(t *testing.T, count int, opts AppTestOptions)
raConf.Proxy.Enabled = false
raConf.SSH.Enabled = false
raConf.Apps.Enabled = true
+ raConf.Apps.MCPDemoServer = true
raConf.CircuitBreakerConfig = breaker.NoopBreakerConfig()
raConf.Apps.MonitorCloseChannel = opts.MonitorCloseChannel
raConf.Apps.Apps = append([]servicecfg.App{
@@ -1064,12 +1065,6 @@ 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)
@@ -1080,12 +1075,12 @@ func waitForAppRegInRemoteSiteCache(t *testing.T, tunnel reversetunnelclient.Ser
apps, err := ap.GetApplicationServers(context.Background(), apidefaults.Namespace)
assert.NoError(t, err)
- counter := 0
- for _, v := range apps {
- if v.GetHostID() == hostUUID {
- counter++
- }
- }
- assert.Len(t, cfgApps, counter)
+ wantNames := sliceutils.Map(cfgApps, func(app servicecfg.App) string {
+ return app.Name
+ })
+ assert.Subset(t,
+ slices.Collect(types.ResourceNames(apps)),
+ wantNames,
+ )
}, time.Minute*2, time.Millisecond*200)
}
diff --git a/lib/config/configuration.go b/lib/config/configuration.go
index 7a887979e8a2c..77eb094deb48c 100644
--- a/lib/config/configuration.go
+++ b/lib/config/configuration.go
@@ -143,6 +143,9 @@ type CommandLineFlags struct {
// AppPublicAddr is the public address of the application to proxy.
AppPublicAddr string
+ // MCPDemoServer enables the "Teleport Demo" MCP server.
+ MCPDemoServer bool
+
// DatabaseName is the name of the database to proxy.
DatabaseName string
// DatabaseDescription is a free-form database description.
@@ -1924,6 +1927,9 @@ func applyAppsConfig(fc *FileConfig, cfg *servicecfg.Config) error {
// Enable debugging application if requested.
cfg.Apps.DebugApp = fc.Apps.DebugApp
+ // Enable the "Teleport Demo" MCP server if requested.
+ cfg.Apps.MCPDemoServer = fc.Apps.MCPDemoServer
+
// Configure resource watcher selectors if present.
for _, matcher := range fc.Apps.ResourceMatchers {
if matcher.AWS.AssumeRoleARN != "" {
@@ -2400,7 +2406,7 @@ func Configure(clf *CommandLineFlags, cfg *servicecfg.Config, legacyAppFlags boo
// If this process is trying to join a cluster as an application service,
// make sure application name and URI are provided.
if slices.Contains(splitRoles(clf.Roles), defaults.RoleApp) {
- if (clf.AppName == "") && (clf.AppURI == "" && clf.AppCloud == "") {
+ if (clf.AppName == "") && (clf.AppURI == "" && clf.AppCloud == "" && !clf.MCPDemoServer) {
// TODO: remove legacyAppFlags once `teleport start --app-name` is removed.
if legacyAppFlags {
return trace.BadParameter("application name (--app-name) and URI (--app-uri) flags are both required to join application proxy to the cluster")
@@ -2408,14 +2414,14 @@ func Configure(clf *CommandLineFlags, cfg *servicecfg.Config, legacyAppFlags boo
return trace.BadParameter("to join application proxy to the cluster provide application name (--name) and either URI (--uri) or Cloud type (--cloud)")
}
- if clf.AppName == "" {
+ if clf.AppName == "" && !clf.MCPDemoServer {
if legacyAppFlags {
return trace.BadParameter("application name (--app-name) is required to join application proxy to the cluster")
}
return trace.BadParameter("to join application proxy to the cluster provide application name (--name)")
}
- if clf.AppURI == "" && clf.AppCloud == "" {
+ if clf.AppName != "" && clf.AppURI == "" && clf.AppCloud == "" {
if legacyAppFlags {
return trace.BadParameter("URI (--app-uri) flag is required to join application proxy to the cluster")
}
@@ -2423,6 +2429,13 @@ func Configure(clf *CommandLineFlags, cfg *servicecfg.Config, legacyAppFlags boo
}
}
+ // Enable the "Teleport Demo" MCP server if requested. Make sure application
+ // service is enabled for proxying MCP servers.
+ if clf.MCPDemoServer {
+ cfg.Apps.MCPDemoServer = true
+ cfg.Apps.Enabled = true
+ }
+
// If application name was specified on command line, add to file
// configuration where it will be validated.
if clf.AppName != "" {
diff --git a/lib/config/configuration_test.go b/lib/config/configuration_test.go
index 722e527b2edc6..09fed23a5a39f 100644
--- a/lib/config/configuration_test.go
+++ b/lib/config/configuration_test.go
@@ -2572,7 +2572,10 @@ func TestAppsCLF(t *testing.T) {
inAppURI string
inAppCloud string
inLegacyAppFlags bool
+ inMCPDemoServer bool
+
outApps []servicecfg.App
+ outMCPDemoServer bool
requireError require.ErrorAssertionFunc
}{
{
@@ -2707,20 +2710,32 @@ func TestAppsCLF(t *testing.T) {
require.ErrorContains(t, err, "missing application \"foo\" URI")
},
},
+ {
+ desc: "mcp demo server",
+ inRoles: defaults.RoleApp,
+ inAppName: "",
+ inAppURI: "",
+ inMCPDemoServer: true,
+ outApps: nil,
+ outMCPDemoServer: true,
+ requireError: require.NoError,
+ },
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
clf := CommandLineFlags{
- Roles: tt.inRoles,
- AppName: tt.inAppName,
- AppURI: tt.inAppURI,
- AppCloud: tt.inAppCloud,
+ Roles: tt.inRoles,
+ AppName: tt.inAppName,
+ AppURI: tt.inAppURI,
+ AppCloud: tt.inAppCloud,
+ MCPDemoServer: tt.inMCPDemoServer,
}
cfg := servicecfg.MakeDefaultConfig()
err := Configure(&clf, cfg, tt.inLegacyAppFlags)
tt.requireError(t, err)
require.Equal(t, tt.outApps, cfg.Apps.Apps)
+ require.Equal(t, tt.outMCPDemoServer, cfg.Apps.MCPDemoServer)
})
}
}
diff --git a/lib/config/fileconf.go b/lib/config/fileconf.go
index 64afba3a3793a..c77be7125e512 100644
--- a/lib/config/fileconf.go
+++ b/lib/config/fileconf.go
@@ -184,6 +184,8 @@ type SampleFlags struct {
AppName string
// AppURI is the internal address of the application to proxy
AppURI string
+ // MCPDemoServer enables the "Teleport Demo" MCP server.
+ MCPDemoServer bool
// NodeLabels is list of labels in the format `foo=bar,baz=bax` to add to newly created nodes.
NodeLabels string
// CAPin is the SKPI hash of the CA used to verify the Auth Server. Can be
@@ -410,17 +412,24 @@ func makeSampleProxyConfig(conf *servicecfg.Config, flags SampleFlags, enabled b
func makeSampleAppsConfig(conf *servicecfg.Config, flags SampleFlags, enabled bool) (Apps, error) {
var apps Apps
// assume users want app role if they added app name and/or uri but didn't add app role
- if enabled || flags.AppURI != "" || flags.AppName != "" {
- if flags.AppURI == "" || flags.AppName == "" {
- return Apps{}, trace.BadParameter("please provide both --app-name and --app-uri")
- }
-
+ if enabled || flags.AppURI != "" || flags.AppName != "" || flags.MCPDemoServer {
apps.EnabledFlag = "yes"
- apps.Apps = []*App{
- {
- Name: flags.AppName,
- URI: flags.AppURI,
- },
+ apps.MCPDemoServer = flags.MCPDemoServer
+
+ switch {
+ case flags.AppURI != "" && flags.AppName != "":
+ apps.Apps = []*App{
+ {
+ Name: flags.AppName,
+ URI: flags.AppURI,
+ },
+ }
+
+ case flags.MCPDemoServer && flags.AppURI == "" && flags.AppName == "":
+ // This is ok if only MCPDemoServer is set.
+
+ default:
+ return Apps{}, trace.BadParameter("please provide both --app-name and --app-uri")
}
}
@@ -2083,6 +2092,9 @@ type Apps struct {
// DebugApp turns on a header debugging application.
DebugApp bool `yaml:"debug_app"`
+ // MCPDemoServer enables the "Teleport Demo" MCP server.
+ MCPDemoServer bool `yaml:"mcp_demo_server"`
+
// Apps is a list of applications that will be run by this service.
Apps []*App `yaml:"apps"`
diff --git a/lib/config/fileconf_test.go b/lib/config/fileconf_test.go
index e64003a624002..85a47ac4f1354 100644
--- a/lib/config/fileconf_test.go
+++ b/lib/config/fileconf_test.go
@@ -1286,6 +1286,49 @@ func TestMakeSampleFileConfig(t *testing.T) {
require.Equal(t, "yes", fc.Apps.EnabledFlag)
})
+ t.Run("App role with MCP Demo server", func(t *testing.T) {
+ fc, err := MakeSampleFileConfig(SampleFlags{
+ Roles: "app",
+ MCPDemoServer: true,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "no", fc.SSH.EnabledFlag)
+ require.Equal(t, "no", fc.Proxy.EnabledFlag)
+ require.Equal(t, "no", fc.Auth.EnabledFlag)
+ require.Equal(t, "yes", fc.Apps.EnabledFlag)
+ require.True(t, fc.Apps.MCPDemoServer)
+ })
+
+ t.Run("App name and MCP Demo Server", func(t *testing.T) {
+ _, err := MakeSampleFileConfig(SampleFlags{
+ Roles: "app",
+ AppURI: "localhost:8080",
+ MCPDemoServer: true,
+ })
+ require.Error(t, err)
+
+ _, err = MakeSampleFileConfig(SampleFlags{
+ Roles: "app",
+ AppName: "nginx",
+ MCPDemoServer: true,
+ })
+ require.Error(t, err)
+
+ fc, err := MakeSampleFileConfig(SampleFlags{
+ Roles: "app",
+ AppURI: "localhost:8080",
+ AppName: "nginx",
+ MCPDemoServer: true,
+ })
+ require.NoError(t, err)
+
+ require.Equal(t, "no", fc.SSH.EnabledFlag)
+ require.Equal(t, "no", fc.Proxy.EnabledFlag)
+ require.Equal(t, "no", fc.Auth.EnabledFlag)
+ require.Equal(t, "yes", fc.Apps.EnabledFlag)
+ require.True(t, fc.Apps.MCPDemoServer)
+ })
+
t.Run("Proxy role", func(t *testing.T) {
fc, err := MakeSampleFileConfig(SampleFlags{
Roles: "proxy",
diff --git a/lib/service/service.go b/lib/service/service.go
index 54ebec7cb5839..c99cfbcb9cee4 100644
--- a/lib/service/service.go
+++ b/lib/service/service.go
@@ -6226,6 +6226,7 @@ func (process *TeleportProcess) initApps() {
// "app_service" section, that is considered enabling "app_service".
if len(process.Config.Apps.Apps) == 0 &&
!process.Config.Apps.DebugApp &&
+ !process.Config.Apps.MCPDemoServer &&
len(process.Config.Apps.ResourceMatchers) == 0 {
return
}
@@ -6363,11 +6364,11 @@ 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")
+ if process.Config.Apps.MCPDemoServer {
+ if mcpDemoServer, err := mcp.NewDemoServerApp(); err != nil {
+ logger.ErrorContext(process.ExitContext(), "Failed to create MCP demo server app")
} else {
- applications = append(applications, mcpInMemoryServer)
+ applications = append(applications, mcpDemoServer)
}
}
@@ -6440,6 +6441,7 @@ func (process *TeleportProcess) initApps() {
ConnectionMonitor: connMonitor,
ServiceComponent: teleport.ComponentApp,
Logger: logger,
+ MCPDemoServer: process.Config.Apps.MCPDemoServer,
})
if err != nil {
return trace.Wrap(err)
diff --git a/lib/service/servicecfg/app.go b/lib/service/servicecfg/app.go
index 7688d16a35fcf..e065181c5abdc 100644
--- a/lib/service/servicecfg/app.go
+++ b/lib/service/servicecfg/app.go
@@ -43,6 +43,9 @@ type AppsConfig struct {
// DebugApp enabled a header dumping debugging application.
DebugApp bool
+ // MCPDemoServer enables the "Teleport Demo" MCP server.
+ MCPDemoServer bool
+
// Apps is the list of applications that are being proxied.
Apps []App
diff --git a/lib/srv/app/connections_handler.go b/lib/srv/app/connections_handler.go
index 3b56cf3ddd62e..33948663e847b 100644
--- a/lib/srv/app/connections_handler.go
+++ b/lib/srv/app/connections_handler.go
@@ -113,6 +113,9 @@ type ConnectionsHandlerConfig struct {
// Logger is the slog.Logger.
Logger *slog.Logger
+
+ // MCPDemoServer enables the "Teleport Demo" MCP server.
+ MCPDemoServer bool
}
// CheckAndSetDefaults validates the config values and sets defaults.
@@ -276,10 +279,11 @@ func NewConnectionsHandler(closeContext context.Context, cfg *ConnectionsHandler
// Handle MCP servers.
c.mcpServer, err = mcp.NewServer(mcp.ServerConfig{
- Emitter: c.cfg.Emitter,
- ParentContext: c.closeContext,
- HostID: c.cfg.HostID,
- AccessPoint: c.cfg.AccessPoint,
+ Emitter: c.cfg.Emitter,
+ ParentContext: c.closeContext,
+ HostID: c.cfg.HostID,
+ AccessPoint: c.cfg.AccessPoint,
+ EnableDemoServer: c.cfg.MCPDemoServer,
})
if err != nil {
return nil, trace.Wrap(err)
diff --git a/lib/srv/mcp/demo.go b/lib/srv/mcp/demo.go
new file mode 100644
index 0000000000000..cd8f359a50935
--- /dev/null
+++ b/lib/srv/mcp/demo.go
@@ -0,0 +1,219 @@
+/*
+ * 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 .
+ */
+
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+
+ "github.com/gravitational/trace"
+ "github.com/mark3labs/mcp-go/mcp"
+ mcpserver "github.com/mark3labs/mcp-go/server"
+
+ "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/utils/mcputils"
+)
+
+const (
+ // DemoServerName is the name of the "Teleport Demo" MCP server.
+ DemoServerName = "teleport-mcp-demo"
+)
+
+// NewDemoServerApp returns the app definition for the "Teleport Demo" MCP
+// server.
+//
+// The purpose of the "Teleport Demo" MCP server is to provide a quick demo on
+// MCP access without the need for external environment setup on MCP
+// servers. This MCP server is in-memory only and uses stdio transport. Access
+// to this MCP server is the same as any other MCP server (`tsh`, RBAC, audit
+// events, etc.).
+func NewDemoServerApp() (types.Application, error) {
+ app, err := types.NewAppV3(types.Metadata{
+ Name: DemoServerName,
+ Labels: map[string]string{types.TeleportInternalResourceType: types.DemoResource},
+ Description: "A demo MCP server that shows current user and session information",
+ }, types.AppSpecV3{
+ URI: types.SchemaMCPStdio + DemoServerName,
+ })
+ return app, trace.Wrap(err)
+}
+
+func isDemoServerApp(app types.Application) bool {
+ labelValue, labelFound := app.GetLabel(types.TeleportInternalResourceType)
+ return labelFound && labelValue == types.DemoResource && app.GetName() == DemoServerName
+}
+
+func makeDemoServerRunner(ctx context.Context, session *sessionHandler) (stdioServerRunner, error) {
+ return makeInMemoryServerRunner(newDemoServer(ctx, session), session.logger)
+}
+
+func newDemoServer(_ context.Context, session *sessionHandler) *mcpserver.MCPServer {
+ demoServer := mcpserver.NewMCPServer(
+ "teleport-demo",
+ teleport.Version,
+ )
+
+ tools := []mcpserver.ServerTool{
+ {
+ Tool: mcp.NewTool(
+ "teleport_user_info",
+ mcp.WithDescription("Shows basic information about your Teleport user."),
+ ),
+ Handler: makeUserInfoToolHandler(session),
+ },
+ {
+ Tool: mcp.NewTool(
+ "teleport_session_info",
+ mcp.WithDescription("Shows information about this MCP session."),
+ ),
+ Handler: makeSessionInfoToolHandler(session),
+ },
+ {
+ Tool: mcp.NewTool(
+ "teleport_demo_info",
+ mcp.WithDescription("Shows information about this Teleport Demo MCP server."),
+ ),
+ Handler: makeDemoInfoToolHandler(),
+ },
+ }
+
+ demoServer.AddTools(tools...)
+ return demoServer
+}
+
+func makeUserInfoToolHandler(session *sessionHandler) mcpserver.ToolHandlerFunc {
+ return func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ data, err := json.Marshal(map[string]any{
+ "name": session.AuthCtx.User.GetName(),
+ "user_kind": session.makeUserMetadata().UserKind.String(),
+ "roles": session.AuthCtx.User.GetRoles(),
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return mcp.NewToolResultText(string(data)), nil
+ }
+}
+
+func makeSessionInfoToolHandler(session *sessionHandler) mcpserver.ToolHandlerFunc {
+ return func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ data, err := json.Marshal(map[string]any{
+ "teleport_cluster": session.Identity.RouteToApp.ClusterName,
+ "teleport_app_name": session.App.GetName(),
+ "teleport_app_description": session.App.GetDescription(),
+ "mcp_transport_type": types.GetMCPServerTransportType(session.App.GetURI()),
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return mcp.NewToolResultText(string(data)), nil
+ }
+}
+
+func makeDemoInfoToolHandler() mcpserver.ToolHandlerFunc {
+ text := `Teleport can provide secure connections to your MCP servers while
+improving both access control and visibility.
+
+This 'teleport-demo' MCP server is a demonstration that showcases how Teleport
+MCP access works.
+
+You can find this 'teleport-demo' server in the Teleport Web UI or by running
+'tsh mcp ls'.
+
+To connect to the demo server with stdio transport from your AI tool, use 'tsh
+mcp connect teleport-demo'. Or run 'tsh mcp config teleport-demo' for more
+configuration details.
+
+If you are an auditor, you can also find this MCP session and corresponding
+requests in the audit events.
+
+Available Tools from the demo server:
+- 'teleport_user_info': Shows basic information about your Teleport user.
+- 'teleport_session_info': Shows information about this MCP session.
+- 'teleport_demo_info' (this tool): Shows information about this Teleport Demo MCP server.
+
+You can restrict what tools a user can access by listing allowed MCP tools in
+the role spec 'role.allow.mcp.tools'.
+
+To learn more about enrolling MCP servers and additional reference materials, please visit:
+https://goteleport.com/docs/enroll-resources/mcp-access
+`
+ return func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ return mcp.NewToolResultText(text), nil
+ }
+}
+
+type inMemoryServerRunner struct {
+ serverStdin io.ReadCloser
+ serverStdout io.WriteCloser
+ writeToServer io.WriteCloser
+ readFromServer io.ReadCloser
+ mcpServer *mcpserver.MCPServer
+ log *slog.Logger
+}
+
+func makeInMemoryServerRunner(mcpServer *mcpserver.MCPServer, log *slog.Logger) (stdioServerRunner, error) {
+ if mcpServer == nil {
+ return nil, trace.BadParameter("mcpServer must not be nil")
+ }
+ if log == nil {
+ log = slog.Default()
+ }
+
+ serverStdin, writeToServer := io.Pipe()
+ readFromServer, serverStdout := io.Pipe()
+ return &inMemoryServerRunner{
+ serverStdin: serverStdin,
+ serverStdout: serverStdout,
+ writeToServer: writeToServer,
+ readFromServer: readFromServer,
+ mcpServer: mcpServer,
+ log: log,
+ }, nil
+}
+
+func (s *inMemoryServerRunner) getStdinPipe() (io.WriteCloser, error) {
+ return s.writeToServer, nil
+}
+
+func (s *inMemoryServerRunner) getStdoutPipe() (io.ReadCloser, error) {
+ return s.readFromServer, nil
+}
+
+func (s *inMemoryServerRunner) run(ctx context.Context) error {
+ s.log.DebugContext(ctx, "Running in-memory MCP server")
+ defer s.log.DebugContext(ctx, "Finished running in-memory MCP server")
+ err := mcpserver.NewStdioServer(s.mcpServer).Listen(ctx, s.serverStdin, s.serverStdout)
+ if err != nil && !mcputils.IsOKCloseError(err) {
+ return trace.Wrap(err)
+ }
+ return nil
+}
+
+func (s *inMemoryServerRunner) close() {
+ if err := s.writeToServer.Close(); err != nil {
+ s.log.DebugContext(context.Background(), "Failed to close pipe", "error", err)
+ }
+ if err := s.serverStdout.Close(); err != nil {
+ s.log.DebugContext(context.Background(), "Failed to close pipe", "error", err)
+ }
+}
diff --git a/lib/srv/mcp/helpers_test.go b/lib/srv/mcp/helpers_test.go
index 95e4b4e91688e..4511a2c91a060 100644
--- a/lib/srv/mcp/helpers_test.go
+++ b/lib/srv/mcp/helpers_test.go
@@ -284,6 +284,12 @@ func checkToolsListResponse(t *testing.T, response mcp.JSONRPCMessage, wantID mc
var result mcp.ListToolsResult
require.NoError(t, json.Unmarshal(mcpResponse.Result, &result))
+ checkToolsListResult(t, &result, wantTools)
+}
+
+func checkToolsListResult(t *testing.T, result *mcp.ListToolsResult, wantTools []string) {
+ t.Helper()
+ require.NotNil(t, result)
var actualNames []string
for _, tool := range result.Tools {
actualNames = append(actualNames, tool.Name)
diff --git a/lib/srv/mcp/memory.go b/lib/srv/mcp/memory.go
deleted file mode 100644
index 288190ff20c4f..0000000000000
--- a/lib/srv/mcp/memory.go
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * 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 .
- */
-
-package mcp
-
-import (
- "context"
- "log/slog"
-
- "github.com/gravitational/trace"
- "github.com/mark3labs/mcp-go/mcp"
- mcpserver "github.com/mark3labs/mcp-go/server"
-
- "github.com/gravitational/teleport/api/types"
-)
-
-const (
- // InMemoryServerEnvVar enables an in-memory MCP server for testing
- // purposes. The test app enables a stdio MCP server that has a
- // "teleport-hello-test" tool and a "teleport-echo-test" tool.
- InMemoryServerEnvVar = "TELEPORT_UNSTABLE_MCP_IN_MEMORY_SERVER"
-
- // InMemoryServerName is the name of the in-memory MCP server.
- InMemoryServerName = "teleport-mcp-test-server"
-)
-
-// NewInMemoryServerApp returns the app definition for the in-memory test server.
-func NewInMemoryServerApp() (types.Application, error) {
- app, err := types.NewAppV3(types.Metadata{
- Name: InMemoryServerName,
- Labels: map[string]string{
- types.TeleportInternalLabelPrefix + "mcp-in-memory-server": "true",
- },
- }, types.AppSpecV3{
- MCP: &types.MCP{
- Command: "in-memory-server",
- RunAsHostUser: "in-memory-server",
- },
- })
- return app, trace.Wrap(err)
-}
-
-func isInMemoryServerApp(app types.Application) bool {
- value, ok := app.GetLabel(types.TeleportInternalLabelPrefix + "mcp-in-memory-server")
- return ok && value == "true"
-}
-
-func (s *Server) handleInMemoryServerSession(ctx context.Context, sessionCtx SessionCtx) error {
- s.cfg.Log.DebugContext(ctx, "Started in-memory server session")
- defer s.cfg.Log.DebugContext(ctx, "Completed in-memory server session")
-
- session, err := s.makeSessionHandler(ctx, sessionCtx)
- if err != nil {
- return trace.Wrap(err)
- }
- session.emitStartEvent(s.cfg.ParentContext)
- defer session.emitEndEvent(s.cfg.ParentContext)
-
- // TODO(greedy52) audit log notification and requests.
- server := mcpserver.NewMCPServer("hello-test-server", "1.0.0")
- stdioServer := mcpserver.NewStdioServer(server)
- stdioServer.SetErrorLogger(slog.NewLogLogger(s.cfg.Log.Handler(), slog.LevelDebug))
-
- helloTool := mcp.NewTool("teleport-hello-test",
- mcp.WithDescription("this is simple hello test and it always return \"hello client\""),
- )
- if session.checkAccessToTool(ctx, helloTool.GetName()) == nil {
- server.AddTool(helloTool, func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- return &mcp.CallToolResult{
- Content: []mcp.Content{mcp.NewTextContent("hello client")},
- }, nil
- })
- }
-
- echoTool := mcp.NewTool("teleport-echo-test",
- mcp.WithDescription("this is simple echo and it always return the input back"),
- mcp.WithString("input", mcp.Required(), mcp.Description("input for echo")),
- )
- if session.checkAccessToTool(ctx, echoTool.GetName()) == nil {
- server.AddTool(echoTool, func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- input, err := request.RequireString("input")
- if err != nil {
- return nil, trace.Wrap(err)
- }
- return &mcp.CallToolResult{
- Content: []mcp.Content{mcp.NewTextContent(input)},
- }, nil
- })
- }
- return stdioServer.Listen(ctx, sessionCtx.ClientConn, sessionCtx.ClientConn)
-}
diff --git a/lib/srv/mcp/server.go b/lib/srv/mcp/server.go
index ea9fe2b3ac086..d43ff21113065 100644
--- a/lib/srv/mcp/server.go
+++ b/lib/srv/mcp/server.go
@@ -22,7 +22,6 @@ import (
"context"
"log/slog"
"net"
- "os"
"time"
"github.com/gravitational/trace"
@@ -54,9 +53,10 @@ type ServerConfig struct {
HostID string
// AccessPoint is a caching client connected to the Auth Server.
AccessPoint AccessPoint
+ // EnableDemoServer enables the "Teleport Demo" MCP server.
+ EnableDemoServer bool
- clock clockwork.Clock
- inMemoryServer bool
+ clock clockwork.Clock
}
// CheckAndSetDefaults checks values and sets defaults
@@ -79,7 +79,6 @@ func (c *ServerConfig) CheckAndSetDefaults() error {
if c.clock == nil {
c.clock = clockwork.NewRealClock()
}
- c.inMemoryServer = os.Getenv(InMemoryServerEnvVar) == "true"
return nil
}
@@ -104,8 +103,8 @@ func (s *Server) HandleSession(ctx context.Context, sessionCtx SessionCtx) error
if err := sessionCtx.checkAndSetDefaults(); err != nil {
return trace.Wrap(err)
}
- if s.cfg.inMemoryServer && isInMemoryServerApp(sessionCtx.App) {
- return trace.Wrap(s.handleInMemoryServerSession(ctx, sessionCtx))
+ if s.cfg.EnableDemoServer && isDemoServerApp(sessionCtx.App) {
+ return trace.Wrap(s.handleStdio(ctx, sessionCtx, makeDemoServerRunner))
}
return trace.Wrap(s.handleStdio(ctx, sessionCtx, makeExecServerRunner))
}
diff --git a/lib/srv/mcp/stdio_test.go b/lib/srv/mcp/stdio_test.go
index 302f0b0b393df..19a9078fdf7e7 100644
--- a/lib/srv/mcp/stdio_test.go
+++ b/lib/srv/mcp/stdio_test.go
@@ -20,15 +20,13 @@ package mcp
import (
"context"
- "io"
- "log/slog"
"os"
"path"
"testing"
"time"
"github.com/gravitational/trace"
- mcpserver "github.com/mark3labs/mcp-go/server"
+ "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -37,7 +35,6 @@ import (
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/events/eventstest"
"github.com/gravitational/teleport/lib/utils/mcptest"
- "github.com/gravitational/teleport/lib/utils/mcputils"
)
func Test_handleAuthErrStdio(t *testing.T) {
@@ -86,8 +83,8 @@ func Test_handleStdio(t *testing.T) {
handlerDoneCh := make(chan struct{}, 1)
defer close(handlerDoneCh)
go func() {
- // Use mock server.
- handlerErr := s.handleStdio(ctx, *testCtx.SessionCtx, makeMockMCPServerRunner)
+ // Use the demo server.
+ handlerErr := s.handleStdio(ctx, *testCtx.SessionCtx, makeDemoServerRunner)
handlerDoneCh <- struct{}{}
require.NoError(t, handlerErr)
}()
@@ -99,11 +96,27 @@ func Test_handleStdio(t *testing.T) {
_, ok := event.(*apievents.MCPSessionStart)
assert.True(collect, ok)
}, time.Second*5, time.Millisecond*100, "expect session start")
+
+ // Some basic tests on the demo server.
resp, err := mcptest.InitializeClient(ctx, stdioClient)
require.NoError(t, err)
- require.Equal(t, "test-server", resp.ServerInfo.Name)
+ require.Equal(t, "teleport-demo", resp.ServerInfo.Name)
- mcptest.MustCallServerTool(t, ctx, stdioClient)
+ listToolsResult, err := stdioClient.ListTools(ctx, mcp.ListToolsRequest{})
+ require.NoError(t, err)
+ checkToolsListResult(t, listToolsResult, []string{
+ "teleport_user_info",
+ "teleport_session_info",
+ "teleport_demo_info",
+ })
+
+ callToolResult, err := stdioClient.CallTool(ctx, mcp.CallToolRequest{
+ Params: mcp.CallToolParams{
+ Name: "teleport_user_info",
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, callToolResult.Content, 1)
// Now close the client.
stdioClient.Close()
@@ -117,52 +130,6 @@ func Test_handleStdio(t *testing.T) {
require.True(t, ok)
}
-func makeMockMCPServerRunner(context.Context, *sessionHandler) (stdioServerRunner, error) {
- serverStdin, writeToServer := io.Pipe()
- readFromServer, serverStdout := io.Pipe()
- return &mockStdioServerRunner{
- serverStdin: serverStdin,
- serverStdout: serverStdout,
- writeToServer: writeToServer,
- readFromServer: readFromServer,
- pipeClosers: []io.Closer{
- serverStdin, writeToServer,
- readFromServer, serverStdout,
- },
- }, nil
-}
-
-type mockStdioServerRunner struct {
- serverStdin io.ReadCloser
- serverStdout io.WriteCloser
- writeToServer io.WriteCloser
- readFromServer io.ReadCloser
- pipeClosers []io.Closer
-}
-
-func (s *mockStdioServerRunner) getStdinPipe() (io.WriteCloser, error) {
- return s.writeToServer, nil
-}
-
-func (s *mockStdioServerRunner) getStdoutPipe() (io.ReadCloser, error) {
- return s.readFromServer, nil
-}
-
-func (s *mockStdioServerRunner) run(ctx context.Context) error {
- slog.DebugContext(ctx, "running mock stdio server")
- err := mcpserver.NewStdioServer(mcptest.NewServer()).Listen(ctx, s.serverStdin, s.serverStdout)
- if err != nil && !mcputils.IsOKCloseError(err) {
- return trace.Wrap(err)
- }
- return nil
-}
-
-func (s *mockStdioServerRunner) close() {
- for _, pipeCloser := range s.pipeClosers {
- pipeCloser.Close()
- }
-}
-
// TestHandleSession_execMCPServer tests real server handler for stdio-based MCP
// server but requires docker installed locally. It will run the "everything"
// MCP server and "alpine".
diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go
index acfef8909cef5..40d9278fc7eeb 100644
--- a/tool/teleport/common/teleport.go
+++ b/tool/teleport/common/teleport.go
@@ -231,6 +231,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
appStartCmd.Flag("insecure", "Insecure mode disables certificate validation").BoolVar(&ccf.InsecureMode)
appStartCmd.Flag("skip-version-check", "Skip version checking between server and client.").Default("false").BoolVar(&ccf.SkipVersionCheck)
appStartCmd.Flag("no-debug-service", "Disables debug service.").BoolVar(&ccf.DisableDebugService)
+ appStartCmd.Flag("mcp-demo-server", "Enables the Teleport demo MCP server that shows current user and session information.").BoolVar(&ccf.MCPDemoServer)
appStartCmd.Alias(appUsageExamples) // We're using "alias" section to display usage examples.
// "teleport db" command and its subcommands
@@ -435,6 +436,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
dump.Flag("proxy", "Address of the proxy.").StringVar(&dumpFlags.ProxyAddress)
dump.Flag("app-name", "Name of the application to start when using app role.").StringVar(&dumpFlags.AppName)
dump.Flag("app-uri", "Internal address of the application to proxy.").StringVar(&dumpFlags.AppURI)
+ dump.Flag("mcp-demo-server", "Enables the Teleport demo MCP server that shows current user and session information.").BoolVar(&dumpFlags.MCPDemoServer)
dump.Flag("node-name", "Name for the Teleport node.").StringVar(&dumpFlags.NodeName)
dump.Flag("node-labels", "Comma-separated list of labels to add to newly created nodes, for example env=staging,cloud=aws.").StringVar(&dumpFlags.NodeLabels)
diff --git a/tool/teleport/common/usage.go b/tool/teleport/common/usage.go
index bc9ce79d18f0f..9ee7c634ac8e3 100644
--- a/tool/teleport/common/usage.go
+++ b/tool/teleport/common/usage.go
@@ -47,7 +47,13 @@ const (
--uri="http://localhost:8080" \
--labels=group=dev
Same as the above, but the app server runs with "group=dev" label which only
- allows access to users with the application label "group: dev" in an assigned role.`
+ allows access to users with the application label "group: dev" in an assigned role.
+
+> teleport app start --token=xyz --auth-server=proxy.example.com:3080 \
+ --mcp-demo-server
+ Runs a Teleport demo MCP server that shows current user and session
+ information.
+`
dbUsageExamples = `
> teleport db start --token=xyz --auth-server=proxy.example.com:3080 \