diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 55b6feb3b27..6426f18306b 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -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 { + experimentalWebMcpTool?: { + name: string; + description: string; + }; injector?: Injector; name?: string; submission?: FormSubmitOptions; @@ -546,6 +551,9 @@ export class PatternValidationError extends BaseNgValidationError { readonly pattern: RegExp; } +// @public +export function provideExperimentalWebMcpForms(): EnvironmentProviders; + // @public export function provideSignalFormsConfig(config: SignalFormsConfig): Provider[]; diff --git a/packages/forms/package.json b/packages/forms/package.json index 80b00356df6..8828429824d 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -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", diff --git a/packages/forms/signals/BUILD.bazel b/packages/forms/signals/BUILD.bazel index d0ad95b2cda..c98d9f67265 100644 --- a/packages/forms/signals/BUILD.bazel +++ b/packages/forms/signals/BUILD.bazel @@ -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", ], ) diff --git a/packages/forms/signals/public_api.ts b/packages/forms/signals/public_api.ts index 70607397555..e75ca6acf61 100644 --- a/packages/forms/signals/public_api.ts +++ b/packages/forms/signals/public_api.ts @@ -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'; diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts index 7793de99884..63d85595f84 100644 --- a/packages/forms/signals/src/api/structure.ts +++ b/packages/forms/signals/src/api/structure.ts @@ -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 { * 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; } @@ -197,6 +213,29 @@ export function form(...args: any[]): FieldTree { 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; } diff --git a/packages/forms/signals/src/webmcp/index.ts b/packages/forms/signals/src/webmcp/index.ts new file mode 100644 index 00000000000..ee406615a33 --- /dev/null +++ b/packages/forms/signals/src/webmcp/index.ts @@ -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'; diff --git a/packages/forms/signals/src/webmcp/registration.ts b/packages/forms/signals/src/webmcp/registration.ts new file mode 100644 index 00000000000..fb62323c62e --- /dev/null +++ b/packages/forms/signals/src/webmcp/registration.ts @@ -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) => { + // 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 = {}; + 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, + }, + ]); +} diff --git a/packages/forms/signals/src/webmcp/tokens.ts b/packages/forms/signals/src/webmcp/tokens.ts new file mode 100644 index 00000000000..a413054d242 --- /dev/null +++ b/packages/forms/signals/src/webmcp/tokens.ts @@ -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( + 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, + options: {name: string; description?: string}, +) => void; diff --git a/packages/forms/signals/test/node/BUILD.bazel b/packages/forms/signals/test/node/BUILD.bazel index 9c5f2e1fb9f..7e6d7f13669 100644 --- a/packages/forms/signals/test/node/BUILD.bazel +++ b/packages/forms/signals/test/node/BUILD.bazel @@ -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", diff --git a/packages/forms/signals/test/web/BUILD.bazel b/packages/forms/signals/test/web/BUILD.bazel index af637133821..87d67e4307f 100644 --- a/packages/forms/signals/test/web/BUILD.bazel +++ b/packages/forms/signals/test/web/BUILD.bazel @@ -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", diff --git a/packages/forms/signals/test/web/webmcp.spec.ts b/packages/forms/signals/test/web/webmcp.spec.ts new file mode 100644 index 00000000000..2699544f2be --- /dev/null +++ b/packages/forms/signals/test/web/webmcp.spec.ts @@ -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"/); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca57f6408c5..4dc32f2ed6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}