mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
358d2e63fb
commit
1963a0eb18
12 changed files with 548 additions and 1 deletions
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
9
packages/forms/signals/src/webmcp/index.ts
Normal file
9
packages/forms/signals/src/webmcp/index.ts
Normal 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';
|
||||
127
packages/forms/signals/src/webmcp/registration.ts
Normal file
127
packages/forms/signals/src/webmcp/registration.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
26
packages/forms/signals/src/webmcp/tokens.ts
Normal file
26
packages/forms/signals/src/webmcp/tokens.ts
Normal 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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
323
packages/forms/signals/test/web/webmcp.spec.ts
Normal file
323
packages/forms/signals/test/web/webmcp.spec.ts
Normal 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"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue