Skip to content

Commit da82f24

Browse files
dgp1130leonsenft
authored andcommitted
refactor(forms): add provideExperimentalWebMcpForms
This enables the use of the `experimentalWebMcpTool` option on signal forms and implicitly declares a WebMCP tool based on the form data model. This is an experiment inspirted by the WebMCP declarative forms API to see if Angular's framework-level knowledge of the form's declarative data model can produce higher quality WebMCP tools than the web standard can on its own with less effort from the developer. Example: ```typescript // main.ts import {bootstrapApplication} from '@angular/platform-browser'; import {provideExperimentalWebMcpForms} from '@angular/forms'; import {MyComp} from './form'; bootstrapApplication(MyComp, { providers: [ // Activate the feature. provideExperimentalWebMcpForms(), ], }); ``` ```typescript // form.ts import {Component, signal} from '@angular/core'; import {form} from '@angular/forms'; @component({ /* ... */ }) export class MyComp { private readonly f = form(signal({ firstName: '', lastName: '', }), { // Implicitly creates a WebMCP tool named `createUser` which accepts a `firstName` and `lastName` as parameters. experimentalWebMcpTool: { name: 'createUser', description: 'Creates a user with the given name.', }, // Invokes the submit action when the agent calls the WebMCP tool. submission: { action: () => { console.log('User clicked submit, or agent called the tool!'); }, }, }); // ... } ```
1 parent defb172 commit da82f24

12 files changed

Lines changed: 548 additions & 1 deletion

File tree

goldens/public-api/forms/signals/index.api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { AbstractControl } from '@angular/forms';
88
import { DebounceTimer } from '@angular/core';
9+
import { EnvironmentProviders } from '@angular/core';
910
import { HttpResourceOptions } from '@angular/common/http';
1011
import { HttpResourceRequest } from '@angular/common/http';
1112
import * as i0 from '@angular/core';
@@ -205,6 +206,10 @@ export interface FormFieldBindingOptions {
205206

206207
// @public
207208
export interface FormOptions<TModel> {
209+
experimentalWebMcpTool?: {
210+
name: string;
211+
description: string;
212+
};
208213
injector?: Injector;
209214
name?: string;
210215
submission?: FormSubmitOptions<TModel, unknown>;
@@ -546,6 +551,9 @@ export class PatternValidationError extends BaseNgValidationError {
546551
readonly pattern: RegExp;
547552
}
548553

554+
// @public
555+
export function provideExperimentalWebMcpForms(): EnvironmentProviders;
556+
549557
// @public
550558
export function provideSignalFormsConfig(config: SignalFormsConfig): Provider[];
551559

packages/forms/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"@standard-schema/spec": "^1.0.0",
1313
"zod": "^4.0.10"
1414
},
15+
"devDependencies": {
16+
"@mcp-b/webmcp-polyfill": "^2.2.0",
17+
"@mcp-b/webmcp-types": "^2.2.0"
18+
},
1519
"peerDependencies": {
1620
"@angular/core": "0.0.0-PLACEHOLDER",
1721
"@angular/common": "0.0.0-PLACEHOLDER",

packages/forms/signals/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ng_project(
1414
"//packages/common/http",
1515
"//packages/core",
1616
"//packages/forms",
17+
"//packages/forms:node_modules/@mcp-b/webmcp-types",
1718
"//packages/forms:node_modules/@standard-schema/spec",
1819
],
1920
)

packages/forms/signals/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export * from './src/api/transformed_value';
2222
export * from './src/api/types';
2323
export * from './src/directive/form_field';
2424
export * from './src/directive/form_root';
25+
export * from './src/webmcp';

packages/forms/signals/src/api/structure.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter';
1919
import {FormFieldManager} from '../field/manager';
2020
import {FieldNode} from '../field/node';
2121
import {addDefaultField} from '../field/validation';
22+
import {REGISTER_WEBMCP_FORM} from '../webmcp/tokens';
2223
import {DYNAMIC} from '../schema/logic';
2324
import {FieldPathNode} from '../schema/path_node';
2425
import {assertPathIsCurrent, SchemaImpl} from '../schema/schema';
@@ -51,8 +52,23 @@ export interface FormOptions<TModel> {
5152
* current [injection context](guide/di/dependency-injection-context), will be used.
5253
*/
5354
injector?: Injector;
55+
5456
/** The name of the root form, used in generating name attributes for the fields. */
5557
name?: string;
58+
59+
/**
60+
* Configuration options to expose this form as an experimental WebMCP AI agent tool.
61+
*
62+
* @experimental
63+
*/
64+
experimentalWebMcpTool?: {
65+
/** The unique name of the WebMCP tool to create from this form. */
66+
name: string;
67+
68+
/** A description of the tool's purpose and usage information. */
69+
description: string;
70+
};
71+
5672
/** Options that define how to handle form submission. */
5773
submission?: FormSubmitOptions<TModel, unknown>;
5874
}
@@ -197,6 +213,29 @@ export function form<TModel>(...args: any[]): FieldTree<TModel> {
197213
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
198214
fieldManager.createFieldManagementEffect(fieldRoot.structure);
199215

216+
// Register a WebMCP tool for the form if configured.
217+
const {experimentalWebMcpTool} = options ?? {};
218+
if (experimentalWebMcpTool) {
219+
const registerWebMcpForm = runInInjectionContext(injector, () =>
220+
inject(REGISTER_WEBMCP_FORM, {optional: true}),
221+
);
222+
if (registerWebMcpForm) {
223+
runInInjectionContext(injector, () =>
224+
registerWebMcpForm(fieldRoot.fieldTree, {
225+
name: experimentalWebMcpTool.name,
226+
description: experimentalWebMcpTool.description,
227+
}),
228+
);
229+
} else {
230+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
231+
throw new Error(
232+
`Cannot register form "${experimentalWebMcpTool.name}" as a WebMCP tool. ` +
233+
`Make sure to use \`provideExperimentalWebMcpForms()\` in your application bootstrap configuration.`,
234+
);
235+
}
236+
}
237+
}
238+
200239
return fieldRoot.fieldTree as FieldTree<TModel>;
201240
}
202241

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
export {provideExperimentalWebMcpForms} from './registration';
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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 {
10+
declareExperimentalWebMcpTool,
11+
EnvironmentProviders,
12+
makeEnvironmentProviders,
13+
untracked,
14+
} from '@angular/core';
15+
import type {JsonSchemaForInference} from '@mcp-b/webmcp-types';
16+
import {submit} from '../api/structure';
17+
import {FieldNode} from '../field/node';
18+
import {REGISTER_WEBMCP_FORM, RegisterWebMcpForm} from './tokens';
19+
20+
const registerWebMcpForm: RegisterWebMcpForm = (formTree, options) => {
21+
untracked(() => {
22+
const node = formTree() as FieldNode;
23+
const inputSchema = inferSchemaFromFieldNode(node);
24+
25+
if (!inputSchema) {
26+
throw new Error(
27+
`Could not accurately infer WebMCP schema for form "${options.name}". ` +
28+
`Ensure that the form model does not contain null, undefined, empty arrays, or unsupported types.`,
29+
);
30+
}
31+
32+
declareExperimentalWebMcpTool({
33+
name: options.name,
34+
description: options.description,
35+
inputSchema,
36+
execute: async (args: Record<string, unknown>) => {
37+
// Populate the form with changes from the agent.
38+
node.value.set(args);
39+
40+
// Trigger form submission.
41+
const success = await submit(formTree);
42+
43+
// Report the result to the agent.
44+
if (success) {
45+
return {content: [{type: 'text', text: 'Form submitted successfully.'}]};
46+
} else {
47+
const errorMessages = node
48+
.errorSummary()
49+
.map((err) => {
50+
const fieldName = (err.fieldTree() as FieldNode).structure.pathKeys().join('.');
51+
return `${fieldName ? `${fieldName}: ` : ''}${err.message || err.kind}`;
52+
})
53+
.join('\n');
54+
return {content: [{type: 'text', text: `Form submission failed:\n${errorMessages}`}]};
55+
}
56+
},
57+
});
58+
});
59+
};
60+
61+
/** Infers the JSON schema from a specific form field. */
62+
function inferSchemaFromFieldNode(node: FieldNode): JsonSchemaForInference | undefined {
63+
const value = node.value();
64+
65+
// Primitive types.
66+
if (typeof value === 'string') return {type: 'string'};
67+
if (typeof value === 'number') return {type: 'number'};
68+
if (typeof value === 'boolean') return {type: 'boolean'};
69+
70+
// `null` or `undefined` does not hint at the underlying type.
71+
if (value === null || value === undefined) return undefined;
72+
73+
// Use the type of the first value of an array.
74+
if (Array.isArray(value)) {
75+
if (value.length === 0) return undefined;
76+
77+
const firstChild = node.structure.getChild('0');
78+
if (!firstChild) return undefined;
79+
80+
const itemSchema = inferSchemaFromFieldNode(firstChild);
81+
if (!itemSchema) return undefined;
82+
83+
return {
84+
type: 'array',
85+
items: itemSchema,
86+
};
87+
}
88+
89+
// Recursively infer the types of all object properties.
90+
if (typeof value === 'object') {
91+
const properties: Record<string, JsonSchemaForInference> = {};
92+
const required: string[] = [];
93+
const children = node.structure.children();
94+
for (const child of children) {
95+
const key = child.keyInParent();
96+
const childSchema = inferSchemaFromFieldNode(child);
97+
if (!childSchema) return undefined;
98+
99+
properties[key] = childSchema;
100+
101+
if (child.required()) required.push(key.toString());
102+
}
103+
104+
return {
105+
type: 'object',
106+
properties,
107+
required,
108+
};
109+
}
110+
111+
return undefined; // Unknown type.
112+
}
113+
114+
/**
115+
* Creates a provider that configures all signal forms with `experimentalWebMcpTool`
116+
* to be registered as WebMCP tools.
117+
*
118+
* @experimental
119+
*/
120+
export function provideExperimentalWebMcpForms(): EnvironmentProviders {
121+
return makeEnvironmentProviders([
122+
{
123+
provide: REGISTER_WEBMCP_FORM,
124+
useValue: registerWebMcpForm,
125+
},
126+
]);
127+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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 {InjectionToken} from '@angular/core';
10+
import {FieldTree} from '../api/types';
11+
12+
/** A function to register a signal form as a WebMCP tool. */
13+
export const REGISTER_WEBMCP_FORM = new InjectionToken<RegisterWebMcpForm>(
14+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'REGISTER_WEBMCP_FORM' : '',
15+
);
16+
17+
/**
18+
* Registers a Signal Form as a WebMCP tool.
19+
*
20+
* @param formTree The form to register.
21+
* @param options Configuration options for the tool.
22+
*/
23+
export type RegisterWebMcpForm = (
24+
form: FieldTree<unknown>,
25+
options: {name: string; description?: string},
26+
) => void;

packages/forms/signals/test/node/BUILD.bazel

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ ng_project(
1010
"//packages/core",
1111
"//packages/core/testing",
1212
"//packages/forms",
13-
"//packages/forms:node_modules/@standard-schema/spec",
1413
"//packages/forms:node_modules/zod",
1514
"//packages/forms/signals",
1615
"//packages/forms/signals/compat",

packages/forms/signals/test/web/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ ng_project(
1111
"//packages/core",
1212
"//packages/core/testing",
1313
"//packages/forms",
14+
"//packages/forms:node_modules/@mcp-b/webmcp-polyfill",
15+
"//packages/forms:node_modules/@mcp-b/webmcp-types",
16+
"//packages/forms:node_modules/@standard-schema/spec",
1417
"//packages/forms:node_modules/zod",
1518
"//packages/forms/signals",
1619
"//packages/forms/signals/compat",

0 commit comments

Comments
 (0)