Skip to content

Commit b76eec8

Browse files
feat(MK8S-196): add WebMCP protocol support to shell-ui
Implements the WebMCP standard (navigator.modelContext.registerTool()) so that micro-apps can expose AI-agent tools through their WebFinger configuration. Shell-ui owns the full registration lifecycle. Changes: - ConfigurationProviders: add optional `mcpTools: FederatedModuleInfo` to BuildtimeWebFinger spec so micro-apps can declare their tool module - MCPRegistrar (new): uses ComponentWithFederatedImports to load each micro-app's tool module and calls navigator.modelContext.registerTool() for every tool; wraps authRequired tools with a PKCE auth modal via OidcClient + requestUserInteraction - AuthProvider: expose `userManager` from useAuth(); add `autoSignIn: !isAIUserAgent()` to suppress Keycloak redirect in AI agent sessions (detection via UA pattern matching in mcp/userAgent.ts) - FederatedApp: mount <MCPRegistrar /> alongside SolutionsNavbar - rspack.config.ts: copy @mcp-b/webmcp-local-relay browser assets to build output (embed.js + widget.html); patch widget.html to raise the request timeout from 10 s to 60 s to accommodate STS + SDK round trips; exclude @mcp-b/* from Module Federation shared config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3824485 commit b76eec8

File tree

9 files changed

+1088
-44
lines changed

9 files changed

+1088
-44
lines changed

shell-ui/package-lock.json

Lines changed: 716 additions & 38 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shell-ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
"ts-node": "^10.9.2"
4747
},
4848
"dependencies": {
49+
"@mcp-b/global": "^2.2.0",
50+
"@mcp-b/webmcp-local-relay": "^2.2.0",
4951
"@scality/core-ui": "0.197.0",
5052
"@scality/module-federation": "1.6.1",
5153
"downshift": "^8.0.0",

shell-ui/rspack.config.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,12 @@ const config: Configuration = {
143143
'./useNotificationCenter': './src/useNotificationCenter.ts',
144144
},
145145
shared: {
146-
...Object.fromEntries(Object.entries(deps).map(([key, version]) => [key, {}])),
146+
...Object.fromEntries(
147+
Object.entries(deps)
148+
// @mcp-b/* are side-effect-only browser globals — must not be shared via MF
149+
.filter(([key]) => !key.startsWith('@mcp-b/'))
150+
.map(([key]) => [key, {}]),
151+
),
147152
'react-intl': {
148153
eager: true,
149154
singleton: true,
@@ -210,7 +215,23 @@ const config: Configuration = {
210215
excludedChunks: ['shell'],
211216
}),
212217
new rspack.CopyRspackPlugin({
213-
patterns: [{ from: 'public' }],
218+
patterns: [
219+
{ from: 'public' },
220+
{
221+
from: 'node_modules/@mcp-b/webmcp-local-relay/dist/browser',
222+
to: '.',
223+
// Increase widget.html request timeout from 10s to 60s so long-running
224+
// MCP tool calls (STS assumeRole + SDK action) don't time out.
225+
transform: (content: Buffer, resourcePath: string) => {
226+
if (resourcePath.endsWith('widget.html')) {
227+
return content
228+
.toString()
229+
.replace('Host response timeout: ${t}`))},1e4', 'Host response timeout: ${t}`))},6*1e4');
230+
}
231+
return content;
232+
},
233+
},
234+
],
214235
}),
215236
process.env.RSDOCTOR && new RsdoctorRspackPlugin({}),
216237
].filter(Boolean),

shell-ui/src/FederatedApp.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { ShellHistoryProvider } from './initFederation/ShellHistoryProvider';
3838
import { ShellThemeSelectorProvider } from './initFederation/ShellThemeSelectorProvider';
3939
import { UIListProvider } from './initFederation/UIListProvider';
40+
import { MCPRegistrar } from './mcp/MCPRegistrar';
4041
import { SolutionsNavbar } from './navbar';
4142
import { LanguageProvider, useLanguage } from './navbar/lang';
4243
import NotificationCenterProvider from './NotificationCenterProvider';
@@ -189,9 +190,12 @@ function InternalApp() {
189190
)}
190191
{status === 'error' && <ErrorPage500 data-cy="sc-error-page500" />}
191192
{status === 'success' && (
192-
<SolutionsNavbar>
193-
<InternalRouter />
194-
</SolutionsNavbar>
193+
<>
194+
<MCPRegistrar />
195+
<SolutionsNavbar>
196+
<InternalRouter />
197+
</SolutionsNavbar>
198+
</>
195199
)}
196200
</NotificationCenterProvider>
197201
</FirstTimeLoginProvider>

