diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java
index b8e7a7b723..bd9ee8f4b7 100644
--- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java
+++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java
@@ -177,6 +177,7 @@ private ReservedProperties() {
public static final String FD_DRAFT_ID = "fd:draftId";
public static final String FD_CHANGE_EVENT_BEHAVIOUR = "fd:changeEventBehaviour";
public static final String FD_SET_PROPERTY_BEHAVIOUR = "fd:setPropertyBehaviour";
+ public static final String FD_WEB_MCP_ENABLED = "fd:webMcpEnabled";
public static final String PN_CQ_ANNOTATIONS = "cq:annotations";
diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java
index 67b2025fa5..e1437bd7d3 100644
--- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java
+++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java
@@ -154,6 +154,10 @@ public class FormContainerImpl extends AbstractContainerImpl implements FormCont
@Nullable
private String setPropertyBehaviour;
+ @ValueMapValue(name = ReservedProperties.FD_WEB_MCP_ENABLED, injectionStrategy = InjectionStrategy.OPTIONAL)
+ @Nullable
+ private Boolean webMcpEnabled;
+
@ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = ReservedProperties.PN_DATA)
@Nullable
private String data;
@@ -437,6 +441,9 @@ public String getLanguageDirection() {
if (StringUtils.isNotBlank(setPropertyBehaviour)) {
properties.put(ReservedProperties.FD_SET_PROPERTY_BEHAVIOUR, setPropertyBehaviour);
}
+ if (webMcpEnabled != null) {
+ properties.put(ReservedProperties.FD_WEB_MCP_ENABLED, webMcpEnabled);
+ }
properties.put(FD_FORM_DATA_ENABLED, formDataEnabled);
if (this.autoSaveConfig != null && this.autoSaveConfig.isEnableAutoSave()) {
properties.put(ReservedProperties.FD_AUTO_SAVE_PROPERTY_WRAPPER, this.autoSaveConfig);
diff --git a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java
index 0be68a8040..08bf087560 100644
--- a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java
+++ b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java
@@ -693,6 +693,46 @@ void testSetPropertyBehaviourAbsentWhenNotAuthored() throws Exception {
assertNull(formContainer.getProperties().get(ReservedProperties.FD_SET_PROPERTY_BEHAVIOUR));
}
+ @Test
+ void testWebMcpEnabledAbsentWhenNotAuthored() throws Exception {
+ FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_1, FormContainer.class, context);
+ assertFalse(formContainer.getProperties().containsKey(ReservedProperties.FD_WEB_MCP_ENABLED));
+ JsonNode properties = new ObjectMapper().readTree(Utils.getJson(formContainer, Views.Publish.class)).get("properties");
+ assertFalse(properties.has(ReservedProperties.FD_WEB_MCP_ENABLED));
+ }
+
+ @Test
+ void testWebMcpEnabledFromNode() throws Exception {
+ assertWebMcpEnabledExport(true, true);
+ }
+
+ @Test
+ void testWebMcpDisabledFromNode() throws Exception {
+ assertWebMcpEnabledExport(false, false);
+ }
+
+ @Test
+ void testWebMcpEnabledFromStringNode() throws Exception {
+ assertWebMcpEnabledExport("true", true);
+ }
+
+ @Test
+ void testWebMcpDisabledFromStringNode() throws Exception {
+ assertWebMcpEnabledExport("false", false);
+ }
+
+ private void assertWebMcpEnabledExport(Object authoredValue, boolean enabled) throws Exception {
+ Resource resource = context.resourceResolver().getResource(PATH_FORM_1);
+ resource.adaptTo(ModifiableValueMap.class).put(ReservedProperties.FD_WEB_MCP_ENABLED, authoredValue);
+ FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_1, FormContainer.class, context);
+ assertEquals(enabled, formContainer.getProperties().get(ReservedProperties.FD_WEB_MCP_ENABLED));
+ JsonNode property = new ObjectMapper().readTree(Utils.getJson(formContainer, Views.Publish.class))
+ .get("properties").get(ReservedProperties.FD_WEB_MCP_ENABLED);
+ assertNotNull(property);
+ assertTrue(property.isBoolean());
+ assertEquals(enabled, property.booleanValue());
+ }
+
@Test
void testCustomFunctionUrl() throws Exception {
FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_1, FormContainer.class, context);
diff --git a/docs/e2e-testing/feature-toggles.md b/docs/e2e-testing/feature-toggles.md
index 641d26b5f0..d04e6a2649 100644
--- a/docs/e2e-testing/feature-toggles.md
+++ b/docs/e2e-testing/feature-toggles.md
@@ -37,7 +37,7 @@ it/config/src/main/content/jcr_root/apps/system/config/
com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json
```
-Add the toggle name to the `enabled` array in that file to turn it on in IT.
+Add the toggle name to the `enabledToggles` array in that file to turn it on in IT.
### Layer 2 — JVM System Property (server-side)
@@ -77,7 +77,7 @@ When adding a new toggle, the only config file that requires an update is:
| File | Change |
|------|--------|
-| `com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json` | Add toggle name to `enabled` array |
+| `com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json` | Add toggle name to `enabledToggles` array |
---
@@ -178,6 +178,11 @@ it/content/src/main/content/jcr_root/content/forms/af/core-components-it/samples
|------|----------------|
| `ui.tests/test-module/specs/fragment/fragment.featuretoggles.cy.js` | FT_FORMS-24087, FT_FORMS-24343, FT_FORMS-24358 |
| `ui.tests/test-module/specs/panelcontainer/panelcontainer.featuretoggles.cy.js` | FT_FORMS-24358 |
+| `ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js` | FT_FORMS-28233 (latest addon) |
+
+The enabled WebMCP authoring suite runs on the latest addon, uses the existing accessibility sample, enables **Enable AI assistant access (WebMCP)** in the container dialog, and opens the saved form in preview. A preview-scoped response interceptor installs a test-only `document.modelContext` host before runtime scripts load; the real runtime must register `list_forms` plus all 12 form tools and return the accessibility form's field summary. Authenticated browser cleanup restores the original opt-in property and its type, or deletes it when originally absent.
+
+Running the authoring flow requires matching Java models and standard/XFA runtime clientlibs, IT content, the examples page component package, and the IT toggle configuration. The instance must have the development toggle provider installed and expose `FT_FORMS-28233` in `/etc.clientlibs/toggles.json`; configuring `enabledToggles` without an active provider does not enable the flag. When the flag is absent, including in without-FT jobs, the suite is reported as skipped without changing the opt-in property. With the flag enabled, the full authoring and preview assertions run.
---
@@ -219,7 +224,7 @@ curl -s -u admin:admin "http://localhost:4502/content/forms/af/core-components-i
1. **Define the constant** — add `public static final String FT_FORMS_XXXXX = "FT_FORMS-XXXXX";` to `FeatureToggleConstants.java`.
-2. **Enable in Granite config** — add `"FT_FORMS-XXXXX"` to the `enabled` array in `DynamicToggleProviderImpl.cfg.json`.
+2. **Enable in Granite config** — add `"FT_FORMS-XXXXX"` to the `enabledToggles` array in `DynamicToggleProviderImpl.cfg.json`.
3. **Provision test content** — decide whether to reuse an existing IT sample page or create a new one under `it/content/.../samples/`. Create new JCR content only when the toggled behavior requires it.
diff --git a/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json b/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json
index 4b8c218bf4..f7b302323f 100644
--- a/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json
+++ b/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json
@@ -28,7 +28,8 @@
"FT_FORMS-24087",
"FT_FORMS-24343",
"FT_FORMS-25252",
- "FT_FORMS-27648"
+ "FT_FORMS-27648",
+ "FT_FORMS-28233"
],
"disabledToggles": [
]
diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml
index 7ce0ab65c4..2636bc1e13 100644
--- a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml
+++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml
@@ -92,6 +92,19 @@
text="Enable the hamburger menu for mobile view"
fieldDescription="Select the option to display the hamburger menu for efficient navigation on mobile devices."
value="{Boolean}true"/>
+
+
+
({registerFormWebMCP: jest.fn()}));
+
import {Constants} from "../src/index";
import FormContainer from "../src/view/FormContainer";
import formJson from './resources/form.json';
@@ -53,14 +55,18 @@ test('GuideBridge test', () => {
const resetButton = resultModel.getElement("reset-c7cccb1a5e");
expect(resetButton).not.toBeNull();
expect(resetButton.visible).toBeFalsy();
+ guideBridge.unloadAdaptiveForm("/a/b/c");
+ expect(unregisterWebMcp).toHaveBeenCalledTimes(1);
}});
});
+ const unregisterWebMcp = jest.fn();
var formContainer = new FormContainer({
_formJson: formJson,
_prefillData: {data: {"textinput1662370110841" : "initialData"}},
_path: "/a/b/c"
});
+ formContainer._setWebMcpUnregister(unregisterWebMcp);
const event = new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, { "detail": formContainer });
document.dispatchEvent(event);
});
diff --git a/ui.frontend/__tests__/webmcp.integration.test.js b/ui.frontend/__tests__/webmcp.integration.test.js
new file mode 100644
index 0000000000..53bb0ac15f
--- /dev/null
+++ b/ui.frontend/__tests__/webmcp.integration.test.js
@@ -0,0 +1,91 @@
+/*******************************************************************************
+ * Copyright 2026 Adobe
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ******************************************************************************/
+
+import {createFormInstance} from '@aemforms/af-core';
+import {registerFormWebMCP} from '@aemforms/af-webmcp';
+import Utils from '../src/utils';
+
+describe('published WebMCP runtime integration', () => {
+ let unregister;
+ let tools;
+ let handles;
+ let host;
+ let rum;
+
+ beforeEach(() => {
+ tools = new Map();
+ handles = [];
+ host = {registerTool: jest.fn(tool => {
+ tools.set(tool.name, tool);
+ const handle = {unregister: jest.fn(() => tools.delete(tool.name))};
+ handles.push(handle);
+ return handle;
+ })};
+ document.modelContext = host;
+ rum = jest.fn();
+ window.hlx = {sampleRUM: rum};
+ });
+
+ afterEach(() => {
+ if (unregister) { unregister(); }
+ unregister = undefined;
+ delete document.modelContext;
+ delete window.hlx;
+ document.body.innerHTML = '';
+ });
+
+ function form(enabled) {
+ return createFormInstance({
+ id: 'published-runtime',
+ properties: {'fd:webMcpEnabled': enabled},
+ items: [{id: 'name', name: 'name', type: 'string', fieldType: 'text-input', value: 'before'}]
+ });
+ }
+
+ test('registers actual tools, changes values, bridges DOM focus, and cleans up', async () => {
+ const model = form(true);
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+ const view = {setFocus: jest.fn(() => input.focus())};
+ unregister = registerFormWebMCP(model, Utils.getWebMcpOptions(view));
+
+ expect(tools.get('validate_form_completeness').annotations)
+ .toEqual(expect.objectContaining({readOnlyHint: true, untrustedContentHint: true}));
+ expect(await tools.get('set_field_value').execute({field: 'name', value: 'after'}))
+ .toEqual(expect.objectContaining({success: true, changed: true}));
+ expect(model.getElement('name').value).toBe('after');
+ expect(rum).toHaveBeenCalledWith('fill', {source: 'af-webmcp', target: 'set_field_value'});
+ rum.mockClear();
+ await tools.get('set_field_value').execute({field: 'name', value: 'after'});
+ expect(rum).not.toHaveBeenCalled();
+
+ expect((await tools.get('focus_field').execute({field: 'name'})).success).toBe(true);
+ expect(view.setFocus).toHaveBeenCalledWith('name');
+ expect(document.activeElement).toBe(input);
+ expect(rum).toHaveBeenCalledWith('click', {source: 'af-webmcp', target: 'focus_field'});
+
+ unregister();
+ expect(tools.size).toBe(0);
+ handles.forEach(handle => expect(handle.unregister).toHaveBeenCalledTimes(1));
+ unregister = undefined;
+ });
+
+ test('does not expose tools for a form that did not opt in', () => {
+ unregister = registerFormWebMCP(form(false));
+ expect(host.registerTool).not.toHaveBeenCalled();
+ expect(tools.size).toBe(0);
+ });
+});
diff --git a/ui.frontend/__tests__/webmcp.test.js b/ui.frontend/__tests__/webmcp.test.js
new file mode 100644
index 0000000000..6a59a947ca
--- /dev/null
+++ b/ui.frontend/__tests__/webmcp.test.js
@@ -0,0 +1,164 @@
+/*******************************************************************************
+ * Copyright 2022 Adobe
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ******************************************************************************/
+
+// Adapter behavior lives in af-webmcp's suite; these tests cover bootstrap and scoped forwarding.
+jest.mock('@aemforms/af-webmcp', () => ({registerFormWebMCP: jest.fn()}));
+jest.mock('../src/HTTPAPILayer.js', () => ({
+ __esModule: true,
+ default: {getFormDefinition: jest.fn(), getJson: jest.fn()}
+}));
+jest.mock('../src/RuleUtils.js', () => ({
+ __esModule: true,
+ default: {registerCustomFunctionsV2: jest.fn(), registerCustomFunctionsByUrl: jest.fn()}
+}));
+
+import Utils from '../src/utils';
+import FormContainer from '../src/view/FormContainer';
+import {registerFormWebMCP} from '@aemforms/af-webmcp';
+import HTTPAPILayer from '../src/HTTPAPILayer.js';
+import formJson from './resources/form.json';
+import GuideBridge from '../src/GuideBridge';
+import {Constants} from '../src/constants';
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ jest.clearAllMocks();
+});
+
+test('setupFormContainer registers the WebMCP catalog once with the form model', async () => {
+ HTTPAPILayer.getFormDefinition.mockResolvedValue(formJson);
+ const unregister = jest.fn();
+ registerFormWebMCP.mockReturnValue(unregister);
+
+ const el = document.createElement('div');
+ el.classList.add('cmp-adaptiveform-container');
+ el.dataset.cmpPath = '/content/a/b/c';
+ document.body.appendChild(el);
+
+ let container;
+ const createFormContainer = (params) => {
+ container = new FormContainer(params);
+ return container;
+ };
+
+ await Utils.setupFormContainer(createFormContainer, '.cmp-adaptiveform-container', 'adaptiveFormContainer');
+
+ expect(registerFormWebMCP).toHaveBeenCalledTimes(1);
+ expect(registerFormWebMCP).toHaveBeenCalledWith(container.getModel(), {onFocusRequest: expect.any(Function)});
+ expect(container.getModel().id).toBe('/content/a/b/c');
+ container._unregisterWebMcp();
+ expect(unregister).toHaveBeenCalledTimes(1);
+});
+
+test('setupFormContainer preserves pre-initialization and connect callback tools', async () => {
+ HTTPAPILayer.getFormDefinition.mockResolvedValue(formJson);
+ const unregister = jest.fn();
+ const additionalTools = jest.fn();
+ const bridge = new GuideBridge();
+ const path = '/content/a/b/domain-form';
+ bridge.registerWebMcpAdditionalTools(path, additionalTools);
+ const replacement = jest.fn();
+ bridge.connect(() => bridge.registerWebMcpAdditionalTools(path, replacement), null, path);
+ registerFormWebMCP.mockReturnValue(unregister);
+
+ const el = document.createElement('div');
+ el.classList.add('cmp-adaptiveform-container');
+ el.dataset.cmpPath = '/content/a/b/domain-form';
+ document.body.appendChild(el);
+
+ let container;
+ const createFormContainer = (params) => {
+ container = new FormContainer(params);
+ return container;
+ };
+ await Utils.setupFormContainer(createFormContainer, '.cmp-adaptiveform-container', 'adaptiveFormContainer');
+ expect(registerFormWebMCP).toHaveBeenCalledWith(container.getModel(), {additionalTools, onFocusRequest: expect.any(Function)});
+ expect(registerFormWebMCP).toHaveBeenLastCalledWith(container.getModel(), {additionalTools: replacement, onFocusRequest: expect.any(Function)});
+ bridge.unloadAdaptiveForm(path);
+});
+
+test('explicit paths isolate two forms and stale disposers cannot remove replacements', () => {
+ const bridge = new GuideBridge();
+ const first = new FormContainer({_formJson: formJson, _prefillData: {}, _path: '/first', _element: document.createElement('div')});
+ const second = new FormContainer({_formJson: formJson, _prefillData: {}, _path: '/second', _element: document.createElement('div')});
+ const factory = jest.fn();
+ const replacement = jest.fn();
+ const dispose = bridge.registerWebMcpAdditionalTools('/first', factory);
+ [first, second].forEach(container => {
+ container._setWebMcpUnregister(jest.fn());
+ document.dispatchEvent(new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, {detail: container}));
+ });
+ expect(registerFormWebMCP).toHaveBeenCalledTimes(1);
+ expect(registerFormWebMCP).toHaveBeenCalledWith(first.getModel(), {additionalTools: factory, onFocusRequest: expect.any(Function)});
+ const remove = bridge.registerWebMcpAdditionalTools('/first', replacement);
+ dispose();
+ expect(registerFormWebMCP).toHaveBeenCalledTimes(2);
+ remove();
+ expect(registerFormWebMCP).toHaveBeenLastCalledWith(first.getModel(), {onFocusRequest: expect.any(Function)});
+ bridge.registerWebMcpAdditionalTools('/second', factory);
+ const unregister = jest.fn();
+ second._setWebMcpUnregister(unregister);
+ bridge.unloadAdaptiveForm('/second');
+ expect(unregister).toHaveBeenCalledTimes(1);
+ bridge.unloadAdaptiveForm('/first');
+ registerFormWebMCP.mockClear();
+ document.dispatchEvent(new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, {detail: second}));
+ expect(registerFormWebMCP).not.toHaveBeenCalled();
+ bridge.unloadAdaptiveForm('/second');
+});
+
+test('registration rejects invalid factories and missing explicit targets before changing tools', () => {
+ const bridge = new GuideBridge();
+ expect(() => bridge.registerWebMcpAdditionalTools('', jest.fn())).toThrow(TypeError);
+ expect(() => bridge.registerWebMcpAdditionalTools('/first', {})).toThrow(TypeError);
+ expect(() => bridge.registerWebMcpAdditionalTools('/first', undefined)).toThrow(TypeError);
+ expect(registerFormWebMCP).not.toHaveBeenCalled();
+});
+
+test('renderer focus options restore focus through the owning container and detect no-ops', () => {
+ const input = document.createElement('input');
+ const outside = document.createElement('button');
+ document.body.append(input, outside);
+ const first = {setFocus: jest.fn(() => input.focus())};
+ const options = Utils.getWebMcpOptions(first);
+ expect(options.onFocusRequest('city')).toBe(true);
+ expect(document.activeElement).toBe(input);
+ expect(options.onFocusRequest('city')).toBe(false);
+ outside.focus();
+ expect(options.onFocusRequest('city')).toBe(true);
+ expect(document.activeElement).toBe(input);
+ expect(first.setFocus).toHaveBeenCalledWith('city');
+});
+
+test('scoped configuration preserves the adapter opt-out gate and does not invoke the factory itself', () => {
+ const bridge = new GuideBridge();
+ const factory = jest.fn();
+ const optedOutJson = {...formJson, properties: {...formJson.properties, 'fd:webMcpEnabled': false}};
+ const view = new FormContainer({
+ _formJson: optedOutJson, _prefillData: {}, _path: '/opted-out', _element: document.createElement('div')
+ });
+ registerFormWebMCP.mockImplementation((model, options) => {
+ if (model.properties['fd:webMcpEnabled'] !== true) { return undefined; }
+ options.additionalTools(model);
+ return jest.fn();
+ });
+ bridge.registerWebMcpAdditionalTools('/opted-out', factory);
+ document.dispatchEvent(new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, {detail: view}));
+ expect(factory).not.toHaveBeenCalled();
+ expect(view._webMcpUnregister).toBeNull();
+ bridge.unloadAdaptiveForm('/opted-out');
+ registerFormWebMCP.mockReset();
+});
diff --git a/ui.frontend/jest.config.js b/ui.frontend/jest.config.js
index 0795e79e53..69992d5600 100644
--- a/ui.frontend/jest.config.js
+++ b/ui.frontend/jest.config.js
@@ -14,7 +14,7 @@
* limitations under the License.
******************************************************************************/
-const esModules = ['@adobe/json-formula', '@aemforms/af-custom-functions'].join('|');
+const esModules = ['@adobe/json-formula', '@aemforms/af-custom-functions', '@aemforms/af-webmcp'].join('|');
module.exports = {
transformIgnorePatterns: [`node_modules/(?!${esModules})`],
diff --git a/ui.frontend/package-lock.json b/ui.frontend/package-lock.json
index 40bf8a7f1f..964dbef627 100644
--- a/ui.frontend/package-lock.json
+++ b/ui.frontend/package-lock.json
@@ -9,10 +9,11 @@
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
- "@aemforms/af-core": "^1.0.4",
+ "@aemforms/af-core": "^1.0.7",
"@aemforms/af-core-xfa": "^0.1.6",
"@aemforms/af-custom-functions": "1.0.18",
- "@aemforms/af-formatters": "^1.0.4"
+ "@aemforms/af-formatters": "^1.0.7",
+ "@aemforms/af-webmcp": "^1.0.7"
},
"devDependencies": {
"@babel/preset-env": "^7.18.2",
@@ -108,13 +109,13 @@
}
},
"node_modules/@aemforms/af-core": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.4.tgz",
- "integrity": "sha512-CYxtPZNFNvRbkOnCGlvwuUXWVmC2I0EmoOf0QgCW2na+GaJHcIA9CkxmzeRhSAui2LomxVkYvQNQBJRVAwTclQ==",
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.7.tgz",
+ "integrity": "sha512-hw6xDFlFwvRcySvmvqb5Xl9sHB6Q5Fz7POURfGFzSfxf+HFc2OkO7sk9mGwPKD22Og3lqC8jxnEr/uL47aa4pA==",
"license": "Adobe Proprietary",
"dependencies": {
"@adobe/json-formula": "0.1.50",
- "@aemforms/af-formatters": "^1.0.4"
+ "@aemforms/af-formatters": "^1.0.7"
}
},
"node_modules/@aemforms/af-core-xfa": {
@@ -137,11 +138,20 @@
"integrity": "sha512-Hg7dkONjmR3aH/Iec+/HRY/lCzVvP3GI6H9wD5FNGsE9zSxPcCzMtnAA0P7n/RKMjcnqYBMt4pFQ6MVb+YvIGg=="
},
"node_modules/@aemforms/af-formatters": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.4.tgz",
- "integrity": "sha512-ShqnAHnwTN6JpEZidP1jqZCy5rku/H+2M1oqC7OwDLenQv55LUQasfvINyhG5H7jVKrocyHpijR5BDKlPdewMw==",
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.7.tgz",
+ "integrity": "sha512-2dEAOmYIH8FfYiiLzPSWqBDmdorrBFCzc+YsQ4JfDoZNH7qMPebnyujZsaFG3grhxUJcz8npMnKdPzDCqUjiqg==",
"license": "Adobe Proprietary"
},
+ "node_modules/@aemforms/af-webmcp": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@aemforms/af-webmcp/-/af-webmcp-1.0.7.tgz",
+ "integrity": "sha512-YvX/BSwnw7/YBXuhtqaKCPoseglR9n9k/1KJKSNxWUWxD9G8JXTkYmN7y04TdDg4GeEAUxzSFaxmgdKf2fXsYQ==",
+ "license": "Adobe Proprietary",
+ "dependencies": {
+ "@aemforms/af-core": "^1.0.7"
+ }
+ },
"node_modules/@ampproject/remapping": {
"version": "2.2.1",
"dev": true,
@@ -11139,12 +11149,12 @@
"integrity": "sha512-dmlLYfbty8NPVIdxvI9cJ+ZdXsrRCFrCdmL1+aR2auEzXJ86rD0bm1qu+S4NOpFiZLKIyx0zvUTykms40vNjsA=="
},
"@aemforms/af-core": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.4.tgz",
- "integrity": "sha512-CYxtPZNFNvRbkOnCGlvwuUXWVmC2I0EmoOf0QgCW2na+GaJHcIA9CkxmzeRhSAui2LomxVkYvQNQBJRVAwTclQ==",
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.7.tgz",
+ "integrity": "sha512-hw6xDFlFwvRcySvmvqb5Xl9sHB6Q5Fz7POURfGFzSfxf+HFc2OkO7sk9mGwPKD22Og3lqC8jxnEr/uL47aa4pA==",
"requires": {
"@adobe/json-formula": "0.1.50",
- "@aemforms/af-formatters": "^1.0.4"
+ "@aemforms/af-formatters": "^1.0.7"
}
},
"@aemforms/af-core-xfa": {
@@ -11169,9 +11179,17 @@
"integrity": "sha512-Hg7dkONjmR3aH/Iec+/HRY/lCzVvP3GI6H9wD5FNGsE9zSxPcCzMtnAA0P7n/RKMjcnqYBMt4pFQ6MVb+YvIGg=="
},
"@aemforms/af-formatters": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.4.tgz",
- "integrity": "sha512-ShqnAHnwTN6JpEZidP1jqZCy5rku/H+2M1oqC7OwDLenQv55LUQasfvINyhG5H7jVKrocyHpijR5BDKlPdewMw=="
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.7.tgz",
+ "integrity": "sha512-2dEAOmYIH8FfYiiLzPSWqBDmdorrBFCzc+YsQ4JfDoZNH7qMPebnyujZsaFG3grhxUJcz8npMnKdPzDCqUjiqg=="
+ },
+ "@aemforms/af-webmcp": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@aemforms/af-webmcp/-/af-webmcp-1.0.7.tgz",
+ "integrity": "sha512-YvX/BSwnw7/YBXuhtqaKCPoseglR9n9k/1KJKSNxWUWxD9G8JXTkYmN7y04TdDg4GeEAUxzSFaxmgdKf2fXsYQ==",
+ "requires": {
+ "@aemforms/af-core": "^1.0.7"
+ }
},
"@ampproject/remapping": {
"version": "2.2.1",
diff --git a/ui.frontend/package.json b/ui.frontend/package.json
index d7a6f6f6b2..dc896810c5 100644
--- a/ui.frontend/package.json
+++ b/ui.frontend/package.json
@@ -25,9 +25,10 @@
"webpack-merge": "^5.8.0"
},
"dependencies": {
- "@aemforms/af-core": "^1.0.4",
+ "@aemforms/af-core": "^1.0.7",
"@aemforms/af-core-xfa": "^0.1.6",
- "@aemforms/af-formatters": "^1.0.4",
- "@aemforms/af-custom-functions": "1.0.18"
+ "@aemforms/af-formatters": "^1.0.7",
+ "@aemforms/af-custom-functions": "1.0.18",
+ "@aemforms/af-webmcp": "^1.0.7"
}
}
diff --git a/ui.frontend/src/GuideBridge.js b/ui.frontend/src/GuideBridge.js
index 2648d7171e..a0f069bbe6 100644
--- a/ui.frontend/src/GuideBridge.js
+++ b/ui.frontend/src/GuideBridge.js
@@ -19,6 +19,7 @@ import Response from "./Response.js";
import AfFormData from "./FormData.js";
import {readAttachments} from "@aemforms/af-core";
import Utils from "./utils.js";
+import {registerFormWebMCP} from "@aemforms/af-webmcp";
/**
* The GuideBridge class represents the bridge between an adaptive form and JavaScript APIs.
@@ -33,6 +34,7 @@ class GuideBridge {
* @instance
*/
#formContainerViewMap = {};
+ #webMcpAdditionalTools = new Map();
/**
* Array to store the guide bridge connect handlers.
* @member {Array}
@@ -96,6 +98,11 @@ class GuideBridge {
function onFormContainerInitialised(e) {
let formContainer = e.detail;
self.#formContainerViewMap[formContainer.getPath()] = formContainer;
+ const config = self.#webMcpAdditionalTools.get(formContainer.getPath());
+ if (config) {
+ formContainer._unregisterWebMcp();
+ formContainer._setWebMcpUnregister(registerFormWebMCP(formContainer.getModel(), Utils.getWebMcpOptions(formContainer, config.factory)));
+ }
self.#invokeConnectHandlers(formContainer.getPath());
}
document.addEventListener(Constants.FORM_CONTAINER_INITIALISED, onFormContainerInitialised);
@@ -114,6 +121,35 @@ class GuideBridge {
}
+ /**
+ * Experimental/internal supported seam for form-scoped WebMCP tools.
+ * @param {string} formContainerPath Explicit target container path.
+ * @param {Function|null} factory Receives the form model and returns tools; null removes it.
+ * @returns {Function} Disposer that removes only this registration.
+ */
+ registerWebMcpAdditionalTools(formContainerPath, factory) {
+ if (typeof formContainerPath !== 'string' || !formContainerPath.trim() ||
+ (factory !== null && typeof factory !== 'function')) {
+ throw new TypeError("An explicit form container path and a function or null are required");
+ }
+ const config = factory === null ? null : {factory};
+ if (config) {
+ this.#webMcpAdditionalTools.set(formContainerPath, config);
+ } else {
+ this.#webMcpAdditionalTools.delete(formContainerPath);
+ }
+ const view = this.#formContainerViewMap[formContainerPath];
+ if (view) {
+ view._unregisterWebMcp();
+ view._setWebMcpUnregister(registerFormWebMCP(view.getModel(), Utils.getWebMcpOptions(view, factory)));
+ }
+ return () => {
+ if (config && this.#webMcpAdditionalTools.get(formContainerPath) === config) {
+ this.registerWebMcpAdditionalTools(formContainerPath, null);
+ }
+ };
+ }
+
/**
* Returns the string representation of the form data.
*
@@ -481,12 +517,17 @@ class GuideBridge {
console.warn("No form container path specified or available to unload.");
return;
}
+ this.#webMcpAdditionalTools.delete(pathToUnload);
// Get the container element from the view and disconnect mutation observers
let container = null;
if (this.#formContainerViewMap[pathToUnload]) {
const formContainerView = this.#formContainerViewMap[pathToUnload];
container = formContainerView.getFormElement();
+
+ if (typeof formContainerView._unregisterWebMcp === 'function') {
+ formContainerView._unregisterWebMcp();
+ }
// Disconnect all mutation observers for this form
if (typeof formContainerView._disconnectMutationObservers === 'function') {
@@ -552,5 +593,3 @@ class GuideBridge {
};
export default GuideBridge;
-
-
diff --git a/ui.frontend/src/utils.js b/ui.frontend/src/utils.js
index 6c8f122a26..62bb0f9164 100644
--- a/ui.frontend/src/utils.js
+++ b/ui.frontend/src/utils.js
@@ -18,6 +18,7 @@ import {Constants} from "./constants.js";
import HTTPAPILayer from "./HTTPAPILayer.js";
import {customFunctions} from "./customFunctions.js";
import {FunctionRuntime} from '@aemforms/af-core';
+import {registerFormWebMCP} from '@aemforms/af-webmcp';
import {loadXfa} from "./handleXfa";
import RuleUtils from "./RuleUtils.js";
@@ -341,6 +342,9 @@ class Utils {
} else {
_formJson = await HTTPAPILayer.getFormDefinition(_path, _pageLang);
}
+ if (!_formJson.id) {
+ _formJson = {..._formJson, id: elements[i].id || _path};
+ }
console.debug("fetched model json", _formJson);
await RuleUtils.registerCustomFunctionsV2( _formJson);
await RuleUtils.registerCustomFunctionsByUrl(customFunctionUrl);
@@ -371,6 +375,11 @@ class Utils {
callback(formContainer.getModel());
}
Utils.initializeAllFields(formContainer);
+ // Expose the form's WebMCP tool catalog to in-browser AI agents. No-ops unless the
+ // form opted in via fd:webMcpEnabled and a browser modelContext is available.
+ formContainer._setWebMcpUnregister(
+ registerFormWebMCP(formContainer.getModel(), Utils.getWebMcpOptions(formContainer))
+ );
const event = new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, { "detail": formContainer });
document.dispatchEvent(event);
}
@@ -378,6 +387,18 @@ class Utils {
}
+ /** Builds form-scoped renderer-focus and optional additional-tool options for WebMCP. */
+ static getWebMcpOptions(formContainer, additionalTools) {
+ return {
+ ...(additionalTools ? {additionalTools} : {}),
+ onFocusRequest: (fieldId) => {
+ const previous = document.activeElement;
+ formContainer.setFocus(fieldId);
+ return document.activeElement !== previous;
+ }
+ };
+ }
+
/**
* For backward compatibility with older data formats of prefill services like FDM.
* @param {object} prefillJson - The prefill JSON object.
diff --git a/ui.frontend/src/view/FormContainer.js b/ui.frontend/src/view/FormContainer.js
index 6053e3a571..aa00ee685e 100644
--- a/ui.frontend/src/view/FormContainer.js
+++ b/ui.frontend/src/view/FormContainer.js
@@ -36,6 +36,7 @@ class FormContainer {
this._deferredParents = {};
this._element = params._element;
this._mutationObservers = []; // Store mutation observers for cleanup
+ this._webMcpUnregister = null;
// Prevent default behaviour on form container.
this.#preventDefaultSubmit();
@@ -85,6 +86,27 @@ class FormContainer {
this._mutationObservers = [];
}
+ /**
+ * Replaces the active WebMCP registration cleanup for this form.
+ * @param {Function} unregister - Function that unregisters this form's WebMCP tools.
+ * @private
+ */
+ _setWebMcpUnregister(unregister) {
+ this._unregisterWebMcp();
+ this._webMcpUnregister = typeof unregister === 'function' ? unregister : null;
+ }
+
+ /**
+ * Unregisters this form's WebMCP tools.
+ * @private
+ */
+ _unregisterWebMcp() {
+ if (this._webMcpUnregister) {
+ this._webMcpUnregister();
+ this._webMcpUnregister = null;
+ }
+ }
+
/**
* Adds an instance manager view to the fields of the formContainer.
* @param {object} instanceManager - The instance manager view to be added.
diff --git a/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js b/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js
index f35e9c1b2c..0e26f56830 100644
--- a/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js
+++ b/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js
@@ -141,7 +141,10 @@ describe('Rule editor authoring sanity for core-components',function(){
cy.wait('@ruleEditorRequest').then((interception) => {
expect(interception.response.statusCode).to.equal(201);
const submittedData = Object.fromEntries(new URLSearchParams(interception.request.body));
- expect(submittedData[":content"]).contains("\"fd:events\":{\"change\":[\"if(contains($event.payload.changes[].propertyName, 'value'), if($field.$value == 'abc', {visible : false()}, {}), {})\"]}");
+ const submittedContent = JSON.parse(submittedData[":content"]);
+ expect(submittedContent["fd:events"].change).to.deep.equal([
+ "if(contains($event.payload.changes[].propertyName, 'value'), if($field.$value == 'abc', {visible : false()}, {}), {})"
+ ]);
});
// check and close rule editor
@@ -466,27 +469,55 @@ describe('Rule editor authoring sanity for core-components',function(){
cy.deleteComponentByPath(datePickerEditPath);
})
- it('should add rule on texbox equality operator to hide a text box', function () {
- cy.openAuthoring(formPath);
- cy.selectLayer("Edit");
- cy.get(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']").should("exist");
+ context('Text box equality rule', function () {
+ const contextPath = Cypress.env('crx.contextPath') || '';
+ const resetTextInput = () => {
+ // Retries must not reuse a persisted field whose saved rules survived a failed assertion.
+ const deleteProperty = textinputEditPath.split('/').pop() + '@Delete';
+ return cy.request(contextPath + '/libs/granite/csrf/token.json').its('body.token').then(token => {
+ return cy.window().then(win => win.fetch(contextPath + formContainerPath, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: new win.URLSearchParams({
+ [deleteProperty]: '',
+ ':cq_csrf_token': token
+ }).toString()
+ }).then(response => {
+ if (!response.ok) {
+ throw new Error('Unable to clean rule-editor text input: HTTP ' + response.status);
+ }
+ }));
+ }).then(() => {
+ return cy.request({
+ url: contextPath + textinputEditPath + '.json',
+ failOnStatusCode: false
+ }).its('status').should('equal', 404);
+ });
+ };
+
+ beforeEach(() => {
+ cy.openAuthoring(formPath);
+ resetTextInput();
+ cy.openSiteAuthoring(formPath);
+ cy.selectLayer("Edit");
+ });
- cy.insertComponent(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']",
- "Adaptive Form Text Box", afConstants.components.forms.resourceType.formtextinput);
- // cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + textinputEditPathSelector);
- // cy.get(formsSelectors.ruleEditor.action.configure).should("exist");
- // cy.get(formsSelectors.ruleEditor.action.configure).click();
- // cy.get(".cmp-adaptiveform-base__editdialogbasic [name='./name']").clear().type("textinput");
- // cy.get(".cq-dialog-actions.cq-dialog-submit").click();
+ afterEach(() => {
+ resetTextInput();
+ });
- cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + textinputEditPathSelector);
+ it('should add rule on texbox equality operator to hide a text box', function () {
+ cy.get(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']").should("exist");
- createRuleToHideTextInputOnEqualityOperator();
- cy.get(sitesSelectors.overlays.overlay.component + textinputEditPathSelector).should("exist");
+ cy.insertComponent(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']",
+ "Adaptive Form Text Box", afConstants.components.forms.resourceType.formtextinput);
+ cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + textinputEditPathSelector);
- cy.selectLayer("Edit");
- cy.deleteComponentByPath(textinputEditPath);
- })
+ createRuleToHideTextInputOnEqualityOperator();
+ cy.get(sitesSelectors.overlays.overlay.component + textinputEditPathSelector).should("exist");
+ });
+ });
it('should add submission handler rules on form', function () {
if (toggle_array.includes("FT_FORMS-13209")) {
diff --git a/ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js b/ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js
new file mode 100644
index 0000000000..413499b6c1
--- /dev/null
+++ b/ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2026 Adobe
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+///
+/* global cy, Cypress, expect, window, document */
+
+const sitesSelectors = require('../../libs/commons/sitesSelectors');
+const afConstants = require('../../libs/commons/formsConstants');
+
+describe('WebMCP form authoring and preview', () => {
+ if (!cy.af.isLatestAddon()) {
+ return;
+ }
+ const pagePath = '/content/forms/af/core-components-it/samples/accessibility';
+ const containerPath = pagePath + afConstants.FORM_EDITOR_FORM_CONTAINER_SUFFIX;
+ const contextPath = Cypress.env('crx.contextPath') || '';
+ const resourcePath = contextPath + containerPath;
+ const toolNames = [
+ 'list_forms', 'get_form_summary', 'get_field_value', 'explain_field', 'validate_form_completeness',
+ 'list_repeatable_instances', 'focus_field', 'navigate_to_panel', 'set_field_value',
+ 'apply_prefill', 'add_repeatable_instance', 'remove_repeatable_instance', 'submit_form'
+ ];
+ let originalProperties;
+
+ before(function () {
+ cy.openAuthoring(pagePath);
+ cy.fetchFeatureToggles().its('body.enabled').should('be.an', 'array').then(enabled => {
+ if (!enabled.includes('FT_FORMS-28233')) {
+ return this.skip();
+ }
+ cy.request(resourcePath + '.json').its('body').then(properties => {
+ originalProperties = properties;
+ });
+ });
+ });
+
+ after(() => {
+ if (!originalProperties) {
+ return;
+ }
+ const originalValue = originalProperties['fd:webMcpEnabled'];
+ const properties = originalValue === undefined ? {'fd:webMcpEnabled@Delete': ''} : {
+ 'fd:webMcpEnabled': originalValue,
+ 'fd:webMcpEnabled@TypeHint': typeof originalValue === 'boolean' ? 'Boolean' : 'String'
+ };
+ cy.request(contextPath + '/libs/granite/csrf/token.json').its('body.token').then(token => {
+ return cy.window().then(win => win.fetch(resourcePath, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: new win.URLSearchParams(Object.assign(properties, {':cq_csrf_token': token})).toString()
+ }).then(response => {
+ if (!response.ok) {
+ throw new Error('Unable to restore WebMCP opt-in: HTTP ' + response.status);
+ }
+ }));
+ });
+ });
+
+ it('enables AI assistant access in the container dialog and registers the preview catalog', () => {
+ const installHost = () => {
+ const tools = new Map();
+ window.__webMcpTestTools = tools;
+ Object.defineProperty(document, 'modelContext', {
+ configurable: true,
+ value: {
+ registerTool(tool) {
+ tools.set(tool.name, tool);
+ return {unregister() { tools.delete(tool.name); }};
+ }
+ }
+ });
+ };
+ cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + '[data-path="' + containerPath + '"]');
+ cy.intercept('GET', '**/container/_cq_dialog.html/**').as('containerDialog');
+ cy.invokeEditableAction('[data-action="CONFIGURE"]');
+ cy.wait('@containerDialog').its('response.statusCode').should('eq', 200);
+ cy.get('.cmp-adaptiveform-container__editdialog', {timeout: 60000}).contains('Basic').click({force: true});
+ cy.get('coral-checkbox[name="./fd:webMcpEnabled"]').should('be.visible')
+ .find('input[type="checkbox"]').check({force: true}).should('be.checked');
+ cy.submitConfigureDialog();
+
+ // Inject into the preview document so editor navigation cannot discard the test host.
+ cy.intercept('GET', '**' + pagePath + '.html*', request => {
+ request.continue(response => {
+ expect(response.body).to.match(/]*)?>/i);
+ response.body = response.body.replace(/]*)?>/i,
+ '$&');
+ });
+ });
+ cy.previewForm(pagePath + '.html').then(formContainer => {
+ expect(formContainer.getModel().webMcpEnabled).to.equal(true);
+ });
+ cy.window().its('__webMcpTestTools').as('tools').should(registered => {
+ expect(Array.from(registered.keys())).to.have.members(toolNames);
+ registered.forEach(tool => {
+ expect(tool.execute, tool.name).to.be.a('function');
+ expect(tool.inputSchema.type, tool.name).to.equal('object');
+ });
+ expect(registered.get('validate_form_completeness').annotations)
+ .to.include({readOnlyHint: true, untrustedContentHint: true});
+ });
+ cy.get('@tools').then(tools => tools.get('list_forms').execute({})).then(result => {
+ expect(result.success).to.equal(true);
+ expect(result.forms).to.have.length(1);
+ });
+ cy.get('@tools').then(tools => tools.get('get_form_summary').execute({})).then(summary => {
+ expect(summary.success).to.equal(true);
+ expect(summary.fields.length).to.be.greaterThan(10);
+ });
+ });
+});