diff --git a/.github/skills/SKILLS.md b/.github/skills/SKILLS.md index e4bb804cf..4daa272d5 100644 --- a/.github/skills/SKILLS.md +++ b/.github/skills/SKILLS.md @@ -3,6 +3,8 @@ This folder contains repo-level AI skill files. Each skill is a `.SKILL.md` file describing how an AI assistant should behave or where to look for information. Available skills -- `skill-read-docs-and-interfaces.SKILL.md` — Guides agents where to find docs and TypeScript interfaces in the monorepo. (path: `.github/skills/skill-read-docs-and-interfaces.SKILL.md`) +- `create-slickgrid-package.SKILL.md` — guidance for creating a new package/external resource in the monorepo. (path: `.github/skills/create-slickgrid-package.SKILL.md`) +- `skill-webmcp.SKILL.md` — pointers for WebMCP (AI Toolkit) docs, code, demos and tests. (path: `.github/skills/skill-webmcp.SKILL.md`) +- `skill-read-docs-and-interfaces.SKILL.md` — guides agents where to find docs and TypeScript interfaces in the monorepo. (path: `.github/skills/skill-read-docs-and-interfaces.SKILL.md`) -Add new skills by adding `.SKILL.md` files to this folder and include a short entry here. +To add a new skill, create a `*.SKILL.md` file in this folder and add a short entry here. diff --git a/.github/skills/skill-webmcp.SKILL.md b/.github/skills/skill-webmcp.SKILL.md new file mode 100644 index 000000000..f065e6448 --- /dev/null +++ b/.github/skills/skill-webmcp.SKILL.md @@ -0,0 +1,30 @@ +# Skill: WebMCP / AI Toolkit + +Purpose +- Help AI agents discover WebMCP (Model Context Protocol) tools, docs and interfaces for the WebMCP integration in this repo. + +Quick summary +- The WebMCP feature is implemented in the `packages/web-mcp` package. It registers MCP tools when `navigator.modelContext` is available and exposes helpers usable directly via the `WebMcpService` instance. +- Tools of interest: `read_slickgrid_data_`, `get_slickgrid_schema_`, `get_slickgrid_state_`, `apply_slickgrid_state_`. + +Where to read (priority order) +1. Concept & examples: `docs/ai/ai-toolkit.md` +2. Package README: `packages/web-mcp/README.md` +3. Implementation & types: `packages/web-mcp/src/web-mcp.service.ts` +4. Core/common interfaces used: `packages/common` (search for `CurrentFilter`, `CurrentSorter`, `ExternalResource`, `FilterService`, `GridService`) +5. Demo using WebMCP: `demos/vanilla/src/examples/example43.ts` and `demos/vanilla/src/examples/example43.html` +6. Unit tests: `packages/web-mcp/src/__tests__/web-mcp.service.spec.ts` + +Notes for agents +- Prefer docs for conceptual questions and examples. Inspect `web-mcp.service.ts` for precise tool names, input shapes and runtime behavior. +- `apply_slickgrid_state` performs basic validation; the service returns structured error objects for malformed input instead of throwing at the tool boundary. Use the tests as concrete examples of validation behavior. +- WebMCP is opt-in — only demos that instantiate `WebMcpService` will register tools. Confirm the demo before attempting to call `navigator.modelContext` tools. + +Common search hints +- Keywords: `WebMCP`, `WebMcpService`, `modelContext`, `apply_slickgrid_state`, `get_slickgrid_schema`. +- Files: `packages/web-mcp/src/**/*.ts`, `packages/common/src/**/*.ts`, `docs/ai/**/*.md`, `demos/**/src/examples/**`. + +Example checklist for answering requests about WebMCP +- Link to `docs/ai/ai-toolkit.md` for overview and prompting guidance. +- Quote the exact tool name pattern and input schema from `web-mcp.service.ts` when suggesting client invocations. +- If recommending local testing, point to `demos/vanilla/example43` and the Model Context Tool Inspector link: https://github.com/beaufortfrancois/model-context-tool-inspector diff --git a/demos/aurelia/package.json b/demos/aurelia/package.json index a9f43a733..22ad13bf3 100644 --- a/demos/aurelia/package.json +++ b/demos/aurelia/package.json @@ -43,6 +43,7 @@ "@slickgrid-universal/rxjs-observable": "workspace:*", "@slickgrid-universal/sql": "workspace:*", "@slickgrid-universal/text-export": "workspace:*", + "@slickgrid-universal/web-mcp": "workspace:*", "aurelia": "^2.0.0-rc.1", "aurelia-slickgrid": "workspace:*", "bootstrap": "catalog:", diff --git a/demos/aurelia/src/examples/slickgrid/example54.html b/demos/aurelia/src/examples/slickgrid/example54.html new file mode 100644 index 000000000..972715d82 --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example54.html @@ -0,0 +1,71 @@ +
+

+ Example 54: AI / Web MCP Toolkit + + + code + + + +

+ +
+ Demonstrates the optional @slickgrid-universal/web-mcp package (WebMcpService), which exposes the grid as + Model Context Protocol (MCP) tools so that AI assistants can read and + manipulate the grid via natural language. The buttons below simulate what an LLM would call — in a real WebMCP-capable browser the same + methods are called automatically by the AI assistant via navigator.modelContext. See + AI Toolkit docs for full details. +
+ +
+
+ + + + +
+
+ + + + +
+
Output (what the LLM sees / sends)
+