shell-ui/src/auth/AuthProvider.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
UserManager,
66
useAuth as useOauth2Auth,
77
} from 'oidc-react';
8+
import { isAIUserAgent } from '../mcp/userAgent';
89
import React, { useCallback, useEffect } from 'react';
910
import { useErrorBoundary } from 'react-error-boundary';
1011
import { useQuery } from 'react-query';
@@ -53,7 +54,7 @@ function defaultDexConnectorMetadataService(connectorId: string) {
5354
return DexDefaultConnectorMetadataService;
5455
}
5556

56-
function getAbsoluteRedirectUrl(redirectUrl?: string) {
57+
export function getAbsoluteRedirectUrl(redirectUrl?: string) {
5758
if (!redirectUrl) {
5859
return window.location.href;
5960
}
@@ -124,6 +125,7 @@ function OAuth2AuthProvider({ children }: { children: React.ReactNode }) {
124125
};
125126
}, [logOut]);
126127
const oidcConfig: AuthProviderProps = {
128+
autoSignIn: !isAIUserAgent(),
127129
onBeforeSignIn: () => {
128130
localStorage.setItem('redirectUrl', window.location.href);
129131
return window.location.href;
@@ -160,6 +162,7 @@ export type UserData = {
160162
export function useAuth(): {
161163
userData?: UserData;
162164
getToken: () => Promise<string | null>;
165+
userManager: UserManager | undefined;
163166
} {
164167
try {
165168
const auth = useOauth2Auth(); // todo add support for OAuth2Proxy
@@ -216,6 +219,7 @@ export function useAuth(): {
216219
return {
217220
userData: undefined,
218221
getToken: () => Promise.resolve(null),
222+
userManager: auth?.userManager,
219223
};
220224
}
221225

@@ -233,11 +237,13 @@ export function useAuth(): {
233237
return user?.access_token;
234238
});
235239
},
240+
userManager: auth.userManager,
236241
};
237242
} catch (e) {
238243
return {
239244
userData: undefined,
240245
getToken: () => Promise.resolve('null'),
246+
userManager: undefined,
241247
};
242248
}
243249
}

shell-ui/src/initFederation/ConfigurationProviders.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export type BuildtimeWebFinger = {
6262
components: Record<string, FederatedModuleInfo>;
6363
navbarUpdaterComponents?: FederatedModuleInfo[];
6464
instanceNameAdapter?: FederatedModuleInfo;
65+
mcpTools?: FederatedModuleInfo;
6566
};
6667
};
6768

