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 \