+  
+
diff --git a/demos/aurelia/src/examples/slickgrid/example54.ts b/demos/aurelia/src/examples/slickgrid/example54.ts new file mode 100644 index 000000000..1e2627ef1 --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example54.ts @@ -0,0 +1,111 @@ +import { WebMcpService, type SlickGridState } from '@slickgrid-universal/web-mcp'; +import { bindable } from 'aurelia'; +import { type AureliaGridInstance, type Column, type GridOption } from 'aurelia-slickgrid'; + +const NB_ITEMS = 2000; +const PRIORITIES = ['Low', 'Medium', 'High', 'Critical']; +const STATUSES = ['Todo', 'In Progress', 'Done', 'Blocked']; + +export class Example54 { + @bindable() textResult = ''; + aureliaGrid!: AureliaGridInstance; + columns: Column[] = []; + gridContainerElm!: HTMLDivElement; + gridOptions!: GridOption; + dataset: any[] = []; + hideSubTitle = false; + mcpService = new WebMcpService(); + + constructor() { + this.defineGrid(); + this.showOutput( + '// Click a button above to inspect or manipulate the grid via the MCP service API.\n// In a real WebMCP-capable browser, an AI assistant calls these same methods automatically.' + ); + } + + attached() { + this.dataset = this.loadData(NB_ITEMS); + } + + aureliaGridReady(aureliaGrid: AureliaGridInstance) { + this.aureliaGrid = aureliaGrid; + } + + defineGrid() { + this.columns = [ + { id: 'id', name: '#', field: 'id', sortable: true, width: 50 }, + { id: 'title', name: 'Title', field: 'title', sortable: true, filterable: true, width: 200 }, + { id: 'priority', name: 'Priority', field: 'priority', sortable: true, filterable: true, width: 110 }, + { id: 'status', name: 'Status', field: 'status', sortable: true, filterable: true, width: 120 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', sortable: true, filterable: true, type: 'number', width: 140 }, + { id: 'completed', name: 'Completed %', field: 'completed', sortable: true, filterable: true, type: 'number', width: 130 }, + ]; + + this.gridOptions = { + enableFiltering: true, + enableSorting: true, + gridHeight: 300, + gridWidth: 800, + externalResources: [this.mcpService], + }; + } + + // --------------------------------------------------------------------------- + // Button handlers — simulating what an LLM would call via WebMCP tools + // --------------------------------------------------------------------------- + + showSchema() { + const schema = this.mcpService.getStructuredSchema(); + this.showOutput(JSON.stringify(schema, null, 2)); + } + + showState() { + const state = this.mcpService.getGridState(); + this.showOutput(JSON.stringify(state, null, 2)); + } + + /** Simulate a typical LLM response: filter to High/Critical priority, sort by duration desc */ + async applyAiState() { + const aiGeneratedState: Partial = { + filters: [{ columnId: 'priority', searchTerms: ['High'], operator: 'EQ' }], + sorters: [{ columnId: 'duration', direction: 'DESC' }], + }; + this.showOutput(`// Simulated LLM response — applying state:\n${JSON.stringify(aiGeneratedState, null, 2)}`); + await this.mcpService.applyGridState(aiGeneratedState); + } + + async resetGrid() { + await this.mcpService.applyGridState({ filters: [], sorters: [] }); + this.showOutput('// Grid state reset.'); + } + + toggleSubTitle() { + this.hideSubTitle = !this.hideSubTitle; + const action = this.hideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + this.aureliaGrid.resizerService.resizeGrid(0); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private showOutput(text: string) { + this.textResult = text; + } + + private loadData(count: number): any[] { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)], + status: STATUSES[Math.floor(Math.random() * STATUSES.length)], + duration: Math.floor(Math.random() * 90) + 1, + completed: Math.floor(Math.random() * 100), + }); + } + return data; + } +} diff --git a/demos/aurelia/src/my-app.ts b/demos/aurelia/src/my-app.ts index 70a4c834a..783b74ccc 100644 --- a/demos/aurelia/src/my-app.ts +++ b/demos/aurelia/src/my-app.ts @@ -59,6 +59,7 @@ const myRoutes: Routeable[] = [ { path: 'example51', component: () => import('./examples/slickgrid/example51.js'), title: '51- Menus with Slots' }, { path: 'example52', component: () => import('./examples/slickgrid/example52.js'), title: '52- SQL Backend Service' }, { path: 'example53', component: () => import('./examples/slickgrid/example53.js'), title: '53- Custom Filter Bar' }, + { path: 'example54', component: () => import('./examples/slickgrid/example54.js'), title: '54- AI / Web MCP Toolkit' }, { path: 'home', component: () => import('./home-page.js'), title: 'Home' }, ]; @route({ diff --git a/demos/aurelia/test/cypress/e2e/example54.cy.ts b/demos/aurelia/test/cypress/e2e/example54.cy.ts new file mode 100644 index 000000000..1c8162ad0 --- /dev/null +++ b/demos/aurelia/test/cypress/e2e/example54.cy.ts @@ -0,0 +1,48 @@ +describe('Example 54 - AI / Web MCP Toolkit', () => { + const titles = ['#', 'Title', 'Priority', 'Status', 'Duration (days)', 'Completed %']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example54`); + cy.get('h2').should('contain', 'Example 54: AI / Web MCP Toolkit'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('.slick-header-columns .slick-column-name').each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('getStructuredSchema() should return column metadata containing "priority"', () => { + cy.contains('getStructuredSchema()').click(); + cy.get('#mcp-output').should('contain', 'priority'); + }); + + it('applyGridState() should apply simulated LLM state (filter priority=High + sort duration desc)', () => { + // Apply simulated AI state + cy.contains('applyGridState()').click(); + + // Wait a short time for state to be applied then inspect grid state + cy.contains('getGridState()').click(); + + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state).to.have.property('filters'); + expect(state.filters).to.be.an('array').and.to.have.length.greaterThan(0); + expect(state.filters[0].columnId).to.equal('priority'); + }); + + // Verify first row priority cell contains 'High' + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', 'High'); + }); + + it('reset should clear filters (getGridState shows empty filters)', () => { + cy.contains('Reset').click(); + cy.contains('getGridState()').click(); + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state.filters).to.be.an('array').and.to.have.length(0); + }); + }); +}); diff --git a/demos/react/package.json b/demos/react/package.json index 87460724c..0e20025d2 100644 --- a/demos/react/package.json +++ b/demos/react/package.json @@ -36,6 +36,7 @@ "@slickgrid-universal/rxjs-observable": "workspace:*", "@slickgrid-universal/sql": "workspace:*", "@slickgrid-universal/text-export": "workspace:*", + "@slickgrid-universal/web-mcp": "workspace:*", "bootstrap": "catalog:", "dompurify": "catalog:", "i18next": "catalog:", diff --git a/demos/react/src/examples/slickgrid/App.tsx b/demos/react/src/examples/slickgrid/App.tsx index 14b23bdec..6650cd4d3 100644 --- a/demos/react/src/examples/slickgrid/App.tsx +++ b/demos/react/src/examples/slickgrid/App.tsx @@ -55,6 +55,7 @@ const routes = [ { path: 'example51', route: '/example51', element: lazy(() => import('./Example51.js')), title: '51- Menus with Slots' }, { path: 'example52', route: '/example52', element: lazy(() => import('./Example52.js')), title: '52- SQL Backend Service' }, { path: 'example53', route: '/example53', element: lazy(() => import('./Example53.js')), title: '53- Custom Filter Bar' }, + { path: 'example54', route: '/example54', element: lazy(() => import('./Example54.js')), title: '54- AI / Web MCP Toolkit' }, ]; export default function Routes() { diff --git a/demos/react/src/examples/slickgrid/Example54.tsx b/demos/react/src/examples/slickgrid/Example54.tsx new file mode 100644 index 000000000..32f5bf032 --- /dev/null +++ b/demos/react/src/examples/slickgrid/Example54.tsx @@ -0,0 +1,191 @@ +import { WebMcpService, type SlickGridState } from '@slickgrid-universal/web-mcp'; +import React, { useEffect, useRef, useState } from 'react'; +import { SlickgridReact, type Column, type GridOption, type SlickgridReactInstance } from 'slickgrid-react'; + +const NB_ITEMS = 2000; +const PRIORITIES = ['Low', 'Medium', 'High', 'Critical']; +const STATUSES = ['Todo', 'In Progress', 'Done', 'Blocked']; + +const Example54: React.FC = () => { + const [columns, setColumns] = useState([]); + const [dataset] = useState(loadData(NB_ITEMS)); + const [gridOptions, setGridOptions] = useState(undefined); + const [hideSubTitle, setHideSubTitle] = useState(false); + const [textResult, setTextResult] = useState(''); + + const mcpService = useRef(new WebMcpService()); + const reactGridRef = useRef(null); + + useEffect(() => { + defineGrid(); + showOutput( + '// Click a button above to inspect or manipulate the grid via the MCP service API.\n// In a real WebMCP-capable browser, an AI assistant calls these same methods automatically.' + ); + }, []); + + function reactGridReady(reactGrid: SlickgridReactInstance) { + reactGridRef.current = reactGrid; + } + + function defineGrid() { + const columns: Column[] = [ + { id: 'id', name: '#', field: 'id', sortable: true, width: 50 }, + { id: 'title', name: 'Title', field: 'title', sortable: true, filterable: true, width: 200 }, + { id: 'priority', name: 'Priority', field: 'priority', sortable: true, filterable: true, width: 110 }, + { id: 'status', name: 'Status', field: 'status', sortable: true, filterable: true, width: 120 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', sortable: true, filterable: true, type: 'number', width: 140 }, + { id: 'completed', name: 'Completed %', field: 'completed', sortable: true, filterable: true, type: 'number', width: 130 }, + ]; + + const gridOptions: GridOption = { + enableFiltering: true, + enableSorting: true, + gridHeight: 300, + gridWidth: 800, + externalResources: [mcpService.current], + }; + + setColumns(columns); + setGridOptions(gridOptions); + } + + function loadData(count: number): any[] { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)], + status: STATUSES[Math.floor(Math.random() * STATUSES.length)], + duration: Math.floor(Math.random() * 90) + 1, + completed: Math.floor(Math.random() * 100), + }); + } + return data; + } + + function showSchema() { + const schema = mcpService.current.getStructuredSchema(); + showOutput(JSON.stringify(schema, null, 2)); + } + + function showState() { + const state = mcpService.current.getGridState(); + showOutput(JSON.stringify(state, null, 2)); + } + + /** Simulate a typical LLM response: filter to High/Critical priority, sort by duration desc */ + async function applyAiState() { + const aiGeneratedState: Partial = { + filters: [{ columnId: 'priority', searchTerms: ['High'], operator: 'EQ' }], + sorters: [{ columnId: 'duration', direction: 'DESC' }], + }; + showOutput(`// Simulated LLM response — applying state:\n${JSON.stringify(aiGeneratedState, null, 2)}`); + await mcpService.current.applyGridState(aiGeneratedState); + } + + async function resetGrid() { + await mcpService.current.applyGridState({ filters: [], sorters: [] }); + showOutput('// Grid state reset.'); + } + + function showOutput(text: string) { + setTextResult(text); + } + + function toggleSubTitle() { + const newHideSubTitle = !hideSubTitle; + setHideSubTitle(newHideSubTitle); + const action = newHideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + reactGridRef.current?.resizerService.resizeGrid(0); + } + + return !gridOptions ? ( + '' + ) : ( +
+
+

+ Example 54: AI / Web MCP Toolkit + + see  + + code + + + +

+ +
+ Demonstrates the optional @slickgrid-universal/web-mcp package (WebMcpService), which exposes the grid + as + + Model Context Protocol (MCP) + {' '} + tools so that AI assistants can read and manipulate the grid via natural language. The buttons below simulate what an LLM would + call — in a real WebMCP-capable browser the same methods are called automatically by the AI assistant via{' '} + navigator.modelContext. See + + AI Toolkit docs + {' '} + for full details. +
+ +
+
+ + + + +
+
+ + reactGridReady($event.detail)} + /> + +
+
Output (what the LLM sees / sends)
+

+        
+
+
+ ); +}; + +export default Example54; diff --git a/demos/react/test/cypress/e2e/example54.cy.ts b/demos/react/test/cypress/e2e/example54.cy.ts new file mode 100644 index 000000000..1c8162ad0 --- /dev/null +++ b/demos/react/test/cypress/e2e/example54.cy.ts @@ -0,0 +1,48 @@ +describe('Example 54 - AI / Web MCP Toolkit', () => { + const titles = ['#', 'Title', 'Priority', 'Status', 'Duration (days)', 'Completed %']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example54`); + cy.get('h2').should('contain', 'Example 54: AI / Web MCP Toolkit'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('.slick-header-columns .slick-column-name').each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('getStructuredSchema() should return column metadata containing "priority"', () => { + cy.contains('getStructuredSchema()').click(); + cy.get('#mcp-output').should('contain', 'priority'); + }); + + it('applyGridState() should apply simulated LLM state (filter priority=High + sort duration desc)', () => { + // Apply simulated AI state + cy.contains('applyGridState()').click(); + + // Wait a short time for state to be applied then inspect grid state + cy.contains('getGridState()').click(); + + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state).to.have.property('filters'); + expect(state.filters).to.be.an('array').and.to.have.length.greaterThan(0); + expect(state.filters[0].columnId).to.equal('priority'); + }); + + // Verify first row priority cell contains 'High' + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', 'High'); + }); + + it('reset should clear filters (getGridState shows empty filters)', () => { + cy.contains('Reset').click(); + cy.contains('getGridState()').click(); + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state.filters).to.be.an('array').and.to.have.length(0); + }); + }); +}); diff --git a/demos/vanilla/package.json b/demos/vanilla/package.json index 48ddf610c..9c12556e3 100644 --- a/demos/vanilla/package.json +++ b/demos/vanilla/package.json @@ -28,6 +28,7 @@ "@slickgrid-universal/text-export": "workspace:*", "@slickgrid-universal/vanilla-bundle": "workspace:*", "@slickgrid-universal/vanilla-force-bundle": "workspace:*", + "@slickgrid-universal/web-mcp": "workspace:*", "alien-signals": "^3.2.1", "bulma": "^1.0.4", "dompurify": "catalog:", diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index b126bc524..9e4acc23a 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -40,6 +40,7 @@ import Example39 from './examples/example39.js'; import Example40 from './examples/example40.js'; import Example41 from './examples/example41.js'; import Example42 from './examples/example42.js'; +import Example43 from './examples/example43.js'; import Icons from './examples/icons.js'; import type { RouterConfig } from './interfaces.js'; @@ -90,6 +91,7 @@ export class AppRouting { { route: 'example40', name: 'example40', view: './examples/example40.html', viewModel: Example40, title: 'Example40' }, { route: 'example41', name: 'example41', view: './examples/example41.html', viewModel: Example41, title: 'Example41' }, { route: 'example42', name: 'example42', view: './examples/example42.html', viewModel: Example42, title: 'Example42' }, + { route: 'example43', name: 'example43', view: './examples/example43.html', viewModel: Example43, title: 'Example43' }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/app.html b/demos/vanilla/src/app.html index 002cba9bc..c95ac1baa 100644 --- a/demos/vanilla/src/app.html +++ b/demos/vanilla/src/app.html @@ -86,6 +86,7 @@

Slickgrid-Universal

Example40 - Menus with Slots Example41 - Grid with SQL Backend Service Example42 - Custom Filter Bar + Example43 - AI / Web MCP Toolkit diff --git a/demos/vanilla/src/examples/example43.html b/demos/vanilla/src/examples/example43.html new file mode 100644 index 000000000..6739f35ff --- /dev/null +++ b/demos/vanilla/src/examples/example43.html @@ -0,0 +1,56 @@ +

+ Example 43 - AI / Web MCP Toolkit + +

+ +
+ Demonstrates the optional @slickgrid-universal/web-mcp package (WebMcpService), which exposes the grid as + Model Context Protocol (MCP) tools so that AI assistants can read and + manipulate the grid via natural language. The buttons below simulate what an LLM would call — in a real WebMCP-capable browser the same + methods are called automatically by the AI assistant via navigator.modelContext. See + AI Toolkit docs for full details. +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +

+  
+
+
+
+
diff --git a/demos/vanilla/src/examples/example43.ts b/demos/vanilla/src/examples/example43.ts new file mode 100644 index 000000000..33de3482d --- /dev/null +++ b/demos/vanilla/src/examples/example43.ts @@ -0,0 +1,110 @@ +import type { Column, GridOption } from '@slickgrid-universal/common'; +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; +import { WebMcpService, type SlickGridState } from '@slickgrid-universal/web-mcp'; +import { ExampleGridOptions } from './example-grid-options.js'; +import '../material-styles.scss'; + +const PRIORITIES = ['Low', 'Medium', 'High', 'Critical']; +const STATUSES = ['Todo', 'In Progress', 'Done', 'Blocked']; + +export default class Example43 { + columns: Column[] = []; + gridOptions!: GridOption; + dataset: any[] = []; + sgb!: SlickVanillaGridBundle; + mcpService!: WebMcpService; + textResult = ''; + + attached() { + this.initializeGrid(); + this.dataset = this.loadData(200); + const gridContainerElm = document.querySelector('.grid43')!; + + this.mcpService = new WebMcpService(); + + this.sgb = new Slicker.GridBundle( + gridContainerElm, + this.columns, + { ...ExampleGridOptions, ...this.gridOptions, externalResources: [this.mcpService] }, + this.dataset + ); + + document.body.classList.add('material-theme'); + this.showOutput( + '// Click a button above to inspect or manipulate the grid via the MCP service API.\n// In a real WebMCP-capable browser, an AI assistant calls these same methods automatically.' + ); + } + + dispose() { + this.sgb?.dispose(); + document.body.classList.remove('material-theme'); + } + + initializeGrid() { + this.columns = [ + { id: 'id', name: '#', field: 'id', sortable: true, width: 50 }, + { id: 'title', name: 'Title', field: 'title', sortable: true, filterable: true, width: 200 }, + { id: 'priority', name: 'Priority', field: 'priority', sortable: true, filterable: true, width: 110 }, + { id: 'status', name: 'Status', field: 'status', sortable: true, filterable: true, width: 120 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', sortable: true, filterable: true, type: 'number', width: 140 }, + { id: 'completed', name: 'Completed %', field: 'completed', sortable: true, filterable: true, type: 'number', width: 130 }, + ]; + + this.gridOptions = { + enableFiltering: true, + enableSorting: true, + }; + } + + // --------------------------------------------------------------------------- + // Button handlers — simulating what an LLM would call via WebMCP tools + // --------------------------------------------------------------------------- + + showSchema() { + const schema = this.mcpService.getStructuredSchema(); + this.showOutput(JSON.stringify(schema, null, 2)); + } + + showState() { + const state = this.mcpService.getGridState(); + this.showOutput(JSON.stringify(state, null, 2)); + } + + /** Simulate a typical LLM response: filter to High/Critical priority, sort by duration desc */ + async applyAiState() { + const aiGeneratedState: Partial = { + filters: [{ columnId: 'priority', searchTerms: ['High'], operator: 'EQ' }], + sorters: [{ columnId: 'duration', direction: 'DESC' }], + }; + this.showOutput(`// Simulated LLM response — applying state:\n${JSON.stringify(aiGeneratedState, null, 2)}`); + await this.mcpService.applyGridState(aiGeneratedState); + } + + async resetGrid() { + await this.mcpService.applyGridState({ filters: [], sorters: [] }); + this.showOutput('// Grid state reset.'); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private showOutput(text: string) { + this.textResult = text; + } + + private loadData(count: number): any[] { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)], + status: STATUSES[Math.floor(Math.random() * STATUSES.length)], + duration: Math.floor(Math.random() * 90) + 1, + completed: Math.floor(Math.random() * 100), + }); + } + return data; + } +} diff --git a/demos/vue/package.json b/demos/vue/package.json index 41ac33d0b..99546fadb 100644 --- a/demos/vue/package.json +++ b/demos/vue/package.json @@ -31,6 +31,7 @@ "@slickgrid-universal/sql": "workspace:*", "@slickgrid-universal/text-export": "workspace:*", "@slickgrid-universal/vue-row-detail-plugin": "workspace:*", + "@slickgrid-universal/web-mcp": "workspace:*", "bootstrap": "catalog:", "dompurify": "catalog:", "i18next": "catalog:", diff --git a/demos/vue/src/components/Example54.vue b/demos/vue/src/components/Example54.vue new file mode 100644 index 000000000..78863aa96 --- /dev/null +++ b/demos/vue/src/components/Example54.vue @@ -0,0 +1,177 @@ + + + diff --git a/demos/vue/src/router/index.ts b/demos/vue/src/router/index.ts index 166b6a322..3000cae65 100644 --- a/demos/vue/src/router/index.ts +++ b/demos/vue/src/router/index.ts @@ -61,6 +61,7 @@ export const routes: RouteRecordRaw[] = [ { path: '/example51', name: '51- Menus with Slots', component: () => import('../components/Example51.vue') }, { path: '/example52', name: '52- SQL Backend Service', component: () => import('../components/Example52.vue') }, { path: '/example53', name: '53- Custom Filter Bar', component: () => import('../components/Example53.vue') }, + { path: '/example54', name: '54- AI / Web MCP Toolkit', component: () => import('../components/Example54.vue') }, ]; export const router = createRouter({ diff --git a/demos/vue/test/cypress/e2e/example54.cy.ts b/demos/vue/test/cypress/e2e/example54.cy.ts new file mode 100644 index 000000000..1c8162ad0 --- /dev/null +++ b/demos/vue/test/cypress/e2e/example54.cy.ts @@ -0,0 +1,48 @@ +describe('Example 54 - AI / Web MCP Toolkit', () => { + const titles = ['#', 'Title', 'Priority', 'Status', 'Duration (days)', 'Completed %']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example54`); + cy.get('h2').should('contain', 'Example 54: AI / Web MCP Toolkit'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('.slick-header-columns .slick-column-name').each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('getStructuredSchema() should return column metadata containing "priority"', () => { + cy.contains('getStructuredSchema()').click(); + cy.get('#mcp-output').should('contain', 'priority'); + }); + + it('applyGridState() should apply simulated LLM state (filter priority=High + sort duration desc)', () => { + // Apply simulated AI state + cy.contains('applyGridState()').click(); + + // Wait a short time for state to be applied then inspect grid state + cy.contains('getGridState()').click(); + + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state).to.have.property('filters'); + expect(state.filters).to.be.an('array').and.to.have.length.greaterThan(0); + expect(state.filters[0].columnId).to.equal('priority'); + }); + + // Verify first row priority cell contains 'High' + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', 'High'); + }); + + it('reset should clear filters (getGridState shows empty filters)', () => { + cy.contains('Reset').click(); + cy.contains('getGridState()').click(); + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state.filters).to.be.an('array').and.to.have.length(0); + }); + }); +}); diff --git a/docs/ai/ai-toolkit.md b/docs/ai/ai-toolkit.md new file mode 100644 index 000000000..2e704cc5f --- /dev/null +++ b/docs/ai/ai-toolkit.md @@ -0,0 +1,298 @@ +# AI Toolkit (`@slickgrid-universal/web-mcp`) + +The AI Toolkit is an optional `ExternalResource` package that bridges SlickGrid with the browser's [Web Model Context Protocol (WebMCP)](https://github.com/webmcp/webmcp). When a user — or an automated agent — makes a natural-language request about the grid ("show me only High-priority tasks sorted by duration"), this package provides the standard MCP surface that lets an AI assistant discover what the grid looks like, read its current state, and push changes back to it — all without any custom glue code in your application. + +It is inspired by the [AG Grid AI Toolkit](https://www.ag-grid.com/angular-data-grid/ai-toolkit/) and follows the same general pattern: provide the LLM with a structured schema of the grid so it understands what it can act on, then let it produce a state object that is applied back to the grid. + +--- + +## Why use this? + +Data grids are powerful but their filter and sort UIs can be intimidating for non-technical users and verbose to drive from automated agents. The AI Toolkit solves this by exposing a well-defined, LLM-friendly interface on top of SlickGrid's existing services. + +**Key use cases:** + +- **Natural-language grid queries** — let users type or speak queries like _"show me overdue tasks assigned to Alice, sorted by priority"_ and have an in-app assistant translate them directly into grid filters and sorts, no filter UI required. +- **AI-powered dashboards** — embed a Copilot/GPT-style assistant sidebar in your application that can drive the grid on the user's behalf, reducing friction for complex multi-column filtering scenarios. +- **Playwright / MCP browser automation** — Playwright's MCP-enabled browser mode can call `get_slickgrid_schema` and `apply_slickgrid_state` directly, making it trivial to write intent-based E2E tests: _"filter by status = Done, then assert row count"_ rather than hard-coding CSS selectors. +- **Accessibility** — users who find filter forms difficult to use can interact with the grid through a text/voice interface backed by an LLM. +- **Developer productivity** — during local development, ask an AI agent to pre-populate filters for a specific scenario without manually clicking through the UI every time. + +Because the package is purely opt-in and makes no changes to `@slickgrid-universal/common` or any framework wrapper, adding it carries zero cost for applications that do not use it. + +--- + +## How It Works + +1. **Schema discovery** — the LLM calls `get_slickgrid_schema` to learn the columns available (id, type, sortable, filterable). +2. **State snapshot** — the LLM calls `get_slickgrid_state` to understand what filters/sorts/column visibility are currently active. +3. **Prompt + LLM call** — your application sends the user query, the schema and the current state to an LLM of your choice. +4. **State application** — the LLM response is passed to `apply_slickgrid_state`, which updates filters, sorting and column visibility in a single call. + +``` +User query + │ + ▼ +get_slickgrid_schema + get_slickgrid_state → LLM → apply_slickgrid_state +``` + +--- + +## Installation + +```bash +npm install @slickgrid-universal/web-mcp +``` + +## Registration + +```ts +import { WebMcpService } from '@slickgrid-universal/web-mcp'; + +const gridOptions = { + externalResources: [new WebMcpService()], + // ... +}; +``` + +The service silently no-ops when the browser does not expose `navigator.modelContext`, so it is safe to include unconditionally. + +--- + +## WebMCP Tools + +All tool names are suffixed with the grid's UID to support multiple grids on the same page. + +| Tool | Description | +|---|---| +| `read_slickgrid_data_` | Returns current data rows. Accepts an optional `limit` (default 20). | +| `get_slickgrid_schema_` | Returns column metadata (id, field, type, filterable, sortable). Call this first so the LLM knows what columns exist. | +| `get_slickgrid_state_` | Returns the current grid state: active filters, active sorters and visible column ids. | +| `apply_slickgrid_state_` | Applies a full or partial grid state. Any omitted key leaves that aspect unchanged. | + +### `apply_slickgrid_state` payload + +```ts +{ + // optional — replaces all active filters + filters?: Array<{ + columnId: string; + searchTerms: string[]; + operator?: 'EQ' | 'NE' | 'GT' | 'GE' | 'LT' | 'LE' | 'CONTAINS' | 'NOT_CONTAINS' | 'IN' | 'NIN'; + }>; + + // optional — replaces all active sorts + sorters?: Array<{ + columnId: string; + direction: 'ASC' | 'DESC'; + }>; + + // optional — ids of columns that should be visible (all others are hidden) + visibleColumnIds?: string[]; +} +``` + +--- + +## Public API + +In addition to the WebMCP tools, these methods are available directly on the service instance for use without `navigator.modelContext` (e.g. integrating with a custom LLM call): + +```ts +// Column metadata as JSON Schema +service.getStructuredSchema(): SlickColumnSchema[] + +// Snapshot of current grid state +service.getGridState(): SlickGridState + +// Apply a full or partial state +await service.applyGridState(state: Partial): Promise +``` + +### Example: custom LLM integration + +```ts +import { WebMcpService } from '@slickgrid-universal/web-mcp'; + +const mcpService = new WebMcpService(); +// mcpService is already init'd via externalResources + +async function onUserQuery(userQuery: string) { + const schema = mcpService.getStructuredSchema(); + const currentState = mcpService.getGridState(); + + const response = await callMyLlm({ + query: userQuery, + schema, + currentState, + }); + + await mcpService.applyGridState(response.newState); +} +``` + +--- + +## Prompting + +The AI Toolkit does not include any prompting logic — the right prompt depends on your LLM and your data. A few practices that consistently improve results: + +- **Include the current grid state** so the LLM understands what is already applied. +- **Include a few sample rows** (or the full dataset for small data) so the LLM understands the data format and domain values. +- **Ask for an `explanation` string** alongside the state change — your UI can show users what changed. +- **List the available features** (filtering, sorting, column visibility) so the LLM knows what it can and cannot do. +- **Include domain context** inline, e.g. _"In this dataset, 'priority' values are 'Low', 'Medium' and 'High'"_. +- **Ask for only the changed state**, not the full state, so that unchanged properties are not accidentally reset. + +### Starter system prompt + +```ts +const systemPrompt = ` +You are an expert data analyst working with a data grid. +Respond to user requests by returning a JSON object with the following shape: + +{ + "newState": { /* partial grid state — only include what changed */ }, + "propertiesToIgnore": [ /* list state keys you did NOT change */ ], + "explanation": "short human-readable description of changes" +} + +Available state keys: "filters", "sorters", "visibleColumnIds". + +Current grid schema (columns and their capabilities): +${JSON.stringify(schema, null, 2)} + +Current grid state: +${JSON.stringify(currentState, null, 2)} +`; +``` + +Using `propertiesToIgnore` is optional but recommended: pass it as a hint to `applyGridState` so that partial responses do not inadvertently clear state keys the LLM left out. + +--- + +## Schema validation + +When using an LLM that does not support [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs), the response may not always conform to the expected shape. Validate it against a JSON schema before calling `applyGridState` to avoid runtime errors: + +```ts +import Ajv from 'ajv'; + +const ajv = new Ajv(); +const validate = ajv.compile({ + type: 'object', + properties: { + filters: { type: 'array' }, + sorters: { type: 'array' }, + visibleColumnIds: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, +}); + +const parsed = JSON.parse(llmResponse); +if (!validate(parsed.newState)) { + console.error('LLM returned invalid grid state', validate.errors); + return; +} + +await mcpService.applyGridState(parsed.newState); +``` + +--- + +## Handling schema size + +`getStructuredSchema()` returns metadata for every column. For grids with many columns this can inflate your prompt and exceed the LLM's context window. Practical mitigations: + +- **Filter the schema** before sending — strip columns that should not be AI-manipulable: + ```ts + const schema = mcpService.getStructuredSchema().filter(col => col.filterable || col.sortable); + ``` +- **Add column descriptions sparingly** — if you augment schema entries with free-text descriptions, keep them concise. +- **Monitor total prompt size** — aim to leave at least 20–25 % of the context window for the model's response. + +--- + +## Extending + +Override `_registerDefaultTools()` to add custom tools or replace the built-in ones: + +```ts +import { WebMcpService, type WebMcpTool } from '@slickgrid-universal/web-mcp'; + +class MyMcpService extends WebMcpService { + protected override _registerDefaultTools(modelContext: { registerTool: (t: WebMcpTool) => void }): void { + super._registerDefaultTools(modelContext); + + modelContext.registerTool({ + name: `highlight_row_${this._grid.getUID()}`, + description: 'Highlights a specific row by its id.', + inputSchema: { + type: 'object', + properties: { rowId: { type: 'number' } }, + required: ['rowId'], + }, + execute: async ({ rowId }) => { + // custom logic here + return { status: 'success' }; + }, + }); + } +} +``` + +--- + +## Notes + +- `apply_slickgrid_state` delegates to `FilterService.updateFilters`, `SortService.updateSorting` and `GridService.showColumnByIds` under the hood — all the same rules that apply to those services apply here (e.g. `enableFiltering` must be `true` in your grid options to use filters). +- **Backend services (OData / GraphQL):** the service has no knowledge of remote data services. When your grid is backed by OData or GraphQL, each call to `applyGridState` will trigger a backend query just as a manual filter would. If you need to batch several state changes and fire only one request, call `filterService.updateFilters(filters, true, false)` / `sortService.updateSorting(sorters, false)` directly (the third argument suppresses the automatic backend call) and then trigger the backend query yourself once all changes are applied. +- **Multiple grids on the same page:** every tool name includes the grid's UID suffix, so two grids each with their own `WebMcpService` register independent, non-conflicting tool sets. + +### Try it locally + +For quick experimentation you can use the Model Context Tool Inspector project which connects to the browser's WebMCP surface and lets you invoke registered tools interactively: https://github.com/beaufortfrancois/model-context-tool-inspector. It also supports recent browser LLM integrations (for example Chrome's Gemini Nano) so you can run free, local prompt-driven calls against your running demo without wiring a full assistant UI. + +--- + +## Playwright / MCP example + +Below is a minimal example that demonstrates the flow used by an MCP-capable client (for example a Playwright test running in an MCP-enabled browser): + +1. Call `get_slickgrid_schema_` to discover column ids and types. +2. Locate the `columnId` for the column you want to filter (e.g. `cost`). +3. Call `filter_slickgrid_` (or `apply_slickgrid_state_`) with a validated payload. + +Note: the exact tool invocation API depends on the MCP client/environment. The snippet below uses `navigator.modelContext.invokeTool(...)` as a concise example; replace with the appropriate client method if different. + +```ts +// Playwright (browser) context example — runs inside the page +const uid = 'slickgrid_123456'; +const mc = (navigator as any).modelContext; + +// 1) discover schema +const schema = await mc.invokeTool(`get_slickgrid_schema_${uid}`, {}); + +// 2) find the cost column +const costCol = schema.find((c: any) => c.field === 'cost' || (c.name && c.name.toLowerCase().includes('cost'))); +if (!costCol) throw new Error('cost column not found'); + +// 3) apply a filter: cost < 50 +await mc.invokeTool(`filter_slickgrid_${uid}`, { + columnId: costCol.id, + search: '50', + operator: 'LT', +}); +``` + +Tool payload (JSON) for the example above: + +```json +{ + "columnId": "cost", + "search": "50", + "operator": "LT" +} +``` + +Security note: exposing grid data and controls to external assistants is an opt-in decision. Consider adding consent gating, sanitization, logging and rate-limiting for production use. diff --git a/frameworks/angular-slickgrid/package.json b/frameworks/angular-slickgrid/package.json index 172c5f621..e83a568a3 100644 --- a/frameworks/angular-slickgrid/package.json +++ b/frameworks/angular-slickgrid/package.json @@ -54,6 +54,7 @@ "@slickgrid-universal/pagination-component": "workspace:*", "@slickgrid-universal/rxjs-observable": "workspace:*", "@slickgrid-universal/utils": "workspace:*", + "@slickgrid-universal/web-mcp": "workspace:*", "dequal": "catalog:", "rxjs": "catalog:" }, diff --git a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts index 262994dc8..bae9a496f 100644 --- a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts +++ b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts @@ -55,6 +55,7 @@ export const routes: Routes = [ { path: 'example51', loadComponent: () => import('./examples/example51.component').then((m) => m.Example51Component) }, { path: 'example52', loadComponent: () => import('./examples/example52.component').then((m) => m.Example52Component) }, { path: 'example53', loadComponent: () => import('./examples/example53.component').then((m) => m.Example53Component) }, + { path: 'example54', loadComponent: () => import('./examples/example54.component').then((m) => m.Example54Component) }, { path: '', redirectTo: '/example34', pathMatch: 'full' }, { path: '**', redirectTo: '/example34', pathMatch: 'full' }, ]; diff --git a/frameworks/angular-slickgrid/src/demos/app.component.html b/frameworks/angular-slickgrid/src/demos/app.component.html index deb218327..a7c0b79ab 100644 --- a/frameworks/angular-slickgrid/src/demos/app.component.html +++ b/frameworks/angular-slickgrid/src/demos/app.component.html @@ -203,6 +203,9 @@ + diff --git a/frameworks/angular-slickgrid/src/demos/examples/example54.component.html b/frameworks/angular-slickgrid/src/demos/examples/example54.component.html new file mode 100644 index 000000000..ec365609c --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example54.component.html @@ -0,0 +1,69 @@ +
+

+ Example 54: AI / Web MCP Toolkit + + + code + + + +

+ +
+ Demonstrates the optional @slickgrid-universal/web-mcp package (WebMcpService), which exposes the grid as + Model Context Protocol (MCP) tools so that AI assistants can read and + manipulate the grid via natural language. The buttons below simulate what an LLM would call — in a real WebMCP-capable browser the same + methods are called automatically by the AI assistant via navigator.modelContext. See + AI Toolkit docs for full details. +
+ +
+
+ + + + +
+
+ + + + +
+
Output (what the LLM sees / sends)
+

+  
+
diff --git a/frameworks/angular-slickgrid/src/demos/examples/example54.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example54.component.ts new file mode 100644 index 000000000..5e2dbb446 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example54.component.ts @@ -0,0 +1,122 @@ +import { Component, inject, type OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { WebMcpService, type SlickGridState } from '@slickgrid-universal/web-mcp'; +import { AngularGridInstance, AngularSlickgridComponent, AngularUtilService, type Column, type GridOption } from '../../library'; + +const NB_ITEMS = 2000; +const PRIORITIES = ['Low', 'Medium', 'High', 'Critical']; +const STATUSES = ['Todo', 'In Progress', 'Done', 'Blocked']; + +@Component({ + templateUrl: './example54.component.html', + providers: [AngularUtilService], + imports: [AngularSlickgridComponent, FormsModule], +}) +export class Example54Component implements OnInit { + protected readonly angularUtilService = inject(AngularUtilService); + + angularGrid!: AngularGridInstance; + columns: Column[] = []; + dataset: any[] = []; + gridContainerElm!: HTMLDivElement; + gridOptions!: GridOption; + hideSubTitle = false; + mcpService = new WebMcpService(); + textResult = ''; + + constructor() { + this.showOutput( + '// Click a button above to inspect or manipulate the grid via the MCP service API.\n// In a real WebMCP-capable browser, an AI assistant calls these same methods automatically.' + ); + } + + angularGridReady(angularGrid: AngularGridInstance) { + this.angularGrid = angularGrid; + } + + ngOnInit(): void { + this.defineGrid(); + + // mock a dataset + this.dataset = this.loadData(NB_ITEMS); + } + + /* Define grid Options and Columns */ + defineGrid() { + this.columns = [ + { id: 'id', name: '#', field: 'id', sortable: true, width: 50 }, + { id: 'title', name: 'Title', field: 'title', sortable: true, filterable: true, width: 200 }, + { id: 'priority', name: 'Priority', field: 'priority', sortable: true, filterable: true, width: 110 }, + { id: 'status', name: 'Status', field: 'status', sortable: true, filterable: true, width: 120 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', sortable: true, filterable: true, type: 'number', width: 140 }, + { id: 'completed', name: 'Completed %', field: 'completed', sortable: true, filterable: true, type: 'number', width: 130 }, + ]; + + this.gridOptions = { + enableFiltering: true, + enableSorting: true, + gridHeight: 300, + gridWidth: 800, + externalResources: [this.mcpService], + }; + } + + // --------------------------------------------------------------------------- + // Button handlers — simulating what an LLM would call via WebMCP tools + // --------------------------------------------------------------------------- + + showSchema() { + const schema = this.mcpService.getStructuredSchema(); + this.showOutput(JSON.stringify(schema, null, 2)); + } + + showState() { + const state = this.mcpService.getGridState(); + this.showOutput(JSON.stringify(state, null, 2)); + } + + /** Simulate a typical LLM response: filter to High/Critical priority, sort by duration desc */ + async applyAiState() { + const aiGeneratedState: Partial = { + filters: [{ columnId: 'priority', searchTerms: ['High'], operator: 'EQ' }], + sorters: [{ columnId: 'duration', direction: 'DESC' }], + }; + this.showOutput(`// Simulated LLM response — applying state:\n${JSON.stringify(aiGeneratedState, null, 2)}`); + await this.mcpService.applyGridState(aiGeneratedState); + } + + async resetGrid() { + await this.mcpService.applyGridState({ filters: [], sorters: [] }); + this.showOutput('// Grid state reset.'); + } + + toggleSubTitle() { + this.hideSubTitle = !this.hideSubTitle; + const action = this.hideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + this.angularGrid.resizerService.resizeGrid(0); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private showOutput(text: string) { + this.textResult = text; + } + + private loadData(count: number): any[] { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)], + status: STATUSES[Math.floor(Math.random() * STATUSES.length)], + duration: Math.floor(Math.random() * 90) + 1, + completed: Math.floor(Math.random() * 100), + }); + } + return data; + } +} diff --git a/frameworks/angular-slickgrid/test/cypress/e2e/example54.cy.ts b/frameworks/angular-slickgrid/test/cypress/e2e/example54.cy.ts new file mode 100644 index 000000000..1c8162ad0 --- /dev/null +++ b/frameworks/angular-slickgrid/test/cypress/e2e/example54.cy.ts @@ -0,0 +1,48 @@ +describe('Example 54 - AI / Web MCP Toolkit', () => { + const titles = ['#', 'Title', 'Priority', 'Status', 'Duration (days)', 'Completed %']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example54`); + cy.get('h2').should('contain', 'Example 54: AI / Web MCP Toolkit'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('.slick-header-columns .slick-column-name').each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('getStructuredSchema() should return column metadata containing "priority"', () => { + cy.contains('getStructuredSchema()').click(); + cy.get('#mcp-output').should('contain', 'priority'); + }); + + it('applyGridState() should apply simulated LLM state (filter priority=High + sort duration desc)', () => { + // Apply simulated AI state + cy.contains('applyGridState()').click(); + + // Wait a short time for state to be applied then inspect grid state + cy.contains('getGridState()').click(); + + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state).to.have.property('filters'); + expect(state.filters).to.be.an('array').and.to.have.length.greaterThan(0); + expect(state.filters[0].columnId).to.equal('priority'); + }); + + // Verify first row priority cell contains 'High' + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', 'High'); + }); + + it('reset should clear filters (getGridState shows empty filters)', () => { + cy.contains('Reset').click(); + cy.contains('getGridState()').click(); + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state.filters).to.be.an('array').and.to.have.length(0); + }); + }); +}); diff --git a/frameworks/slickgrid-react/src/tsconfig.build.json b/frameworks/slickgrid-react/src/tsconfig.build.json index 31751c11c..450d35241 100644 --- a/frameworks/slickgrid-react/src/tsconfig.build.json +++ b/frameworks/slickgrid-react/src/tsconfig.build.json @@ -2,8 +2,8 @@ "compilerOptions": { "module": "esnext", "moduleResolution": "bundler", - "target": "es2018", - "lib": ["es2018", "dom"], + "target": "es2021", + "lib": ["es2021", "dom"], "typeRoots": ["../types", "../../node_modules/@types"], "outDir": "dist/amd", "noImplicitAny": true, diff --git a/packages/web-mcp/README.md b/packages/web-mcp/README.md new file mode 100644 index 000000000..35218eb93 --- /dev/null +++ b/packages/web-mcp/README.md @@ -0,0 +1,29 @@ +# `@slickgrid-universal/web-mcp` + +An optional External Resource that exposes SlickGrid data manipulation capabilities as [WebMCP](https://github.com/webmcp/webmcp) (Model Context Protocol) tools, allowing AI assistants running in the browser to read and manipulate the live grid via natural language. + +> For full documentation see [docs/ai/ai-toolkit.md](../../docs/ai/ai-toolkit.md). + +## Installation + +```bash +npm install @slickgrid-universal/web-mcp +``` + +## Basic Usage + +```ts +import { WebMcpService } from '@slickgrid-universal/web-mcp'; + +const gridOptions = { + externalResources: [new WebMcpService()], + // ... +}; +``` + +The service silently no-ops when the browser does not expose `navigator.modelContext`, so it is safe to include unconditionally. + +## License + +MIT + diff --git a/packages/web-mcp/package.json b/packages/web-mcp/package.json new file mode 100644 index 000000000..2685dc72f --- /dev/null +++ b/packages/web-mcp/package.json @@ -0,0 +1,42 @@ +{ + "name": "@slickgrid-universal/web-mcp", + "version": "10.7.1", + "description": "Slick Web MCP Service - Vanilla Implementation exposing SlickGrid data and filtering as WebMCP (Model Context Protocol) tools", + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "files": [ + "/dist", + "/src" + ], + "scripts": { + "build": "pnpm run clean && tsc", + "build:incremental": "tsc --incremental --declaration", + "clean": "remove dist tsconfig.tsbuildinfo", + "dev": "pnpm build:incremental" + }, + "license": "MIT", + "author": "Ghislain B.", + "homepage": "https://github.com/ghiscoding/slickgrid-universal", + "repository": { + "type": "git", + "url": "git+https://github.com/ghiscoding/slickgrid-universal.git", + "directory": "packages/web-mcp" + }, + "bugs": { + "url": "https://github.com/ghiscoding/slickgrid-universal/issues" + }, + "dependencies": { + "@slickgrid-universal/common": "workspace:*" + } +} diff --git a/packages/web-mcp/src/__tests__/web-mcp.service.spec.ts b/packages/web-mcp/src/__tests__/web-mcp.service.spec.ts new file mode 100644 index 000000000..d1fe6a354 --- /dev/null +++ b/packages/web-mcp/src/__tests__/web-mcp.service.spec.ts @@ -0,0 +1,313 @@ +import type { GridOption, SlickGrid } from '@slickgrid-universal/common'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContainerServiceStub } from '../../../../test/containerServiceStub.js'; +import { WebMcpService } from '../web-mcp.service.js'; + +const GRID_UID = 'slickgrid_123456'; + +const mockColumns = [ + { id: 'name', field: 'name', name: 'Name', type: 'string', filterable: true, sortable: true, hidden: false }, + { id: 'priority', field: 'priority', name: 'Priority', type: 'string', filterable: true, sortable: false, hidden: false }, + { id: 'done', field: 'done', name: 'Done', type: 'boolean', filterable: false, sortable: false, hidden: true }, +]; + +const mockItems = [ + { id: 1, name: 'Task 1', priority: 'High' }, + { id: 2, name: 'Task 2', priority: 'Low' }, +]; + +const dataViewStub = { + getItems: vi.fn().mockReturnValue(mockItems), + getLength: vi.fn().mockReturnValue(mockItems.length), +}; + +const gridStub = { + getData: vi.fn().mockReturnValue(dataViewStub), + getColumns: vi.fn().mockReturnValue(mockColumns), + getOptions: () => ({}) as GridOption, + getUID: () => GRID_UID, +} as unknown as SlickGrid; + +const filterServiceStub = { + getCurrentLocalFilters: vi.fn().mockReturnValue([]), + updateFilters: vi.fn().mockResolvedValue(true), +}; + +const sortServiceStub = { + getCurrentLocalSorters: vi.fn().mockReturnValue([]), + updateSorting: vi.fn(), +}; + +const gridServiceStub = { + showColumnByIds: vi.fn(), +}; + +function makeModelContext() { + return { registerTool: vi.fn() }; +} + +/** Grab registered tools by name prefix from a mocked modelContext */ +function getTool(modelContext: ReturnType, prefix: string) { + return modelContext.registerTool.mock.calls.map((c: any) => c[0]).find((t: any) => t.name.startsWith(prefix)); +} + +describe('WebMcpService', () => { + let service: WebMcpService; + let container: ContainerServiceStub; + + beforeEach(() => { + service = new WebMcpService(); + container = new ContainerServiceStub(); + container.registerInstance('FilterService', filterServiceStub); + container.registerInstance('SortService', sortServiceStub); + container.registerInstance('GridService', gridServiceStub); + Object.defineProperty(navigator, 'modelContext', { value: undefined, writable: true, configurable: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should have the correct pluginName', () => { + expect(service.pluginName).toBe('WebMCPService'); + }); + + // ------------------------------------------------------------------------- + describe('init()', () => { + it('should not register tools when navigator.modelContext is absent', () => { + expect(() => service.init(gridStub, container)).not.toThrow(); + }); + + it('should register 4 default tools when navigator.modelContext is available', () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + service.init(gridStub, container); + + expect(modelContext.registerTool).toHaveBeenCalledTimes(4); + expect(getTool(modelContext, 'read_slickgrid_data_')).toBeDefined(); + expect(getTool(modelContext, 'get_slickgrid_schema_')).toBeDefined(); + expect(getTool(modelContext, 'get_slickgrid_state_')).toBeDefined(); + expect(getTool(modelContext, 'apply_slickgrid_state_')).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + describe('getStructuredSchema()', () => { + it('should return a schema entry per column with correct fields', () => { + service.init(gridStub, container); + const schema = service.getStructuredSchema(); + + expect(schema).toHaveLength(3); + expect(schema[0]).toEqual({ id: 'name', name: 'Name', field: 'name', type: 'string', filterable: true, sortable: true }); + expect(schema[2]).toEqual({ id: 'done', name: 'Done', field: 'done', type: 'boolean', filterable: false, sortable: false }); + }); + + it('should be callable via the get_slickgrid_schema tool', async () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + service.init(gridStub, container); + const tool = getTool(modelContext, 'get_slickgrid_schema_'); + const result = await tool.execute({}); + + expect(result).toHaveLength(3); + }); + }); + + // ------------------------------------------------------------------------- + describe('getGridState()', () => { + it('should return current filters, sorters and visible column ids', () => { + filterServiceStub.getCurrentLocalFilters.mockReturnValue([{ columnId: 'priority', searchTerms: ['High'] }]); + sortServiceStub.getCurrentLocalSorters.mockReturnValue([{ columnId: 'name', direction: 'ASC' }]); + + service.init(gridStub, container); + const state = service.getGridState(); + + expect(state.filters).toEqual([{ columnId: 'priority', searchTerms: ['High'] }]); + expect(state.sorters).toEqual([{ columnId: 'name', direction: 'ASC' }]); + // hidden: true column should be excluded + expect(state.visibleColumnIds).toEqual(['name', 'priority']); + }); + + it('should be callable via the get_slickgrid_state tool', async () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + service.init(gridStub, container); + const tool = getTool(modelContext, 'get_slickgrid_state_'); + const result: any = await tool.execute({}); + + expect(result).toHaveProperty('filters'); + expect(result).toHaveProperty('sorters'); + expect(result).toHaveProperty('visibleColumnIds'); + }); + }); + + // ------------------------------------------------------------------------- + describe('applyGridState()', () => { + beforeEach(() => { + service.init(gridStub, container); + }); + + it('should apply filters when provided', async () => { + await service.applyGridState({ filters: [{ columnId: 'priority', searchTerms: ['High'] }] }); + expect(filterServiceStub.updateFilters).toHaveBeenCalledWith([{ columnId: 'priority', searchTerms: ['High'] }]); + expect(sortServiceStub.updateSorting).not.toHaveBeenCalled(); + expect(gridServiceStub.showColumnByIds).not.toHaveBeenCalled(); + }); + + it('should apply sorters when provided', async () => { + await service.applyGridState({ sorters: [{ columnId: 'name', direction: 'ASC' }] }); + expect(sortServiceStub.updateSorting).toHaveBeenCalledWith([{ columnId: 'name', direction: 'ASC' }]); + expect(filterServiceStub.updateFilters).not.toHaveBeenCalled(); + }); + + it('should apply column visibility when provided', async () => { + await service.applyGridState({ visibleColumnIds: ['name'] }); + expect(gridServiceStub.showColumnByIds).toHaveBeenCalledWith(['name']); + expect(filterServiceStub.updateFilters).not.toHaveBeenCalled(); + }); + + it('should apply all three properties together', async () => { + await service.applyGridState({ + filters: [{ columnId: 'priority', searchTerms: ['High'] }], + sorters: [{ columnId: 'name', direction: 'DESC' }], + visibleColumnIds: ['name', 'priority'], + }); + expect(filterServiceStub.updateFilters).toHaveBeenCalled(); + expect(sortServiceStub.updateSorting).toHaveBeenCalled(); + expect(gridServiceStub.showColumnByIds).toHaveBeenCalled(); + }); + + it('should throw on malformed filters', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ filters: 'not-an-array' })).rejects.toThrow(/filters must be an array/); + }); + + it('should throw on malformed sorters (bad direction)', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ sorters: [{ columnId: 'name', direction: 'UP' }] })).rejects.toThrow( + /sorters\[0\]\.direction must be 'ASC' or 'DESC'/ + ); + }); + + it('should throw on malformed visibleColumnIds element type', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ visibleColumnIds: [{ not: 'valid' }] })).rejects.toThrow(/visibleColumnIds\[0\] must be a string or number/); + }); + + it('should throw when visibleColumnIds is not an array', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ visibleColumnIds: 'not-an-array' })).rejects.toThrow(/visibleColumnIds must be an array/); + }); + + it('should throw when filters contains a non-object element', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ filters: [null] })).rejects.toThrow(/filters\[0\] must be an object/); + }); + + it('should throw when a filter is missing columnId', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ filters: [{ searchTerms: ['x'] }] })).rejects.toThrow(/filters\[0\]\.columnId is required/); + }); + + it('should throw when a filter has non-array searchTerms', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ filters: [{ columnId: 'name', searchTerms: 'x' }] })).rejects.toThrow( + /filters\[0\]\.searchTerms must be an array of strings/ + ); + }); + + it('should throw on non-array sorters', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ sorters: 'nope' })).rejects.toThrow(/sorters must be an array/); + }); + + it('should throw when sorter element is not an object', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ sorters: [null] })).rejects.toThrow(/sorters\[0\] must be an object/); + }); + + it('should throw when sorter is missing columnId', async () => { + // @ts-ignore intentionally malformed + await expect(service.applyGridState({ sorters: [{ direction: 'ASC' }] })).rejects.toThrow(/sorters\[0\]\.columnId is required/); + }); + }); + + // ------------------------------------------------------------------------- + describe('apply_slickgrid_state tool', () => { + it('should call applyGridState and return success', async () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + service.init(gridStub, container); + const tool = getTool(modelContext, 'apply_slickgrid_state_'); + + const state = { filters: [{ columnId: 'priority', searchTerms: ['Low'] }] }; + const result: any = await tool.execute(state); + + expect(filterServiceStub.updateFilters).toHaveBeenCalledWith(state.filters); + expect(result).toEqual({ status: 'success', appliedState: state }); + }); + + it('should return structured error when provided malformed state', async () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + service.init(gridStub, container); + const tool = getTool(modelContext, 'apply_slickgrid_state_'); + + // send malformed filters + // @ts-ignore intentionally malformed + const result: any = await tool.execute({ filters: 'oops' }); + + expect(result).toHaveProperty('status', 'error'); + expect(result).toHaveProperty('message'); + expect(result.message).toMatch(/filters must be an array/); + }); + + it('should return structured error when visibleColumnIds is not an array', async () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + service.init(gridStub, container); + const tool = getTool(modelContext, 'apply_slickgrid_state_'); + + // @ts-ignore intentionally malformed + const result: any = await tool.execute({ visibleColumnIds: 'oops' }); + + expect(result).toHaveProperty('status', 'error'); + expect(result).toHaveProperty('message'); + expect(result.message).toMatch(/visibleColumnIds must be an array/); + }); + }); + + // ------------------------------------------------------------------------- + describe('read_slickgrid_data tool', () => { + it('should return sliced items and total count', async () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + service.init(gridStub, container); + const tool = getTool(modelContext, 'read_slickgrid_data_'); + const result: any = await tool.execute({ limit: 1 }); + + expect(result).toEqual({ data: [mockItems[0]], totalCount: mockItems.length }); + }); + + it('should handle dataView returned as a raw array', async () => { + const modelContext = makeModelContext(); + Object.defineProperty(navigator, 'modelContext', { value: modelContext, writable: true, configurable: true }); + + // make grid.getData return a plain array instead of a DataView-like object + (gridStub.getData as any).mockReturnValueOnce(mockItems); + + service.init(gridStub, container); + const tool = getTool(modelContext, 'read_slickgrid_data_'); + const result: any = await tool.execute({ limit: 1 }); + + expect(result).toEqual({ data: [mockItems[0]], totalCount: mockItems.length }); + }); + }); +}); diff --git a/packages/web-mcp/src/index.ts b/packages/web-mcp/src/index.ts new file mode 100644 index 000000000..4d47dd45d --- /dev/null +++ b/packages/web-mcp/src/index.ts @@ -0,0 +1 @@ +export * from './web-mcp.service.js'; diff --git a/packages/web-mcp/src/web-mcp.service.ts b/packages/web-mcp/src/web-mcp.service.ts new file mode 100644 index 000000000..8e5ac1d2c --- /dev/null +++ b/packages/web-mcp/src/web-mcp.service.ts @@ -0,0 +1,347 @@ +import type { + Column, + ContainerService, + CurrentFilter, + CurrentSorter, + ExternalResource, + FilterService, + GridService, + SlickGrid, + SortService, +} from '@slickgrid-universal/common'; + +// ------------------------------------------------------------------------- +// Public interfaces +// ------------------------------------------------------------------------- + +/** A single WebMCP tool definition */ +export interface WebMcpTool { + name: string; + description: string; + inputSchema: Record; + execute: (args: Record) => Promise; +} + +/** Snapshot of the current grid state returned by getGridState / the state tool */ +export interface SlickGridState { + filters: CurrentFilter[]; + sorters: CurrentSorter[]; + /** IDs of currently visible columns */ + visibleColumnIds: Array; +} + +/** JSON-Schema representation of one column (used in getStructuredSchema) */ +export interface SlickColumnSchema { + id: string | number; + name: string | undefined; + field: string; + type: string; + filterable: boolean; + sortable: boolean; +} + +// ------------------------------------------------------------------------- +// Minimal typings for the browser WebMCP modelContext API (not in lib.dom.d.ts) +// ------------------------------------------------------------------------- +interface ModelContext { + registerTool: (tool: WebMcpTool) => void; +} + +declare global { + interface Navigator { + /** WebMCP model context — available only in supporting browsers/extensions */ + modelContext?: ModelContext; + } +} + +// ------------------------------------------------------------------------- +// Service +// ------------------------------------------------------------------------- + +/** + * WebMcpService — an optional External Resource that exposes SlickGrid + * data manipulation capabilities as WebMCP (Model Context Protocol) tools, + * allowing AI assistants running in the browser to read and manipulate the + * live grid via natural language. + * + * Add it to your grid via `externalResources: [new WebMcpService()]`. + * + * The service silently no-ops when the browser does not expose + * `navigator.modelContext`, so it is safe to include unconditionally. + * + * @see docs/ai/ai-toolkit.md for full documentation. + */ +export class WebMcpService implements ExternalResource { + readonly pluginName = 'WebMCPService'; + + protected _grid!: SlickGrid; + protected _filterService?: FilterService | null; + protected _sortService?: SortService | null; + protected _gridService?: GridService | null; + + // ----------------------------------------------------------------------- + // ExternalResource lifecycle + // ----------------------------------------------------------------------- + + init(grid: SlickGrid, containerService: ContainerService): void { + this._grid = grid; + this._filterService = containerService.get('FilterService'); + this._sortService = containerService.get('SortService'); + this._gridService = containerService.get('GridService'); + + if (!('modelContext' in navigator) || !navigator.modelContext) { + return; + } + + this._registerDefaultTools(navigator.modelContext); + } + + // ----------------------------------------------------------------------- + // Public API (usable independently of WebMCP) + // ----------------------------------------------------------------------- + + /** + * Returns a JSON-Schema representation of the grid's columns so an LLM + * knows what it can act on (ids, types, sortable/filterable flags). + */ + getStructuredSchema(): SlickColumnSchema[] { + return this._grid.getColumns().map((col: Column) => ({ + id: col.id, + name: typeof col.name === 'string' ? col.name : col.field, + field: col.field, + type: col.type ?? 'string', + filterable: col.filterable ?? false, + sortable: col.sortable ?? false, + })); + } + + /** + * Returns a snapshot of the current grid state: + * active filters, active sorters and visible column ids. + */ + getGridState(): SlickGridState { + const filters = this._filterService?.getCurrentLocalFilters() ?? []; + const sorters = this._sortService?.getCurrentLocalSorters() ?? []; + const visibleColumnIds = this._grid + .getColumns() + .filter((c) => !c.hidden) + .map((c) => c.id); + return { filters, sorters, visibleColumnIds }; + } + + /** + * Applies a full grid state object produced by an LLM (or any other source). + * Each property is optional — omit any key to leave that aspect of the grid untouched. + */ + async applyGridState(state: Partial): Promise { + const validation = this._validateGridState(state); + if (!validation.valid) { + throw new Error(`Invalid grid state: ${validation.errors?.join('; ')}`); + } + + if (state.filters !== undefined && this._filterService) { + await this._filterService.updateFilters(state.filters); + } + if (state.sorters !== undefined && this._sortService) { + this._sortService.updateSorting(state.sorters); + } + if (state.visibleColumnIds !== undefined && this._gridService) { + this._gridService.showColumnByIds(state.visibleColumnIds); + } + } + + /** + * Basic validation for incoming grid state objects. Returns {valid, errors}. + * This is intentionally conservative — it only checks shapes and primitive types. + */ + protected _validateGridState(state: Partial): { valid: boolean; errors?: string[] } { + const errors: string[] = []; + + if (state.filters !== undefined) { + if (!Array.isArray(state.filters)) { + errors.push('filters must be an array'); + } else { + state.filters.forEach((f, idx) => { + if (!f || (typeof f as unknown) !== 'object') { + errors.push(`filters[${idx}] must be an object`); + return; + } + // columnId and searchTerms are required per schema + if ((f as any).columnId === undefined) { + errors.push(`filters[${idx}].columnId is required`); + } + if (!Array.isArray((f as any).searchTerms)) { + errors.push(`filters[${idx}].searchTerms must be an array of strings`); + } + }); + } + } + + if (state.sorters !== undefined) { + if (!Array.isArray(state.sorters)) { + errors.push('sorters must be an array'); + } else { + state.sorters.forEach((s, idx) => { + if (!s || (typeof s as unknown) !== 'object') { + errors.push(`sorters[${idx}] must be an object`); + return; + } + if ((s as any).columnId === undefined) { + errors.push(`sorters[${idx}].columnId is required`); + } + const dir = (s as any).direction; + if (dir !== 'ASC' && dir !== 'DESC') { + errors.push(`sorters[${idx}].direction must be 'ASC' or 'DESC'`); + } + }); + } + } + + if (state.visibleColumnIds !== undefined) { + if (!Array.isArray(state.visibleColumnIds)) { + errors.push('visibleColumnIds must be an array'); + } else { + state.visibleColumnIds.forEach((id, idx) => { + const t = typeof id; + if (t !== 'string' && t !== 'number') { + errors.push(`visibleColumnIds[${idx}] must be a string or number`); + } + }); + } + } + + return { valid: errors.length === 0, errors: errors.length ? errors : undefined }; + } + + // ----------------------------------------------------------------------- + // Protected helpers + // ----------------------------------------------------------------------- + + /** + * Register the built-in WebMCP tools on the provided modelContext instance. + * Override this method to add extra tools or replace the built-in ones. + */ + protected _registerDefaultTools(modelContext: ModelContext): void { + const uid = this._grid.getUID(); + + // Tool 1 — read current grid data rows + modelContext.registerTool({ + name: `read_slickgrid_data_${uid}`, + description: 'Returns the current rows from the SlickGrid data grid.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', default: 20 }, + }, + }, + execute: async ({ limit }: { limit?: number }) => { + const dataView = this._grid.getData(); + const items: unknown[] = + typeof dataView?.getItems === 'function' + ? (dataView.getItems() as unknown[]).slice(0, limit ?? 20) + : (dataView as unknown[]).slice(0, limit ?? 20); + const totalCount: number = + typeof dataView?.getLength === 'function' ? (dataView.getLength() as number) : (dataView as unknown[]).length; + return { data: items, totalCount }; + }, + }); + + // Tool 2 — get structured schema (column metadata) + modelContext.registerTool({ + name: `get_slickgrid_schema_${uid}`, + description: + 'Returns a JSON-Schema description of the grid columns (id, type, sortable, filterable). Use this before filtering or sorting.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => this.getStructuredSchema(), + }); + + // Tool 3 — get current grid state + modelContext.registerTool({ + name: `get_slickgrid_state_${uid}`, + description: 'Returns the current grid state: active filters, active sorters and visible column ids.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => this.getGridState(), + }); + + // Tool 4 — apply a full or partial grid state + modelContext.registerTool({ + name: `apply_slickgrid_state_${uid}`, + description: + 'Applies a full or partial grid state (filters, sorters, visibleColumnIds). ' + + 'Omit any key to leave that aspect unchanged. ' + + 'Call get_slickgrid_schema first to know valid columnIds and types.', + inputSchema: { + type: 'object', + properties: { + filters: { + type: 'array', + items: { + type: 'object', + properties: { + columnId: { type: 'string' }, + searchTerms: { type: 'array', items: { type: 'string' } }, + operator: { + type: 'string', + // add all OperatorType values here to avoid forcing LLMs to guess the operator syntax + enum: [ + '=', + '!=', + '<>', + '>', + '>=', + '<', + '<=', + '*', + 'a*', + '*z', + 'a*z', + 'EQ', + 'NE', + 'GT', + 'GE', + 'LT', + 'LE', + 'CONTAINS', + 'NOT_CONTAINS', + 'IN', + 'NIN', + 'IN_COLLECTION', + 'NOT_IN_COLLECTION', + 'Custom', + 'EndsWith', + 'StartsWith', + 'StartsWithEndsWith', + 'RangeInclusive', + 'RangeExclusive', + ], + }, + }, + required: ['columnId', 'searchTerms'], + }, + }, + sorters: { + type: 'array', + items: { + type: 'object', + properties: { + columnId: { type: 'string' }, + direction: { type: 'string', enum: ['ASC', 'DESC'] }, + }, + required: ['columnId', 'direction'], + }, + }, + visibleColumnIds: { type: 'array', items: { type: 'string' } }, + }, + }, + execute: async (state: Partial) => { + try { + await this.applyGridState(state); + return { status: 'success', appliedState: state }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { status: 'error', message }; + } + }, + }); + } +} diff --git a/packages/web-mcp/tsconfig.json b/packages/web-mcp/tsconfig.json new file mode 100644 index 000000000..5f10effa5 --- /dev/null +++ b/packages/web-mcp/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compileOnSave": false, + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "typeRoots": ["./node_modules/@types", "../../node_modules/@types"] + }, + "exclude": ["dist", "node_modules", "**/*.spec.ts"], + "filesGlob": ["./src/**/*.ts"], + "include": ["src/**/*.ts", "types/**/*.ts"], + "references": [ + { + "path": "../common" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 874cae9eb..a0711d9d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@slickgrid-universal/text-export': specifier: workspace:* version: link:../../packages/text-export + '@slickgrid-universal/web-mcp': + specifier: workspace:* + version: link:../../packages/web-mcp aurelia: specifier: ^2.0.0-rc.1 version: 2.0.0-rc.1 @@ -421,6 +424,9 @@ importers: '@slickgrid-universal/text-export': specifier: workspace:* version: link:../../packages/text-export + '@slickgrid-universal/web-mcp': + specifier: workspace:* + version: link:../../packages/web-mcp bootstrap: specifier: 'catalog:' version: 5.3.8(@popperjs/core@2.11.8) @@ -651,6 +657,9 @@ importers: '@slickgrid-universal/vanilla-force-bundle': specifier: workspace:* version: link:../../packages/vanilla-force-bundle + '@slickgrid-universal/web-mcp': + specifier: workspace:* + version: link:../../packages/web-mcp alien-signals: specifier: ^3.2.1 version: 3.2.1 @@ -736,6 +745,9 @@ importers: '@slickgrid-universal/vue-row-detail-plugin': specifier: workspace:* version: link:../../frameworks-plugins/vue-row-detail-plugin + '@slickgrid-universal/web-mcp': + specifier: workspace:* + version: link:../../packages/web-mcp bootstrap: specifier: 'catalog:' version: 5.3.8(@popperjs/core@2.11.8) @@ -928,6 +940,9 @@ importers: '@slickgrid-universal/utils': specifier: workspace:* version: link:../../packages/utils + '@slickgrid-universal/web-mcp': + specifier: workspace:* + version: link:../../packages/web-mcp dequal: specifier: 'catalog:' version: 2.0.3 @@ -1637,6 +1652,12 @@ importers: specifier: 'catalog:' version: 8.0.13(@types/node@24.12.4)(esbuild@0.27.3)(jiti@2.6.1)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.99.0)(terser@5.46.0)(yaml@2.9.0) + packages/web-mcp: + dependencies: + '@slickgrid-universal/common': + specifier: workspace:* + version: link:../common + packages: '@4tw/cypress-drag-drop@2.3.1': diff --git a/test/cypress/e2e/example43.cy.ts b/test/cypress/e2e/example43.cy.ts new file mode 100644 index 000000000..17522d895 --- /dev/null +++ b/test/cypress/e2e/example43.cy.ts @@ -0,0 +1,50 @@ +describe('Example 43 - AI / Web MCP Toolkit', () => { + const titles = ['#', 'Title', 'Priority', 'Status', 'Duration (days)', 'Completed %']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example43`); + cy.get('h3').should('contain', 'Example 43 - AI / Web MCP Toolkit'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('.grid43') + .find('.slick-header-columns .slick-column-name') + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('getStructuredSchema() should return column metadata containing "priority"', () => { + cy.contains('getStructuredSchema()').click(); + cy.get('#mcp-output').should('contain', 'priority'); + }); + + it('applyGridState() should apply simulated LLM state (filter priority=High + sort duration desc)', () => { + // Apply simulated AI state + cy.contains('applyGridState()').click(); + + // Wait a short time for state to be applied then inspect grid state + cy.contains('getGridState()').click(); + + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state).to.have.property('filters'); + expect(state.filters).to.be.an('array').and.to.have.length.greaterThan(0); + expect(state.filters[0].columnId).to.equal('priority'); + }); + + // Verify first row priority cell contains 'High' + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', 'High'); + }); + + it('reset should clear filters (getGridState shows empty filters)', () => { + cy.contains('Reset').click(); + cy.contains('getGridState()').click(); + cy.get('#mcp-output') + .invoke('text') + .then((text) => { + const state = JSON.parse(text); + expect(state.filters).to.be.an('array').and.to.have.length(0); + }); + }); +}); diff --git a/tsconfig.packages.json b/tsconfig.packages.json index b7e67add3..0f84a154c 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -19,6 +19,7 @@ { "path": "./packages/text-export" }, { "path": "./packages/utils" }, { "path": "./packages/vanilla-bundle" }, - { "path": "./packages/vanilla-force-bundle" } + { "path": "./packages/vanilla-force-bundle" }, + { "path": "./packages/web-mcp" } ] }