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!');
      },
    },
  });

  // ...
}
```
This commit is contained in:
Douglas Parker 2026-05-04 14:05:20 -07:00 committed by Leon Senft
parent 358d2e63fb
commit 1963a0eb18
12 changed files with 548 additions and 1 deletions

View file

@ -6,6 +6,7 @@
import { AbstractControl } from '@angular/forms';
import { DebounceTimer } from '@angular/core';
import { EnvironmentProviders } from '@angular/core';
import { HttpResourceOptions } from '@angular/common/http';
import { HttpResourceRequest } from '@angular/common/http';
import * as i0 from '@angular/core';
@ -205,6 +206,10 @@ export interface FormFieldBindingOptions {
// @public
export interface FormOptions<TModel> {
experimentalWebMcpTool?: {
name: string;
description: string;
};
injector?: Injector;
name?: string;
submission?: FormSubmitOptions<TModel, unknown>;
@ -546,6 +551,9 @@ export class PatternValidationError extends BaseNgValidationError {
readonly pattern: RegExp;
}
// @public
export function provideExperimentalWebMcpForms(): EnvironmentProviders;
// @public
export function provideSignalFormsConfig(config: SignalFormsConfig): Provider[];

View file

@ -12,6 +12,10 @@
"@standard-schema/spec": "^1.0.0",
"zod": "^4.0.10"
},
"devDependencies": {
"@mcp-b/webmcp-polyfill": "^2.2.0",
"@mcp-b/webmcp-types": "^2.2.0"
},
"peerDependencies": {
"@angular/core": "0.0.0-PLACEHOLDER",
"@angular/common": "0.0.0-PLACEHOLDER",

View file

@ -14,6 +14,7 @@ ng_project(
"//packages/common/http",
"//packages/core",
"//packages/forms",
"//packages/forms:node_modules/@mcp-b/webmcp-types",
"//packages/forms:node_modules/@standard-schema/spec",
],
)

View file

@ -22,3 +22,4 @@ export * from './src/api/transformed_value';
export * from './src/api/types';
export * from './src/directive/form_field';
export * from './src/directive/form_root';
export * from './src/webmcp';

View file

@ -19,6 +19,7 @@ import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter';
import {FormFieldManager} from '../field/manager';
import {FieldNode} from '../field/node';
import {addDefaultField} from '../field/validation';
import {REGISTER_WEBMCP_FORM} from '../webmcp/tokens';
import {DYNAMIC} from '../schema/logic';
import {FieldPathNode} from '../schema/path_node';
import {assertPathIsCurrent, SchemaImpl} from '../schema/schema';
@ -51,8 +52,23 @@ export interface FormOptions<TModel> {
* current [injection context](guide/di/dependency-injection-context), will be used.
*/
injector?: Injector;
/** The name of the root form, used in generating name attributes for the fields. */
name?: string;
/**
* Configuration options to expose this form as an experimental WebMCP AI agent tool.
*
* @experimental
*/
experimentalWebMcpTool?: {
/** The unique name of the WebMCP tool to create from this form. */
name: string;
/** A description of the tool's purpose and usage information. */
description: string;
};
/** Options that define how to handle form submission. */
submission?: FormSubmitOptions<TModel, unknown>;
}
@ -197,6 +213,29 @@ export function form<TModel>(...args: any[]): FieldTree<TModel> {
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
fieldManager.createFieldManagementEffect(fieldRoot.structure);
// Register a WebMCP tool for the form if configured.
const {experimentalWebMcpTool} = options ?? {};
if (experimentalWebMcpTool) {
const registerWebMcpForm = runInInjectionContext(injector, () =>
inject(REGISTER_WEBMCP_FORM, {optional: true}),
);
if (registerWebMcpForm) {
runInInjectionContext(injector, () =>
registerWebMcpForm(fieldRoot.fieldTree, {
name: experimentalWebMcpTool.name,
description: experimentalWebMcpTool.description,
}),
);
} else {
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
throw new Error(
`Cannot register form "${experimentalWebMcpTool.name}" as a WebMCP tool. ` +
`Make sure to use \`provideExperimentalWebMcpForms()\` in your application bootstrap configuration.`,
);
}
}
}
return fieldRoot.fieldTree as FieldTree<TModel>;
}

