Skip to content

Commit 3b0ae5f

Browse files
dgp1130alxhub
authored andcommitted
feat(core): add provideWebMcpTools
This is an ergonomic wrapper around `declareWebMcpTool`, allowing a user to define multiple tools directly on an injector's providers, rather than needing to find an injection context. Example: ```typescript import {bootstrapApplication, provideWebMcpTools} from '@angular/core'; await bootstrapApplication(RootComp, { providers: [ provideWebMcpTools([ { name: 'hello', description: 'Says hello', inputSchema: {type: 'object', properties: {}}, execute: async () => ({content: [{type: 'text', text: 'Hello, World!'}]}); }, ]), ], }); ``` The `execute` function is invoked in the injection context of the `Injector` it is provided to, meaning you can easily `inject` dependencies and invoke them. This also works particularly well with route `providers` and `withExperimentalAutoCleanupInjectors`, registering the tools when the router is navigated to and then automatically unregistering them when navigating away. Note that `withExperimentalAutoCleanupInjectors` is required for unregistration to work. ```typescript import {provideWebMcpTools} from '@angular/core'; import {provideRouter} from '@angular/router'; provideRouter( [ { path: '', component: Home, providers: [ provideWebMcpTools([ { name: 'hello', description: 'Says hello', inputSchema: {type: 'object', properties: {}}, execute: async () => ({content: [{type: 'text', text: 'Hello, World!'}]}), }, ]), ], }, ], withExperimentalAutoCleanupInjectors(), ); ```
1 parent 77ec837 commit 3b0ae5f

5 files changed

Lines changed: 170 additions & 0 deletions

File tree

goldens/public-api/core/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,9 @@ export type ProviderToken<T> = Type<T> | AbstractType<T> | InjectionToken<T>;
15171517
// @public
15181518
export function provideStabilityDebugging(): EnvironmentProviders;
15191519

1520+
// @public
1521+
export function provideWebMcpTools<const InputSchema extends JsonSchemaForInference>(tools: WebMcpToolDescriptor<InputSchema>[]): EnvironmentProviders;
1522+
15201523
// @public
15211524
export function provideZoneChangeDetection(options?: NgZoneOptions): EnvironmentProviders;
15221525

