Skip to content

Commit d084229

Browse files
committed
[mcp] Web UI and Teleport Connect adjustments for SSE and Streamable HTTP MCP servers
1 parent 69cf71e commit d084229

File tree

18 files changed

+226
-20
lines changed

18 files changed

+226
-20
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import { getAppProtocol, getAppUriScheme } from './apps';
20+
import type { AppProtocol } from './types';
21+
22+
describe('getAppProtocol', () => {
23+
test.each<[string, AppProtocol]>([
24+
// TCP
25+
['tcp://localhost:8080', 'TCP'],
26+
27+
// MCP
28+
['mcp+stdio://', 'MCP'],
29+
['mcp+http://example.com/mcp', 'MCP'],
30+
['mcp+sse+https://example.com/sse', 'MCP'],
31+
32+
// HTTP (fallback/default)
33+
['http://localhost:8080', 'HTTP'],
34+
['https://localhost:8080', 'HTTP'],
35+
['cloud://AWS', 'HTTP'],
36+
])('%s is %s', (uri, expected) => {
37+
expect(getAppProtocol(uri)).toBe(expected);
38+
});
39+
});
40+
41+
describe('getAppUriScheme', () => {
42+
test.each<[string, string]>([
43+
['tcp://localhost:8080', 'tcp'],
44+
['https://localhost:8080', 'https'],
45+
['mcp+http://example.com/mcp', 'mcp+http'],
46+
['', ''],
47+
])('scheme from %s is %s', (uri, expected) => {
48+
expect(getAppUriScheme(uri)).toBe(expected);
49+
});
50+
});

web/packages/shared/services/apps.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,33 @@
1515
* You should have received a copy of the GNU Affero General Public License
1616
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
*/
18+
import { AppProtocol } from 'shared/services/types';
1819

1920
export type AwsRole = {
2021
name: string;
2122
arn: string;
2223
display: string;
2324
accountId: string;
2425
};
26+
27+
/**
28+
* getAppProtocol returns the protocol of the application. Equivalent to
29+
* types.Application.GetProtocol.
30+
*/
31+
export function getAppProtocol(appURI: string): AppProtocol {
32+
if (appURI.startsWith('tcp://')) {
33+
return 'TCP';
34+
}
35+
if (appURI.startsWith('mcp+')) {
36+
return 'MCP';
37+
}
38+
return 'HTTP';
39+
}
40+
41+
/**
42+
* getAppUriScheme extracts the scheme from the app URI.
43+
*/
44+
export function getAppUriScheme(appURI: string): string {
45+
const sepIdx = appURI.indexOf('://');
46+
return sepIdx > 0 ? appURI.slice(0, sepIdx) : '';
47+
}

web/packages/shared/services/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,8 @@ export enum AppSubKind {
6262
AwsIcAccount = 'aws_ic_account',
6363
MCP = 'mcp',
6464
}
65+
66+
/**
67+
* AppProtocol defines the protocol of an App resource.
68+
*/
69+
export type AppProtocol = 'TCP' | 'HTTP' | 'MCP';

web/packages/teleport/src/services/apps/apps.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,30 @@ test('correct formatting of apps fetch response', async () => {
180180
runAsHostUser: 'hostuser',
181181
},
182182
},
183+
{
184+
kind: 'app',
185+
id: 'cluster-id-mcp-http-app-mcp-http-app.example.com',
186+
name: 'mcp-http-app',
187+
useAnyProxyPublicAddr: false,
188+
description: 'Some MCP HTTP app',
189+
uri: 'mcp+http://localhost:12345/mcp',
190+
publicAddr: 'mcp-http-app.example.com',
191+
labels: [],
192+
clusterId: 'cluster-id',
193+
fqdn: '',
194+
friendlyName: '',
195+
launchUrl: '',
196+
awsRoles: [],
197+
awsConsole: false,
198+
isCloud: false,
199+
isTcp: false,
200+
addrWithProtocol: 'mcp+http://mcp-http-app.example.com',
201+
userGroups: [],
202+
samlApp: false,
203+
samlAppSsoUrl: '',
204+
integration: '',
205+
permissionSets: [],
206+
},
183207
],
184208
startKey: mockResponse.startKey,
185209
totalCount: mockResponse.totalCount,
@@ -285,6 +309,13 @@ const mockResponse = {
285309
runAsHostUser: 'hostuser',
286310
},
287311
},
312+
{
313+
clusterId: 'cluster-id',
314+
name: 'mcp-http-app',
315+
publicAddr: 'mcp-http-app.example.com',
316+
description: 'Some MCP HTTP app',
317+
uri: 'mcp+http://localhost:12345/mcp',
318+
},
288319
],
289320
startKey: 'mockKey',
290321
totalCount: 100,

web/packages/teleport/src/services/apps/makeApps.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
import { AppSubKind } from 'shared/services';
20-
import { AwsRole } from 'shared/services/apps';
20+
import { AwsRole, getAppUriScheme } from 'shared/services/apps';
2121

