From a9eb02dbd9db6941f0a49ba66c1faa6c25365f6d Mon Sep 17 00:00:00 2001 From: aminesbdev Date: Sat, 15 Aug 2026 15:17:31 +0200 Subject: [PATCH] feat(router): introduce WebMCP router features Introduce withExperimentalWebMcpRouterTools() router feature to enable browser-side AI assistants (using the emerging WebMCP standard) to query available routes and trigger page navigations. This adds router.list_routes and router.navigate tools. --- adev/src/app/routing/router_providers.ts | 2 + goldens/public-api/router/index.api.md | 8 +- packages/router/BUILD.bazel | 3 + packages/router/package.json | 4 +- packages/router/src/index.ts | 2 + packages/router/src/provide_router.ts | 10 +- packages/router/src/webmcp.ts | 114 +++++++++++++++++++++++ packages/router/test/BUILD.bazel | 1 + packages/router/test/webmcp.spec.ts | 97 +++++++++++++++++++ pnpm-lock.yaml | 6 ++ 10 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 packages/router/src/webmcp.ts create mode 100644 packages/router/test/webmcp.spec.ts diff --git a/adev/src/app/routing/router_providers.ts b/adev/src/app/routing/router_providers.ts index 4d02a6be731b..07de46c3496b 100644 --- a/adev/src/app/routing/router_providers.ts +++ b/adev/src/app/routing/router_providers.ts @@ -23,6 +23,7 @@ import { withNavigationErrorHandler, withRouterConfig, isActive, + withExperimentalWebMcpRouterTools, } from '@angular/router'; import {routes} from './routes'; import {ADevTitleStrategy} from '../core/services/a-dev-title-strategy'; @@ -65,6 +66,7 @@ export const routerProviders = [ }, }), withComponentInputBinding(), + withExperimentalWebMcpRouterTools(), ), { provide: RouteReuseStrategy, diff --git a/goldens/public-api/router/index.api.md b/goldens/public-api/router/index.api.md index 1a8ea5faaa21..beead0319b10 100644 --- a/goldens/public-api/router/index.api.md +++ b/goldens/public-api/router/index.api.md @@ -791,7 +791,7 @@ export interface RouterFeature { } // @public -export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature | ExperimentalAutoCleanupInjectorsFeature | RouterHashLocationFeature | ExperimentalPlatformNavigationFeature; +export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature | ExperimentalAutoCleanupInjectorsFeature | RouterHashLocationFeature | ExperimentalPlatformNavigationFeature | WebMcpRouterToolsFeature; // @public export type RouterHashLocationFeature = RouterFeature; @@ -1136,6 +1136,9 @@ export interface ViewTransitionsFeatureOptions { skipInitialTransition?: boolean; } +// @public +export type WebMcpRouterToolsFeature = RouterFeature; + // @public export function withComponentInputBinding(options?: ComponentInputBindingOptions): ComponentInputBindingFeature; @@ -1154,6 +1157,9 @@ export function withExperimentalAutoCleanupInjectors(): ExperimentalAutoCleanupI // @public export function withExperimentalPlatformNavigation(): ExperimentalPlatformNavigationFeature; +// @public +export function withExperimentalWebMcpRouterTools(): WebMcpRouterToolsFeature; + // @public export function withHashLocation(): RouterHashLocationFeature; diff --git a/packages/router/BUILD.bazel b/packages/router/BUILD.bazel index b6886a667ca6..6b44e9024cea 100644 --- a/packages/router/BUILD.bazel +++ b/packages/router/BUILD.bazel @@ -1,7 +1,10 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") load("//tools:defaults.bzl", "api_golden_test", "api_golden_test_npm_package", "generate_api_docs", "ng_package", "ng_project", "ts_config") package(default_visibility = ["//visibility:public"]) +npm_link_all_packages(name = "node_modules") + ts_config( name = "tsconfig_build", src = "tsconfig.json", diff --git a/packages/router/package.json b/packages/router/package.json index 1d0fce281eba..c126d07bc1d3 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -24,7 +24,9 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@types/dom-navigation": "^1.0.5" + "@types/dom-navigation": "^1.0.5", + "@mcp-b/webmcp-polyfill": "^4.0.0", + "@mcp-b/webmcp-types": "^4.0.0" }, "peerDependencies": { "@angular/core": "0.0.0-PLACEHOLDER", diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 6513a0d7044f..10094268674d 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -100,6 +100,8 @@ export { withViewTransitions, } from './provide_router'; +export {withExperimentalWebMcpRouterTools, WebMcpRouterToolsFeature} from './webmcp'; + export { BaseRouteReuseStrategy, destroyDetachedRouteHandle, diff --git a/packages/router/src/provide_router.ts b/packages/router/src/provide_router.ts index ab73bfe0f612..e9cc41933c1d 100644 --- a/packages/router/src/provide_router.ts +++ b/packages/router/src/provide_router.ts @@ -54,6 +54,7 @@ import {ROUTER_SCROLLER, RouterScroller} from './router_scroller'; import {getLoadedRoutes, getRouterInstance, navigateByUrl} from './router_devtools'; import {ActivatedRoute} from './router_state'; +import type {WebMcpRouterToolsFeature} from './webmcp'; import {NavigationStateManager} from './statemanager/navigation_state_manager'; import {StateManager} from './statemanager/state_manager'; import {afterNextNavigation} from './utils/navigations'; @@ -136,7 +137,7 @@ export interface RouterFeature { /** * Helper function to create an object that represents a Router feature. */ -function routerFeature( +export function routerFeature( kind: FeatureKind, providers: Array, ): RouterFeature { @@ -363,8 +364,7 @@ export type EnabledBlockingInitialNavigationFeature = * @publicApi */ export type InitialNavigationFeature = - | EnabledBlockingInitialNavigationFeature - | DisabledInitialNavigationFeature; + EnabledBlockingInitialNavigationFeature | DisabledInitialNavigationFeature; /** * Configures initial navigation to start before the root component is created. @@ -923,7 +923,8 @@ export type RouterFeatures = | ViewTransitionsFeature | ExperimentalAutoCleanupInjectorsFeature | RouterHashLocationFeature - | ExperimentalPlatformNavigationFeature; + | ExperimentalPlatformNavigationFeature + | WebMcpRouterToolsFeature; /** * The list of features as an enum to uniquely type each feature. @@ -941,4 +942,5 @@ export const enum RouterFeatureKind { ViewTransitionsFeature, ExperimentalAutoCleanupInjectorsFeature, ExperimentalPlatformNavigationFeature, + WebMcpRouterToolsFeature, } diff --git a/packages/router/src/webmcp.ts b/packages/router/src/webmcp.ts new file mode 100644 index 000000000000..8a780dc056f2 --- /dev/null +++ b/packages/router/src/webmcp.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {declareExperimentalWebMcpTool, inject, provideEnvironmentInitializer} from '@angular/core'; +import {Router} from './router'; +import {Routes} from './models'; +import {routerFeature, RouterFeature, RouterFeatureKind} from './provide_router'; + +/** + * A Router feature that provides WebMCP routing tools to browser-side AI assistants. + * + * @experimental + */ +export type WebMcpRouterToolsFeature = RouterFeature; + +function getPaths(routes: Routes, parentPath = ''): string[] { + const paths: string[] = []; + for (const route of routes) { + let currentPath = parentPath; + if (route.path !== undefined) { + currentPath = parentPath ? `${parentPath}/${route.path}` : route.path; + if (route.path !== '**') { + let normalizedPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`; + // Replace multiple consecutive slashes with a single slash + normalizedPath = normalizedPath.replace(/\/+/g, '/'); + // Strip trailing slash unless it is the root path "/" + if (normalizedPath.length > 1 && normalizedPath.endsWith('/')) { + normalizedPath = normalizedPath.slice(0, -1); + } + paths.push(normalizedPath); + } + } + if (route.children) { + paths.push(...getPaths(route.children, currentPath)); + } + if (route._loadedRoutes) { + paths.push(...getPaths(route._loadedRoutes, currentPath)); + } + } + return Array.from(new Set(paths)).sort(); +} + +/** + * Enables WebMCP tools for the Angular Router, allowing browser-side AI assistants + * to list available routes and navigate between them. + * + * @returns A router feature that registers the WebMCP routing tools. + * @experimental + */ +export function withExperimentalWebMcpRouterTools(): WebMcpRouterToolsFeature { + return routerFeature(RouterFeatureKind.WebMcpRouterToolsFeature, [ + provideEnvironmentInitializer(() => { + const router = inject(Router); + + // Tool to list loaded/configured routes + declareExperimentalWebMcpTool({ + name: 'router.list_routes', + description: 'Lists all available routes/paths configured in the application.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + execute: () => { + const paths = getPaths(router.config); + return { + content: [ + { + type: 'text', + text: `Available paths:\n${paths.map((p) => `- ${p}`).join('\n')}`, + }, + ], + }; + }, + }); + + // Tool to navigate to a path/URL + declareExperimentalWebMcpTool({ + name: 'router.navigate', + description: 'Navigates the application to the specified path or URL.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The URL or path to navigate to, e.g., "/dashboard" or "/profile".', + }, + }, + required: ['url'], + additionalProperties: false, + }, + execute: async ({url}) => { + if (typeof url !== 'string') { + throw new Error('Invalid URL provided.'); + } + const success = await router.navigateByUrl(url); + return { + content: [ + { + type: 'text', + text: success ? `Successfully navigated to ${url}` : `Failed to navigate to ${url}`, + }, + ], + }; + }, + }); + }), + ]); +} diff --git a/packages/router/test/BUILD.bazel b/packages/router/test/BUILD.bazel index afa6272123a2..b643703bf8ed 100644 --- a/packages/router/test/BUILD.bazel +++ b/packages/router/test/BUILD.bazel @@ -26,6 +26,7 @@ ts_project( "//packages/platform-browser/testing", "//packages/private/testing", "//packages/router", + "//packages/router:node_modules/@mcp-b/webmcp-polyfill", "//packages/router/testing", ], ) diff --git a/packages/router/test/webmcp.spec.ts b/packages/router/test/webmcp.spec.ts new file mode 100644 index 000000000000..a645fdcccddf --- /dev/null +++ b/packages/router/test/webmcp.spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {initializeWebMCPPolyfill, cleanupWebMCPPolyfill} from '@mcp-b/webmcp-polyfill'; +import {Router, RouterModule, provideRouter, withExperimentalWebMcpRouterTools} from '../index'; + +@Component({ + template: '', + imports: [RouterModule], +}) +export class RootComponent {} + +@Component({template: '
Home
'}) +export class HomeComponent {} + +@Component({template: '
Dashboard
'}) +export class DashboardComponent {} + +@Component({template: '
Settings
'}) +export class SettingsComponent {} + +describe('withExperimentalWebMcpRouterTools', () => { + beforeEach(() => { + initializeWebMCPPolyfill({installTestingShim: true}); + const modelContext = (globalThis.navigator as any).modelContext; + if (modelContext) { + modelContext.dispatchEvent = () => true; + } + const testingContext = (globalThis.navigator as any).modelContextTesting; + if (testingContext) { + testingContext.dispatchEvent = () => true; + } + }); + + afterEach(() => { + cleanupWebMCPPolyfill(); + }); + + it('should register router.list_routes and router.navigate tools', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [ + {path: 'home', component: HomeComponent}, + { + path: 'admin', + children: [ + {path: 'dashboard', component: DashboardComponent}, + {path: 'settings', component: SettingsComponent}, + {path: '', component: DashboardComponent}, + ], + }, + ], + withExperimentalWebMcpRouterTools(), + ), + ], + }); + + const fixture = TestBed.createComponent(RootComponent); + await fixture.whenStable(); + + const testingContext = (globalThis.navigator as any).modelContextTesting!; + const tools = testingContext.listTools(); + expect(tools).toContain(jasmine.objectContaining({name: 'router.list_routes'})); + expect(tools).toContain(jasmine.objectContaining({name: 'router.navigate'})); + + // Test router.list_routes execution + const listResultJson = await testingContext.executeTool('router.list_routes', '{}'); + const listResult = JSON.parse(listResultJson!); + expect(listResult.content[0].text).toContain('/home'); + expect(listResult.content[0].text).toContain('/admin/dashboard'); + expect(listResult.content[0].text).toContain('/admin/settings'); + expect(listResult.content[0].text).toContain('/admin\n'); + expect(listResult.content[0].text).not.toContain('/admin/\n'); + + // Test router.navigate execution + const router = TestBed.inject(Router); + expect(router.url).toEqual('/'); + + const navigateResultJson = await testingContext.executeTool( + 'router.navigate', + '{"url": "/admin/dashboard"}', + ); + const navigateResult = JSON.parse(navigateResultJson!); + expect(navigateResult.content[0].text).toContain('Successfully navigated to /admin/dashboard'); + + await fixture.whenStable(); + expect(router.url).toEqual('/admin/dashboard'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dd4d39f9500..339eab825bd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1165,6 +1165,12 @@ importers: specifier: ^2.3.0 version: 2.8.1 devDependencies: + '@mcp-b/webmcp-polyfill': + specifier: ^4.0.0 + version: 4.0.0 + '@mcp-b/webmcp-types': + specifier: ^4.0.0 + version: 4.0.0 '@types/dom-navigation': specifier: ^1.0.5 version: 1.0.7