shell-ui/src/mcp/MCPRegistrar.tsx

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import '@mcp-b/global';
2+
import { ComponentWithFederatedImports } from '@scality/module-federation';
3+
import { OidcClient } from 'oidc-client-ts';
4+
import { useEffect } from 'react';
5+
6+
declare const __webpack_public_path__: string;
7+
import { ErrorBoundary } from 'react-error-boundary';
8+
import {
9+
getAbsoluteRedirectUrl,
10+
useAuth,
11+
} from '../auth/AuthProvider';
12+
import {
13+
FederatedModuleInfo,
14+
OIDCConfig,
15+
useConfigRetriever,
16+
} from '../initFederation/ConfigurationProviders';
17+
import { useDeployedApps } from '../initFederation/UIListProvider';
18+
import type { MCPToolDefinition, ModelContextClient, ToolContext } from './types';
19+
20+
// Do not use directly - exported for testing purposes
21+
export const _InternalMCPRegistrar = ({
22+
moduleExports,
23+
mcpToolsModuleInfo,
24+
selfConfiguration,
25+
authConfig,
26+
}: {
27+
moduleExports: Record<string, { tools: MCPToolDefinition[] }>;
28+
mcpToolsModuleInfo: FederatedModuleInfo;
29+
selfConfiguration: Record<string, unknown>;
30+
authConfig: OIDCConfig | null;
31+
}) => {
32+
const { userManager } = useAuth();
33+
34+
useEffect(() => {
35+
if (!navigator.modelContext || !userManager) return;
36+
37+
const tools = moduleExports[mcpToolsModuleInfo.module]?.tools ?? [];
38+
const registeredNames: string[] = [];
39+
40+
for (const tool of tools) {
41+
navigator.modelContext.registerTool({
42+
name: tool.name,
43+
description: tool.description,
44+
inputSchema: tool.inputSchema,
45+
execute: async (params: unknown, client: ModelContextClient) => {
46+
if (tool.authRequired) {
47+
let user = await userManager.getUser();
48+
49+
if (!user || user.expired) {
50+
user = await userManager.signinSilent().catch(() => null);
51+
}
52+
53+
if (!user || user.expired) {
54+
if (!authConfig) {
55+
return {
56+
success: false,
57+
error: {
58+
code: 'AUTH_REQUIRED',
59+
message: 'Authentication required but no OIDC configuration is available',
60+
},
61+
};
62+
}
63+
64+
const oidcClient = new OidcClient({
65+
authority: authConfig.providerUrl,
66+
client_id: authConfig.clientId,
67+
redirect_uri: getAbsoluteRedirectUrl(authConfig.redirectUrl),
68+
response_type: authConfig.responseType || 'code',
69+
scope: authConfig.scopes,
70+
});
71+
72+
// connector_id is passed as extraQueryParams rather than via MetadataServiceCtor
73+
// (which is UserManager-only). The effect on the final auth URL is identical.
74+
const signinRequest = await oidcClient.createSigninRequest({
75+
...(authConfig.defaultDexConnector && {
76+
extraQueryParams: { connector_id: authConfig.defaultDexConnector },
77+
}),
78+
});
79+
const authUrl = signinRequest.url;
80+
81+
const authenticated = await client.requestUserInteraction(
82+
(_innerClient: ModelContextClient) => {
83+
return new Promise<boolean>((resolve) => {
84+
const modal = document.createElement('div');
85+
modal.className = 'mcp-auth-modal';
86+
modal.style.cssText =
87+
'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);z-index:9999';
88+
modal.innerHTML = `
89+
<div style="background:#fff;padding:2rem;border-radius:8px;max-width:400px;text-align:center">
90+
<h3 style="margin:0 0 1rem">Authentication Required</h3>
91+
<p style="margin:0 0 1.5rem">This action requires you to sign in.</p>
92+
<a href="${authUrl}" target="_blank" style="display:inline-block;padding:0.5rem 1.5rem;background:#0066cc;color:#fff;border-radius:4px;text-decoration:none;margin-bottom:1rem">Sign in to continue</a>
93+
<br/>
94+
<button id="mcp-cancel-auth" style="margin-top:0.5rem;padding:0.4rem 1rem;cursor:pointer">Cancel</button>
95+
</div>
96+
`;
97+
document.body.appendChild(modal);
98+
99+
const onUserLoaded = () => {
100+
modal.remove();
101+
userManager.events.removeUserLoaded(onUserLoaded);
102+
resolve(true);
103+
};
104+
userManager.events.addUserLoaded(onUserLoaded);
105+
106+
modal
107+
.querySelector('#mcp-cancel-auth')
108+
?.addEventListener('click', () => {
109+
userManager.events.removeUserLoaded(onUserLoaded);
110+
modal.remove();
111+
resolve(false);
112+
});
113+
});
114+
},
115+
);
116+
117+
if (!authenticated) {
118+
return {
119+
success: false,
120+
error: {
121+
code: 'AUTH_REQUIRED',
122+
message: 'Authentication required to perform this action',
123+
},
124+
};
125+
}
126+
}
127+
}
128+
129+
const context: ToolContext = {
130+
getToken: async () => {
131+
const user = await userManager.getUser();
132+
return user?.access_token ?? '';
133+
},
134+
selfConfiguration,
135+
};
136+
137+
return tool.execute(
138+
{ ...(params as Record<string, unknown>), context },
139+
client,
140+
);
141+
},
142+
});
143+
144+
registeredNames.push(tool.name);
145+
}
146+
147+
return () => {
148+
registeredNames.forEach((name) =>
149+
navigator.modelContext?.unregisterTool?.(name),
150+
);
151+
};
152+
}, [moduleExports, mcpToolsModuleInfo, userManager, selfConfiguration, authConfig]);
153+
154+
return null;
155+
};
156+
157+
// Inject the local-relay embed script once — must be a <script> tag (not an ES module import)
158+
// so that document.currentScript.src is set, allowing widget.html to resolve locally
159+
// instead of falling back to the CDN.
160+
function useRelayEmbed() {
161+
useEffect(() => {
162+
if (document.querySelector('script[data-webmcp-relay-embed]')) return;
163+
const script = document.createElement('script');
164+
// __webpack_public_path__ is the runtime public path (e.g. '/shell/'),
165+
// ensuring the request hits the actual file rather than the SPA fallback.
166+
script.src = `${__webpack_public_path__}embed.js`;
167+
script.dataset.webmcpRelayEmbed = '1';
168+
document.head.appendChild(script);
169+
return () => script.remove();
170+
}, []);
171+
}
172+
173+
export const MCPRegistrar = () => {
174+
useRelayEmbed();
175+
const deployedApps = useDeployedApps();
176+
const { retrieveConfiguration } = useConfigRetriever();
177+
178+
return (
179+
<>
180+
{deployedApps.flatMap((app) => {
181+
const buildConfig = retrieveConfiguration<'build'>({
182+
configType: 'build',
183+
name: app.name,
184+
});
185+
186+
if (!buildConfig?.spec.mcpTools) return [];
187+
188+
const runtimeConfig = retrieveConfiguration<Record<string, unknown>>({
189+
configType: 'run',
190+
name: app.name,
191+
});
192+
193+
const mcpToolsModuleInfo = buildConfig.spec.mcpTools;
194+
const selfConfiguration =
195+
(runtimeConfig?.spec?.selfConfiguration as Record<string, unknown>) ??
196+
{};
197+
const auth = runtimeConfig?.spec?.auth;
198+
const authConfig =
199+
auth && (auth as { kind: string }).kind === 'OIDC'
200+
? (auth as unknown as OIDCConfig)
201+
: null;
202+
const remoteEntryUrl = app.url + buildConfig.spec.remoteEntryPath;
203+
204+
return [
205+
<ErrorBoundary key={app.name} FallbackComponent={() => null}>
206+
<ComponentWithFederatedImports
207+
componentWithInjectedImports={_InternalMCPRegistrar}
208+
componentProps={{ mcpToolsModuleInfo, selfConfiguration, authConfig }}
209+
renderOnError={null}
210+
federatedImports={[{ ...mcpToolsModuleInfo, remoteEntryUrl }]}
211+
/>
212+
</ErrorBoundary>,
213+
];
214+
})}
215+
</>
216+
);
217+
};

0 commit comments

Comments
 (0)