View file

@ -0,0 +1,9 @@
/**
* @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
*/
export {provideExperimentalWebMcpForms} from './registration';

View file

@ -0,0 +1,127 @@
/**
* @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,
EnvironmentProviders,
makeEnvironmentProviders,
untracked,
} from '@angular/core';
import type {JsonSchemaForInference} from '@mcp-b/webmcp-types';
import {submit} from '../api/structure';
import {FieldNode} from '../field/node';
import {REGISTER_WEBMCP_FORM, RegisterWebMcpForm} from './tokens';
const registerWebMcpForm: RegisterWebMcpForm = (formTree, options) => {
untracked(() => {
const node = formTree() as FieldNode;
const inputSchema = inferSchemaFromFieldNode(node);
if (!inputSchema) {
throw new Error(
`Could not accurately infer WebMCP schema for form "${options.name}". ` +
`Ensure that the form model does not contain null, undefined, empty arrays, or unsupported types.`,
);
}
declareExperimentalWebMcpTool({
name: options.name,
description: options.description,
inputSchema,
execute: async (args: Record<string, unknown>) => {
// Populate the form with changes from the agent.
node.value.set(args);
// Trigger form submission.
const success = await submit(formTree);
// Report the result to the agent.
if (success) {
return {content: [{type: 'text', text: 'Form submitted successfully.'}]};
} else {
const errorMessages = node
.errorSummary()
.map((err) => {
const fieldName = (err.fieldTree() as FieldNode).structure.pathKeys().join('.');
return `${fieldName ? `${fieldName}: ` : ''}${err.message || err.kind}`;
})
.join('\n');
return {content: [{type: 'text', text: `Form submission failed:\n${errorMessages}`}]};
}
},
});
});
};
/** Infers the JSON schema from a specific form field. */
function inferSchemaFromFieldNode(node: FieldNode): JsonSchemaForInference | undefined {
const value = node.value();
// Primitive types.
if (typeof value === 'string') return {type: 'string'};
if (typeof value === 'number') return {type: 'number'};
if (typeof value === 'boolean') return {type: 'boolean'};
// `null` or `undefined` does not hint at the underlying type.
if (value === null || value === undefined) return undefined;
// Use the type of the first value of an array.
if (Array.isArray(value)) {
if (value.length === 0) return undefined;
const firstChild = node.structure.getChild('0');
if (!firstChild) return undefined;
const itemSchema = inferSchemaFromFieldNode(firstChild);
if (!itemSchema) return undefined;
return {
type: 'array',
items: itemSchema,
};
}
// Recursively infer the types of all object properties.
if (typeof value === 'object') {
const properties: Record<string, JsonSchemaForInference> = {};
const required: string[] = [];
const children = node.structure.children();
for (const child of children) {
const key = child.keyInParent();
const childSchema = inferSchemaFromFieldNode(child);
if (!childSchema) return undefined;
properties[key] = childSchema;
if (child.required()) required.push(key.toString());
}
return {
type: 'object',
properties,
required,
};
}
return undefined; // Unknown type.
}
/**
* Creates a provider that configures all signal forms with `experimentalWebMcpTool`
* to be registered as WebMCP tools.
*
* @experimental
*/
export function provideExperimentalWebMcpForms(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: REGISTER_WEBMCP_FORM,
useValue: registerWebMcpForm,
},
]);
}

View file

@ -0,0 +1,26 @@
/**
* @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 {InjectionToken} from '@angular/core';
import {FieldTree} from '../api/types';
/** A function to register a signal form as a WebMCP tool. */
export const REGISTER_WEBMCP_FORM = new InjectionToken<RegisterWebMcpForm>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'REGISTER_WEBMCP_FORM' : '',
);
/**
* Registers a Signal Form as a WebMCP tool.
*
* @param formTree The form to register.
* @param options Configuration options for the tool.
*/
export type RegisterWebMcpForm = (
form: FieldTree<unknown>,
options: {name: string; description?: string},
) => void;

View file