2222
import cfg from 'teleport/config';
2323

@@ -80,18 +80,20 @@ export default function makeApp(json: any): App {
8080
const userGroups = json.userGroups || [];
8181
const permissionSets: PermissionSet[] = json.permissionSets || [];
8282

83-
const isTcp = !!uri && uri.startsWith('tcp://');
84-
const isCloud = !!uri && uri.startsWith('cloud://');
85-
const isMCPStdio = !!uri && uri.startsWith('mcp+stdio://');
83+
const scheme = getAppUriScheme(uri);
84+
const isTcp = scheme === 'tcp';
85+
const isCloud = scheme === 'cloud';
86+
const isMCP = scheme.startsWith('mcp+');
8687

8788
let addrWithProtocol = uri;
8889
if (publicAddr) {
8990
if (isCloud) {
9091
addrWithProtocol = `cloud://${publicAddr}`;
9192
} else if (isTcp) {
9293
addrWithProtocol = `tcp://${publicAddr}`;
93-
} else if (isMCPStdio) {
94-
addrWithProtocol = `mcp+stdio://${publicAddr}`;
94+
} else if (isMCP) {
95+
// Not used anywhere yet.
96+
addrWithProtocol = `${scheme}://${publicAddr}`;
9597
} else if (subKind === AppSubKind.AwsIcAccount) {
9698
/** publicAddr for Identity Center account app is a URL with scheme. */
9799
addrWithProtocol = publicAddr;

web/packages/teleterm/src/services/tshd/app.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
RouteToApp,
2323
} from 'gen-proto-ts/teleport/lib/teleterm/v1/app_pb';
2424
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
25+
import { getAppUriScheme } from 'shared/services/apps';
2526

2627
/** Returns a URL that opens the web app in the browser. */
2728
export function getWebAppLaunchUrl({
@@ -93,6 +94,18 @@ export function isMcp(app: App): boolean {
9394
return app.endpointUri.startsWith('mcp+');
9495
}
9596

97+
/**
98+
* doesMcpAppSupportGateway returns true for MCP servers that supports local
99+
* proxy gateway. Currently only MCP servers with streamable HTTP transport
100+
* support the gateway.
101+
*/
102+
export function doesMcpAppSupportGateway(app: App): boolean {
103+
return (
104+
app.endpointUri.startsWith('mcp+http://') ||
105+
app.endpointUri.startsWith('mcp+https://')
106+
);
107+
}
108+
96109
/**
97110
* Returns address with protocol which is an app protocol + a public address.
98111
* If the public address is empty, it falls back to the endpoint URI.
@@ -102,17 +115,18 @@ export function isMcp(app: App): boolean {
102115
export function getAppAddrWithProtocol(source: App): string {
103116
const { publicAddr, endpointUri } = source;
104117

118+
const scheme = getAppUriScheme(endpointUri);
105119
const isTcp = endpointUri && endpointUri.startsWith('tcp://');
106120
const isCloud = endpointUri && endpointUri.startsWith('cloud://');
107-
const isMCPStdio = endpointUri && endpointUri.startsWith('mcp+stdio://');
121+
const isMCP = scheme.startsWith('mcp+');
108122
let addrWithProtocol = endpointUri;
109123
if (publicAddr) {
110124
if (isCloud) {
111125
addrWithProtocol = `cloud://${publicAddr}`;
112126
} else if (isTcp) {
113127
addrWithProtocol = `tcp://${publicAddr}`;
114-
} else if (isMCPStdio) {
115-
addrWithProtocol = `mcp+stdio://${publicAddr}`;
128+
} else if (isMCP) {
129+
addrWithProtocol = `${scheme}://${publicAddr}`;
116130
} else {
117131
// publicAddr for Identity Center account app is a URL with scheme.
118132
addrWithProtocol = publicAddr.startsWith('https://')

web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,14 @@ function Buttons(props: StoryProps) {
123123
</Box>
124124
<HoverTooltip tipContent="Connect doesn't support MCP apps properly yet but it renders a div with consintent width.">
125125
<Box>
126-
<Text>MCP</Text>
127-
<Mcp />
126+
<Text>MCP (Stdio)</Text>
127+
<Mcp scheme={'mcp+stdio'} />
128128
</Box>
129129
</HoverTooltip>
130+
<Box>
131+
<Text>MCP (Streamable HTTP)</Text>
132+
<Mcp scheme={'mcp+http'} />
133+
</Box>
130134
</Flex>
131135
<Box>
132136
<Text>Server</Text>
@@ -266,11 +270,11 @@ function SamlApp() {
266270
);
267271
}
268272

269-
function Mcp() {
273+
function Mcp(props: { scheme: string }) {
270274
return (
271275
<ConnectAppActionButton
272276
app={makeApp({
273-
endpointUri: 'mcp+stdio://localhost:3000',
277+
endpointUri: `${props.scheme}://localhost:3000`,
274278
uri: `${testCluster.uri}/apps/bar`,
275279
})}
276280
/>

web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ import {
4343
MenuLoginProps,
4444
} from 'shared/components/MenuLogin';
4545
import { MenuLoginWithActionMenu } from 'shared/components/MenuLoginWithActionMenu';
46+
import { getAppProtocol } from 'shared/services/apps';
4647

4748
import {
49+
doesMcpAppSupportGateway,
4850
formatPortRange,
4951
getAwsAppLaunchUrl,
5052
getSamlAppSsoUrl,
@@ -178,6 +180,7 @@ export function ConnectAppActionButton(props: { app: App }): React.JSX.Element {
178180
setUpAppGateway(appContext, props.app.uri, {
179181
telemetry: { origin: 'resource_table' },
180182
targetPort,
183+
targetProtocol: getAppProtocol(props.app.endpointUri),
181184
});
182185
}
183186

@@ -326,7 +329,20 @@ function AppButton(props: {
326329
}
327330

328331
if (isMcp(props.app)) {
329-
// TODO(greedy52) decide what to do with MCP servers.
332+
// Streamable HTTP MCP servers support local proxy gateway.
333+
if (doesMcpAppSupportGateway(props.app)) {
334+
return (
335+
<ButtonBorder
336+
size="small"
337+
onClick={() => props.setUpGateway()}
338+
textTransform="none"
339+
width={buttonWidth}
340+
>
341+
Connect
342+
</ButtonBorder>
343+
);
344+
}
345+
// TODO(greedy52) decide what to do with MCP servers that don't support gateway.
330346
// In the meantime, display a box of specific width to make the other columns line up for MCP
331347
// apps in the list view of unified resources.
332348
return <Box width={buttonWidth} />;

web/packages/teleterm/src/ui/DocumentGatewayApp/AppGateway.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ export function AppGateway(props: {
8787
const handleTargetPortChange =
8888
useDebouncedPortChangeHandler(changeTargetPort);
8989

90+
const isMCP = gateway.protocol === 'MCP';
9091
let address = `${gateway.localAddress}:${gateway.localPort}`;
91-
if (gateway.protocol === 'HTTP') {
92+
if (gateway.protocol === 'HTTP' || isMCP) {
9293
address = `http://${address}`;
9394
}
9495

@@ -147,6 +148,7 @@ export function AppGateway(props: {
147148
setUpAppGateway(ctx, targetUri, {
148149
telemetry: { origin: 'resource_table' },
149150
targetPort,
151+
targetProtocol: gateway.protocol,
150152
});
151153
};
152154

@@ -162,7 +164,7 @@ export function AppGateway(props: {
162164
>
163165
<Flex flexDirection="column" gap={2}>
164166
<Flex justifyContent="space-between" mb="2" flexWrap="wrap" gap={2}>
165-
<H1>App Connection</H1>
167+
<H1>{isMCP ? 'MCP Server Connection' : 'App Connection'}</H1>
166168
<Flex gap={2}>
167169
{isMultiPort && (
168170
<MenuLogin
@@ -219,7 +221,11 @@ export function AppGateway(props: {
219221

220222
<Flex flexDirection="column" gap={2}>
221223
<div>
222-
<Text>Access the app at:</Text>
224+
<Text>
225+
{isMCP
226+
? 'Access the MCP server with a streamable HTTP compatible client like "mcp-remote" at:'
227+
: 'Access the app at:'}
228+
</Text>
223229
<TextSelectCopy mt={1} text={address} bash={false} />
224230
</div>
225231

web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspace
3434
import * as types from 'teleterm/ui/services/workspacesService';
3535

3636
type StoryProps = {
37-
appType: 'web' | 'tcp' | 'tcp-multi-port';
37+
appType: 'web' | 'tcp' | 'tcp-multi-port' | 'mcp';
3838
online: boolean;
3939
changeLocalPort: 'succeed' | 'throw-error';
4040
changeTargetPort: 'succeed' | 'throw-error';
@@ -48,7 +48,7 @@ const meta: Meta<StoryProps> = {
4848
argTypes: {
4949
appType: {
5050
control: { type: 'radio' },
51-
options: ['web', 'tcp', 'tcp-multi-port'],
51+
options: ['web', 'tcp', 'tcp-multi-port', 'mcp'],
5252
},
5353
changeLocalPort: {
5454
if: { arg: 'online' },
@@ -93,6 +93,9 @@ export function Story(props: StoryProps) {
9393
gateway.protocol = 'TCP';
9494
gateway.targetSubresourceName = '4242';
9595
}
96+
if (props.appType === 'mcp') {
97+
gateway.protocol = 'MCP';
98+
}
9699
const documentGateway: types.DocumentGateway = {
97100
kind: 'doc.gateway',
98101
targetUri: '/clusters/bar/apps/quux',

0 commit comments

Comments
 (0)