diff --git a/web/packages/shared/services/apps.test.ts b/web/packages/shared/services/apps.test.ts new file mode 100644 index 0000000000000..cfd65e101a704 --- /dev/null +++ b/web/packages/shared/services/apps.test.ts @@ -0,0 +1,50 @@ +/** + * 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 . + */ + +import { getAppProtocol, getAppUriScheme } from './apps'; +import type { AppProtocol } from './types'; + +describe('getAppProtocol', () => { + test.each<[string, AppProtocol]>([ + // TCP + ['tcp://localhost:8080', 'TCP'], + + // MCP + ['mcp+stdio://', 'MCP'], + ['mcp+http://example.com/mcp', 'MCP'], + ['mcp+sse+https://example.com/sse', 'MCP'], + + // HTTP (fallback/default) + ['http://localhost:8080', 'HTTP'], + ['https://localhost:8080', 'HTTP'], + ['cloud://AWS', 'HTTP'], + ])('%s is %s', (uri, expected) => { + expect(getAppProtocol(uri)).toBe(expected); + }); +}); + +describe('getAppUriScheme', () => { + test.each<[string, string]>([ + ['tcp://localhost:8080', 'tcp'], + ['https://localhost:8080', 'https'], + ['mcp+http://example.com/mcp', 'mcp+http'], + ['', ''], + ])('scheme from %s is %s', (uri, expected) => { + expect(getAppUriScheme(uri)).toBe(expected); + }); +}); diff --git a/web/packages/shared/services/apps.ts b/web/packages/shared/services/apps.ts index 75b4cdd465f3c..3c5570b3719be 100644 --- a/web/packages/shared/services/apps.ts +++ b/web/packages/shared/services/apps.ts @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import { AppProtocol } from 'shared/services/types'; export type AwsRole = { name: string; @@ -22,3 +23,25 @@ export type AwsRole = { display: string; accountId: string; }; + +/** + * getAppProtocol returns the protocol of the application. Equivalent to + * types.Application.GetProtocol. + */ +export function getAppProtocol(appURI: string): AppProtocol { + if (appURI.startsWith('tcp://')) { + return 'TCP'; + } + if (appURI.startsWith('mcp+')) { + return 'MCP'; + } + return 'HTTP'; +} + +/** + * getAppUriScheme extracts the scheme from the app URI. + */ +export function getAppUriScheme(appURI: string): string { + const sepIdx = appURI.indexOf('://'); + return sepIdx > 0 ? appURI.slice(0, sepIdx) : ''; +} diff --git a/web/packages/shared/services/types.ts b/web/packages/shared/services/types.ts index 04c608140be79..7879c56789902 100644 --- a/web/packages/shared/services/types.ts +++ b/web/packages/shared/services/types.ts @@ -62,3 +62,8 @@ export enum AppSubKind { AwsIcAccount = 'aws_ic_account', MCP = 'mcp', } + +/** + * AppProtocol defines the protocol of an App resource. + */ +export type AppProtocol = 'TCP' | 'HTTP' | 'MCP'; diff --git a/web/packages/teleport/src/services/apps/apps.test.ts b/web/packages/teleport/src/services/apps/apps.test.ts index 8bd0362f54c9d..2e4499fa5b586 100644 --- a/web/packages/teleport/src/services/apps/apps.test.ts +++ b/web/packages/teleport/src/services/apps/apps.test.ts @@ -180,6 +180,30 @@ test('correct formatting of apps fetch response', async () => { runAsHostUser: 'hostuser', }, }, + { + kind: 'app', + id: 'cluster-id-mcp-http-app-mcp-http-app.example.com', + name: 'mcp-http-app', + useAnyProxyPublicAddr: false, + description: 'Some MCP HTTP app', + uri: 'mcp+http://localhost:12345/mcp', + publicAddr: 'mcp-http-app.example.com', + labels: [], + clusterId: 'cluster-id', + fqdn: '', + friendlyName: '', + launchUrl: '', + awsRoles: [], + awsConsole: false, + isCloud: false, + isTcp: false, + addrWithProtocol: 'mcp+http://mcp-http-app.example.com', + userGroups: [], + samlApp: false, + samlAppSsoUrl: '', + integration: '', + permissionSets: [], + }, ], startKey: mockResponse.startKey, totalCount: mockResponse.totalCount, @@ -285,6 +309,13 @@ const mockResponse = { runAsHostUser: 'hostuser', }, }, + { + clusterId: 'cluster-id', + name: 'mcp-http-app', + publicAddr: 'mcp-http-app.example.com', + description: 'Some MCP HTTP app', + uri: 'mcp+http://localhost:12345/mcp', + }, ], startKey: 'mockKey', totalCount: 100, diff --git a/web/packages/teleport/src/services/apps/makeApps.ts b/web/packages/teleport/src/services/apps/makeApps.ts index 1baa4fd13de0f..c905596052dfa 100644 --- a/web/packages/teleport/src/services/apps/makeApps.ts +++ b/web/packages/teleport/src/services/apps/makeApps.ts @@ -17,7 +17,7 @@ */ import { AppSubKind } from 'shared/services'; -import { AwsRole } from 'shared/services/apps'; +import { AwsRole, getAppUriScheme } from 'shared/services/apps'; import cfg from 'teleport/config'; @@ -80,9 +80,10 @@ export default function makeApp(json: any): App { const userGroups = json.userGroups || []; const permissionSets: PermissionSet[] = json.permissionSets || []; - const isTcp = !!uri && uri.startsWith('tcp://'); - const isCloud = !!uri && uri.startsWith('cloud://'); - const isMCPStdio = !!uri && uri.startsWith('mcp+stdio://'); + const scheme = getAppUriScheme(uri); + const isTcp = scheme === 'tcp'; + const isCloud = scheme === 'cloud'; + const isMcp = scheme.startsWith('mcp+'); let addrWithProtocol = uri; if (publicAddr) { @@ -90,8 +91,9 @@ export default function makeApp(json: any): App { addrWithProtocol = `cloud://${publicAddr}`; } else if (isTcp) { addrWithProtocol = `tcp://${publicAddr}`; - } else if (isMCPStdio) { - addrWithProtocol = `mcp+stdio://${publicAddr}`; + } else if (isMcp) { + // Not used anywhere yet. + addrWithProtocol = `${scheme}://${publicAddr}`; } else if (subKind === AppSubKind.AwsIcAccount) { /** publicAddr for Identity Center account app is a URL with scheme. */ addrWithProtocol = publicAddr; diff --git a/web/packages/teleterm/src/services/tshd/app.ts b/web/packages/teleterm/src/services/tshd/app.ts index 838c7e60bc199..ff02a63041e89 100644 --- a/web/packages/teleterm/src/services/tshd/app.ts +++ b/web/packages/teleterm/src/services/tshd/app.ts @@ -22,6 +22,7 @@ import { RouteToApp, } from 'gen-proto-ts/teleport/lib/teleterm/v1/app_pb'; import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; +import { getAppUriScheme } from 'shared/services/apps'; /** Returns a URL that opens the web app in the browser. */ export function getWebAppLaunchUrl({ @@ -93,6 +94,18 @@ export function isMcp(app: App): boolean { return app.endpointUri.startsWith('mcp+'); } +/** + * doesMcpAppSupportGateway returns true for MCP servers that supports local + * proxy gateway. Currently only MCP servers with streamable HTTP transport + * support the gateway. + */ +export function doesMcpAppSupportGateway(app: App): boolean { + return ( + app.endpointUri.startsWith('mcp+http://') || + app.endpointUri.startsWith('mcp+https://') + ); +} + /** * Returns address with protocol which is an app protocol + a public address. * If the public address is empty, it falls back to the endpoint URI. @@ -102,17 +115,18 @@ export function isMcp(app: App): boolean { export function getAppAddrWithProtocol(source: App): string { const { publicAddr, endpointUri } = source; + const scheme = getAppUriScheme(endpointUri); const isTcp = endpointUri && endpointUri.startsWith('tcp://'); const isCloud = endpointUri && endpointUri.startsWith('cloud://'); - const isMCPStdio = endpointUri && endpointUri.startsWith('mcp+stdio://'); + const isMcp = scheme.startsWith('mcp+'); let addrWithProtocol = endpointUri; if (publicAddr) { if (isCloud) { addrWithProtocol = `cloud://${publicAddr}`; } else if (isTcp) { addrWithProtocol = `tcp://${publicAddr}`; - } else if (isMCPStdio) { - addrWithProtocol = `mcp+stdio://${publicAddr}`; + } else if (isMcp) { + addrWithProtocol = `${scheme}://${publicAddr}`; } else { // publicAddr for Identity Center account app is a URL with scheme. addrWithProtocol = publicAddr.startsWith('https://') diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx index e57cd7aae347b..8b15c37273f00 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx @@ -123,10 +123,14 @@ function Buttons(props: StoryProps) { - MCP - + MCP (Stdio) + + + MCP (Streamable HTTP) + + Server @@ -266,11 +270,11 @@ function SamlApp() { ); } -function Mcp() { +function Mcp(props: { scheme: string }) { return ( diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx index 3c392e32ca2a2..a532a9ccf2b70 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx @@ -43,8 +43,10 @@ import { MenuLoginProps, } from 'shared/components/MenuLogin'; import { MenuLoginWithActionMenu } from 'shared/components/MenuLoginWithActionMenu'; +import { getAppProtocol } from 'shared/services/apps'; import { + doesMcpAppSupportGateway, formatPortRange, getAwsAppLaunchUrl, getSamlAppSsoUrl, @@ -178,6 +180,7 @@ export function ConnectAppActionButton(props: { app: App }): React.JSX.Element { setUpAppGateway(appContext, props.app.uri, { telemetry: { origin: 'resource_table' }, targetPort, + targetProtocol: getAppProtocol(props.app.endpointUri), }); } @@ -326,7 +329,20 @@ function AppButton(props: { } if (isMcp(props.app)) { - // TODO(greedy52) decide what to do with MCP servers. + // Streamable HTTP MCP servers support local proxy gateway. + if (doesMcpAppSupportGateway(props.app)) { + return ( + props.setUpGateway()} + textTransform="none" + width={buttonWidth} + > + Connect + + ); + } + // TODO(greedy52) decide what to do with MCP servers that don't support gateway. // In the meantime, display a box of specific width to make the other columns line up for MCP // apps in the list view of unified resources. return ; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx index f8b75e7576642..3ab35e08ce73d 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx @@ -87,8 +87,10 @@ export function AppGateway(props: { const handleTargetPortChange = useDebouncedPortChangeHandler(changeTargetPort); + const isMcp = gateway.protocol === 'MCP'; + const isHttpWebApp = gateway.protocol === 'HTTP'; let address = `${gateway.localAddress}:${gateway.localPort}`; - if (gateway.protocol === 'HTTP') { + if (isHttpWebApp || isMcp) { address = `http://${address}`; } @@ -147,6 +149,7 @@ export function AppGateway(props: { setUpAppGateway(ctx, targetUri, { telemetry: { origin: 'resource_table' }, targetPort, + targetProtocol: gateway.protocol, }); }; @@ -162,7 +165,7 @@ export function AppGateway(props: { > -

App Connection

+

{isMcp ? 'MCP Server Connection' : 'App Connection'}

{isMultiPort && (
- Access the app at: + + {isMcp + ? 'Access the MCP server with a streamable-HTTP-compatible client like "mcp-remote" at:' + : 'Access the app at:'} +
diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx index 38eb65561cc31..0d1ad3c152fae 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx @@ -34,7 +34,7 @@ import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspace import * as types from 'teleterm/ui/services/workspacesService'; type StoryProps = { - appType: 'web' | 'tcp' | 'tcp-multi-port'; + appType: 'web' | 'tcp' | 'tcp-multi-port' | 'mcp'; online: boolean; changeLocalPort: 'succeed' | 'throw-error'; changeTargetPort: 'succeed' | 'throw-error'; @@ -48,7 +48,7 @@ const meta: Meta = { argTypes: { appType: { control: { type: 'radio' }, - options: ['web', 'tcp', 'tcp-multi-port'], + options: ['web', 'tcp', 'tcp-multi-port', 'mcp'], }, changeLocalPort: { if: { arg: 'online' }, @@ -93,6 +93,9 @@ export function Story(props: StoryProps) { gateway.protocol = 'TCP'; gateway.targetSubresourceName = '4242'; } + if (props.appType === 'mcp') { + gateway.protocol = 'MCP'; + } const documentGateway: types.DocumentGateway = { kind: 'doc.gateway', targetUri: '/clusters/bar/apps/quux', diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx index 8dfc3d6c3975b..8a34aec7bca6e 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx @@ -281,6 +281,30 @@ const makeConnections = (index = 0) => { login: 'casey', clusterName: 'teleport.example.sh', }, + { + connected: true, + kind: 'connection.gateway' as const, + title: 'some-web-app' + suffix, + targetName: 'some-web-app', + id: '11111' + suffix, + targetUri: '/clusters/foo/apps/some-web-app' + suffix, + port: '11111', + gatewayUri: '/gateways/some-web-app', + clusterName: 'teleport.example.sh', + targetProtocol: 'HTTP', + }, + { + connected: true, + kind: 'connection.gateway' as const, + title: 'some-mcp-server' + suffix, + targetName: 'some-mcp-server', + id: '22222' + suffix, + targetUri: '/clusters/foo/apps/some-mcp-server' + suffix, + port: '22222', + gatewayUri: '/gateways/some-mcp-server', + clusterName: 'teleport.example.sh', + targetProtocol: 'MCP', + }, ]; }; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index d5c1953afdad2..1183a4faec518 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -158,6 +158,9 @@ function getKindName(connection: ExtendedTrackedConnection): string { switch (connection.kind) { case 'connection.gateway': if (isAppUri(connection.targetUri)) { + if (connection.targetProtocol === 'MCP') { + return 'MCP'; + } return 'APP'; } if (isDatabaseUri(connection.targetUri)) { diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts b/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts index 10aecd311154d..86cd3161cecc7 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts @@ -183,6 +183,7 @@ export function createGatewayConnection( targetUser: document.targetUser, targetName: document.targetName, targetSubresourceName: document.targetSubresourceName, + targetProtocol: document.targetProtocol, }; } diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/types.ts b/web/packages/teleterm/src/ui/services/connectionTracker/types.ts index 013781d42ffb1..c9f5ddfcb2c48 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/types.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/types.ts @@ -44,6 +44,7 @@ export interface TrackedGatewayConnection extends TrackedConnectionBase { targetUser?: string; port?: string; targetSubresourceName?: string; + targetProtocol?: string; } export interface TrackedKubeConnection extends TrackedConnectionBase { diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.test.ts index 95e843367609e..b0c4569ff67c3 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.test.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.test.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { getAppProtocol } from 'shared/services/apps'; + import { makeApp, makeRootCluster } from 'teleterm/services/tshd/testHelpers'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { @@ -113,6 +115,7 @@ describe('connectToApp', () => { targetUser: '', title: 'foo', uri: expect.any(String), + targetProtocol: 'TCP', }); }); }); @@ -147,6 +150,7 @@ describe('setUpAppGateway', () => { await setUpAppGateway(appContext, app.uri, { telemetry: { origin: 'resource_table' }, targetPort, + targetProtocol: getAppProtocol(app.endpointUri), }); const documents = appContext.workspacesService .getActiveWorkspaceDocumentService() @@ -164,6 +168,7 @@ describe('setUpAppGateway', () => { targetUser: '', title: expectedTitle || 'foo', uri: expect.any(String), + targetProtocol: getAppProtocol(app.endpointUri), }); }); }); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts index 8343dca0cc6fe..3102f5ca77f07 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts @@ -17,6 +17,7 @@ */ import { App } from 'gen-proto-ts/teleport/lib/teleterm/v1/app_pb'; +import { getAppProtocol } from 'shared/services/apps'; import { getAwsAppLaunchUrl, @@ -121,7 +122,12 @@ export async function connectToApp( targetPort = target.tcpPorts[0].port; } - await setUpAppGateway(ctx, target.uri, { telemetry, targetPort }); + const targetProtocol = getAppProtocol(target.endpointUri); + await setUpAppGateway(ctx, target.uri, { + telemetry, + targetPort, + targetProtocol, + }); } export async function setUpAppGateway( @@ -134,6 +140,10 @@ export async function setUpAppGateway( * only for multi-port TCP apps. */ targetPort?: number; + /** + * targetProtocol is the protocol of the resource proxied by the gateway. + */ + targetProtocol?: string; } ) { const rootClusterUri = routing.ensureRootClusterUri(targetUri); @@ -146,6 +156,7 @@ export async function setUpAppGateway( targetName: routing.parseAppUri(targetUri).params.appId, targetUser: '', targetSubresourceName: options.targetPort?.toString(), + targetProtocol: options.targetProtocol, }); const connectionToReuse = ctx.connectionTracker.findConnectionByDocument(doc); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index 5c586e828f31a..54c2d3da69fd4 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -142,6 +142,7 @@ export class DocumentsService { port, gatewayUri, origin, + targetProtocol, } = opts; const uri = routing.getDocUri({ docId: unique() }); @@ -157,6 +158,7 @@ export class DocumentsService { port, origin, status: '', + targetProtocol, }; doc.title = getDocumentGatewayTitle(doc); return doc; diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index 79a46587fbd65..279dce8aaf786 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -107,6 +107,10 @@ export interface DocumentGateway extends DocumentBase { */ port?: string; origin: DocumentOrigin; + /** + * targetProtocol is the protocol of the resource proxied by the gateway. + */ + targetProtocol?: string; } /** @@ -326,6 +330,7 @@ export type CreateGatewayDocumentOpts = { title?: string; port?: string; origin: DocumentOrigin; + targetProtocol?: string; }; export type CreateAccessRequestDocumentOpts = {