@ -10,7 +10,6 @@ ng_project(
"//packages/core",
"//packages/core/testing",
"//packages/forms",
"//packages/forms:node_modules/@standard-schema/spec",
"//packages/forms:node_modules/zod",
"//packages/forms/signals",
"//packages/forms/signals/compat",

View file

@ -11,6 +11,9 @@ ng_project(
"//packages/core",
"//packages/core/testing",
"//packages/forms",
"//packages/forms:node_modules/@mcp-b/webmcp-polyfill",
"//packages/forms:node_modules/@mcp-b/webmcp-types",
"//packages/forms:node_modules/@standard-schema/spec",
"//packages/forms:node_modules/zod",
"//packages/forms/signals",
"//packages/forms/signals/compat",

View file

@ -0,0 +1,323 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license block that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {initializeWebMCPPolyfill, cleanupWebMCPPolyfill} from '@mcp-b/webmcp-polyfill';
import {signal} from '@angular/core';
import {form, required, provideExperimentalWebMcpForms} from '@angular/forms/signals';
import {TestBed} from '@angular/core/testing';
describe('Signal Forms WebMCP Integration', () => {
beforeEach(() => {
initializeWebMCPPolyfill({installTestingShim: true});
});
afterEach(() => {
cleanupWebMCPPolyfill();
});
describe('with provideWebMcpForms() provided', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideExperimentalWebMcpForms()],
});
});
it('should infer schema and register form as a tool', () => {
const model = signal({
name: 'John',
age: 30,
isActive: true,
hobbies: ['reading', 'coding'],
address: {
city: 'Sunnyvale',
zip: 94089,
},
});
TestBed.runInInjectionContext(() => {
form(model, {
experimentalWebMcpTool: {
name: 'testFormTool',
description: 'A test form tool',
},
});
});
const registeredTools = globalThis.navigator.modelContextTesting!.listTools();
expect(registeredTools[0].name).toBe('testFormTool');
expect(registeredTools[0].description).toBe('A test form tool');
expect(JSON.parse(registeredTools[0].inputSchema!)).toEqual({
type: 'object',
properties: {
name: {type: 'string'},
age: {type: 'number'},
isActive: {type: 'boolean'},
hobbies: {
type: 'array',
items: {type: 'string'},
},
address: {
type: 'object',
properties: {
city: {type: 'string'},
zip: {type: 'number'},
},
required: [],
},
},
required: [],
});
});
it('should infer required validators in schema', () => {
const model = signal({
name: 'John',
age: 30,
address: {
city: 'Sunnyvale',
zip: 94089,
},
});
TestBed.runInInjectionContext(() => {
form(
model,
(p) => {
required(p.name);
required(p.address.city);
},
{
experimentalWebMcpTool: {
name: 'requiredTestTool',
description: 'A test for required validators',
},
},
);
});
const registeredTools = globalThis.navigator.modelContextTesting!.listTools();
const tool = registeredTools.find((t) => t.name === 'requiredTestTool')!;
expect(JSON.parse(tool.inputSchema!)).toEqual({
type: 'object',
properties: {
name: {type: 'string'},
age: {type: 'number'},
address: {
type: 'object',
properties: {
city: {type: 'string'},
zip: {type: 'number'},
},
required: ['city'],
},
},
required: ['name'],
});
});
it('should fill out and submit the form successfully', async () => {
const model = signal({
name: '',
age: 0,
});
const submitSpy = jasmine.createSpy('submitSpy').and.returnValue(Promise.resolve(undefined));
TestBed.runInInjectionContext(() => {
form(model, {
experimentalWebMcpTool: {
name: 'testFormSubmitTool',
description: 'A test form submit tool',
},
submission: {
action: submitSpy,
},
});
});
const result = await globalThis.navigator.modelContextTesting!.executeTool(
'testFormSubmitTool',
JSON.stringify({
name: 'Alice',
age: 25,
}),
);
// Should update raw data model.
expect(model()).toEqual({
name: 'Alice',
age: 25,
});
expect(submitSpy).toHaveBeenCalledTimes(1);
expect(JSON.parse(result!)).toEqual({
content: [{type: 'text', text: 'Form submitted successfully.'}],
});
});
it('should return a failure message if form validation fails', async () => {
const model = signal({name: {first: ''}});
TestBed.runInInjectionContext(() => {
form(
model,
(p) => {
required(p.name.first, {message: 'First name is required'});
},
{
experimentalWebMcpTool: {
name: 'testFormInvalidTool',
description: 'A validation test tool',
},
submission: {
action: async () => undefined,
},
},
);
});
const result = await globalThis.navigator.modelContextTesting!.executeTool(
'testFormInvalidTool',
JSON.stringify({name: {first: ''}}),
);
expect(JSON.parse(result!)).toEqual({
content: [
{
type: 'text',
text: jasmine.stringContaining('name.first: First name is required'),
},
],
});
});
it('should return a failure message if the submit action fails', async () => {
const model = signal({name: ''});
TestBed.runInInjectionContext(() => {
form(model, {
experimentalWebMcpTool: {
name: 'testFormSubmitFailTool',
description: 'A submit fail test tool',
},
submission: {
action: async () => {
return {
kind: 'submit-failed',
message: 'Database write failed',
};
},
},
});
});
const result = await globalThis.navigator.modelContextTesting!.executeTool(
'testFormSubmitFailTool',
JSON.stringify({name: ''}),
);
expect(JSON.parse(result!)).toEqual({
content: [
{
type: 'text',
text: jasmine.stringContaining('Database write failed'),
},
],
});
});
it('should throw an error if the submit action throws an error', async () => {
const model = signal({name: ''});
TestBed.runInInjectionContext(() => {
form(model, {
experimentalWebMcpTool: {
name: 'testFormSubmitErrorTool',
description: 'A submit error test tool',
},
submission: {
action: async () => {
throw new Error('Database connection lost');
},
},
});
});
await expectAsync(
globalThis.navigator.modelContextTesting!.executeTool(
'testFormSubmitErrorTool',
JSON.stringify({name: ''}),
),
).toBeRejectedWithError(/Database connection lost/);
});
it('should throw an error if schema cannot be inferred accurately', () => {
// 1. Null value
TestBed.runInInjectionContext(() => {
expect(() => {
form(signal({value: null}), {
experimentalWebMcpTool: {
name: 'nullTool',
description: 'A null tool',
},
});
}).toThrowError(/Could not accurately infer WebMCP schema/);
});
expect(
globalThis.navigator.modelContextTesting!.listTools().some((t) => t.name === 'nullTool'),
).toBeFalse();
// 2. Empty array value
TestBed.runInInjectionContext(() => {
expect(() => {
form(signal({value: [] as string[]}), {
experimentalWebMcpTool: {
name: 'emptyArrayTool',
description: 'An empty array tool',
},
});
}).toThrowError(/Could not accurately infer WebMCP schema/);
});
expect(
globalThis.navigator
.modelContextTesting!.listTools()
.some((t) => t.name === 'emptyArrayTool'),
).toBeFalse();
// 3. Unsupported type (symbol)
TestBed.runInInjectionContext(() => {
expect(() => {
form(signal({value: Symbol('test')}), {
experimentalWebMcpTool: {
name: 'symbolTool',
description: 'A symbol tool',
},
});
}).toThrowError(/Could not accurately infer WebMCP schema/);
});
expect(
globalThis.navigator.modelContextTesting!.listTools().some((t) => t.name === 'symbolTool'),
).toBeFalse();
});
});
it('should throw an error if `experimentalWebMcpTool` is configured but `provideWebMcpForms` was not', () => {
const model = signal({name: ''});
TestBed.runInInjectionContext(() => {
expect(() => {
form(model, {
experimentalWebMcpTool: {
name: 'orphanTool',
description: 'An orphan tool with no registry provided',
},
});
}).toThrowError(/Cannot register form "orphanTool"/);
});
});
});

View file

@ -1071,6 +1071,13 @@ importers:
zod:
specifier: ^4.0.10
version: 4.4.3
devDependencies:
'@mcp-b/webmcp-polyfill':
specifier: ^2.2.0
version: 2.3.1
'@mcp-b/webmcp-types':
specifier: ^2.2.0
version: 2.3.1
packages/language-service: {}