packages/core/src/webmcp/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
export {declareWebMcpTool} from './declare_tool';
10+
export {provideWebMcpTools} from './provide_tools';
1011
export type {
1112
Execute as WebMcpToolExecute,
1213
Client as WebMcpClient,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type {JsonSchemaForInference} from '../../third_party/@mcp-b/webmcp-types';
10+
import {EnvironmentProviders, makeEnvironmentProviders, provideEnvironmentInitializer} from '../di';
11+
import {declareWebMcpTool} from './declare_tool';
12+
import type {ToolDescriptor} from './types';
13+
14+
/**
15+
* Provides a list of WebMCP tools tied to the lifecycle of the associated `Injector`.
16+
*
17+
* The tools are automatically registered when the environment is initialized
18+
* and unregistered when the associated injector is destroyed.
19+
*
20+
* The `tools[number].execute` function is invoked in the injection context of the
21+
* associated `Injector`.
22+
*
23+
* @param tools The tools to register and execute when invoked by an AI agent.
24+
* @returns An {@link EnvironmentProviders} that can be used in `bootstrapApplication`
25+
* or route providers.
26+
* @experimental
27+
*/
28+
export function provideWebMcpTools<const InputSchema extends JsonSchemaForInference>(
29+
tools: ToolDescriptor<InputSchema>[],
30+
): EnvironmentProviders {
31+
return makeEnvironmentProviders([
32+
provideEnvironmentInitializer(() => {
33+
for (const tool of tools) declareWebMcpTool(tool);
34+
}),
35+
]);
36+
}

packages/core/test/webmcp/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ ts_project(
1414
"//packages/core:node_modules/@mcp-b/webmcp-polyfill",
1515
"//packages/core/testing",
1616
"//packages/core/third_party/@mcp-b/webmcp-types",
17+
"//packages/router",
18+
"//packages/router/testing",
1719
],
1820
)
1921

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {initializeWebMCPPolyfill, cleanupWebMCPPolyfill} from '@mcp-b/webmcp-polyfill';
10+
import type {JsonSchemaForInference} from '../../third_party/@mcp-b/webmcp-types';
11+
import {Component, createEnvironmentInjector, EnvironmentInjector} from '../../src/core';
12+
import {provideRouter, Router, withExperimentalAutoCleanupInjectors} from '@angular/router';
13+
import {provideWebMcpTools} from '../../src/webmcp/provide_tools';
14+
import {Execute} from '../../src/webmcp/types';
15+
import {TestBed} from '../../testing';
16+
17+
describe('provideWebMcpTools', () => {
18+
beforeEach(() => {
19+
initializeWebMCPPolyfill({installTestingShim: true});
20+
});
21+
22+
afterEach(() => {
23+
cleanupWebMCPPolyfill();
24+
});
25+
26+
it('should register tools when initialized', async () => {
27+
const execute = jasmine.createSpy<Execute<JsonSchemaForInference>>('execute').and.returnValue({
28+
content: [{type: 'text', text: 'Hello!'}],
29+
});
30+
31+
const envInjector = createEnvironmentInjector(
32+
[
33+
provideWebMcpTools([
34+
{
35+
name: 'testTool',
36+
description: 'A test tool',
37+
inputSchema: {type: 'object', properties: {}},
38+
execute,
39+
},
40+
]),
41+
],
42+
TestBed.inject(EnvironmentInjector),
43+
);
44+
45+
expect(globalThis.navigator.modelContextTesting!.listTools()).toEqual([
46+
jasmine.objectContaining({name: 'testTool'}),
47+
]);
48+
49+
await globalThis.navigator.modelContextTesting!.executeTool('testTool', '{}');
50+
expect(execute).toHaveBeenCalledOnceWith({}, jasmine.any(Object));
51+
52+
envInjector.destroy();
53+
});
54+
55+
it('should unregister tools when the injector is destroyed', () => {
56+
const envInjector = createEnvironmentInjector(
57+
[
58+
provideWebMcpTools([
59+
{
60+
name: 'testTool',
61+
description: 'A test tool',
62+
inputSchema: {type: 'object', properties: {}},
63+
execute: async () => ({content: []}),
64+
},
65+
]),
66+
],
67+
TestBed.inject(EnvironmentInjector),
68+
);
69+
70+
expect(globalThis.navigator.modelContextTesting!.listTools()).toEqual([
71+
jasmine.objectContaining({name: 'testTool'}),
72+
]);
73+
74+
envInjector.destroy();
75+
76+
expect(globalThis.navigator.modelContextTesting!.listTools()).toEqual([]);
77+
});
78+
79+
it('should work with route providers', async () => {
80+
@Component({
81+
selector: 'test-comp',
82+
template: '',
83+
})
84+
class TestComp {}
85+
86+
TestBed.configureTestingModule({
87+
imports: [TestComp],
88+
providers: [
89+
provideRouter(
90+
[
91+
{
92+
path: 'test',
93+
component: TestComp,
94+
providers: [
95+
provideWebMcpTools([
96+
{
97+
name: 'routeTool',
98+
description: 'A route tool',
99+
inputSchema: {type: 'object', properties: {}},
100+
execute: async () => ({content: []}),
101+
},
102+
]),
103+
],
104+
},
105+
],
106+
withExperimentalAutoCleanupInjectors(),
107+
),
108+
],
109+
});
110+
111+
const router = TestBed.inject(Router);
112+
const rootFixture = TestBed.createComponent(TestComp);
113+
await rootFixture.whenStable();
114+
115+
// No tools should be registered initially
116+
expect(globalThis.navigator.modelContextTesting!.listTools()).toEqual([]);
117+
118+
// Navigate to the route to register the tools
119+
await router.navigateByUrl('/test');
120+
expect(globalThis.navigator.modelContextTesting!.listTools()).toEqual([
121+
jasmine.objectContaining({name: 'routeTool'}),
122+
]);
123+
124+
// Navigate away to destroy route environment injector context
125+
await router.navigateByUrl('/');
126+
expect(globalThis.navigator.modelContextTesting!.listTools()).toEqual([]);
127+
});
128+
});

0 commit comments

Comments
 (0)