|
| 1 | +# AI Toolkit (`@slickgrid-universal/web-mcp`) |
| 2 | + |
| 3 | +This page describes using the AI Toolkit from the Angular Slickgrid wrapper. |
| 4 | + |
| 5 | +The AI Toolkit is an optional external resource 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 (for example, "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. |
| 6 | + |
| 7 | +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 then applied back to the grid. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Why use this? |
| 12 | + |
| 13 | +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. |
| 14 | + |
| 15 | +**Key use cases:** |
| 16 | + |
| 17 | +- **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. |
| 18 | +- **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. |
| 19 | +- **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. |
| 20 | +- **Accessibility** — users who find filter forms difficult to use can interact with the grid through a text/voice interface backed by an LLM. |
| 21 | +- **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. |
| 22 | + |
| 23 | +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. |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## How It Works |
| 28 | + |
| 29 | +1. **Schema discovery** — the LLM calls `get_slickgrid_schema` to learn the columns available (id, type, sortable, filterable). |
| 30 | +2. **State snapshot** — the LLM calls `get_slickgrid_state` to understand what filters/sorts/column visibility are currently active. |
| 31 | +3. **Prompt + LLM call** — your application sends the user query, the schema and the current state to an LLM of your choice. |
| 32 | +4. **State application** — the LLM response is passed to `apply_slickgrid_state`, which updates filters, sorting and column visibility in a single call. |
| 33 | + |
| 34 | +``` |
| 35 | +User query |
| 36 | + │ |
| 37 | + ▼ |
| 38 | +get_slickgrid_schema + get_slickgrid_state → LLM → apply_slickgrid_state |
| 39 | +``` |
| 40 | + |
| 41 | +--- |
| 42 | + |
| 43 | +## Installation |
| 44 | + |
| 45 | +```bash |
| 46 | +npm install @slickgrid-universal/web-mcp |
| 47 | +``` |
| 48 | + |
| 49 | +## Registration |
| 50 | + |
| 51 | +```ts |
| 52 | +import { WebMcpService } from '@slickgrid-universal/web-mcp'; |
| 53 | + |
| 54 | +const gridOptions = { |
| 55 | + externalResources: [new WebMcpService()], |
| 56 | + // ... |
| 57 | +}; |
| 58 | +``` |
| 59 | + |
| 60 | +The service silently no-ops when the browser does not expose `navigator.modelContext`, so it is safe to include unconditionally. |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +## WebMCP Tools |
| 65 | + |
| 66 | +All tool names are suffixed with the grid's UID to support multiple grids on the same page. |
| 67 | + |
| 68 | +| Tool | Description | |
| 69 | +|---|---| |
| 70 | +| `read_slickgrid_data_<uid>` | Returns current data rows. Accepts an optional `limit` (default 20). | |
| 71 | +| `get_slickgrid_schema_<uid>` | Returns column metadata (id, field, type, filterable, sortable). Call this first so the LLM knows what columns exist. | |
| 72 | +| `get_slickgrid_state_<uid>` | Returns the current grid state: active filters, active sorters and visible column ids. | |
| 73 | +| `apply_slickgrid_state_<uid>` | Applies a full or partial grid state. Any omitted key leaves that aspect unchanged. | |
| 74 | + |
| 75 | +### `apply_slickgrid_state` payload |
| 76 | + |
| 77 | +```ts |
| 78 | +{ |
| 79 | + // optional — replaces all active filters |
| 80 | + filters?: Array<{ |
| 81 | + columnId: string; |
| 82 | + searchTerms: string[]; |
| 83 | + operator?: 'EQ' | 'NE' | 'GT' | 'GE' | 'LT' | 'LE' | 'CONTAINS' | 'NOT_CONTAINS' | 'IN' | 'NIN'; |
| 84 | + }>; |
| 85 | + |
| 86 | + // optional — replaces all active sorts |
| 87 | + sorters?: Array<{ |
| 88 | + columnId: string; |
| 89 | + direction: 'ASC' | 'DESC'; |
| 90 | + }>; |
| 91 | + |
| 92 | + // optional — ids of columns that should be visible (all others are hidden) |
| 93 | + visibleColumnIds?: string[]; |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +--- |
| 98 | + |
| 99 | +## Public API |
| 100 | + |
| 101 | +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): |
| 102 | + |
| 103 | +```ts |
| 104 | +// Column metadata as JSON Schema |
| 105 | +service.getStructuredSchema(): SlickColumnSchema[] |
| 106 | + |
| 107 | +// Snapshot of current grid state |
| 108 | +service.getGridState(): SlickGridState |
| 109 | + |
| 110 | +// Apply a full or partial state |
| 111 | +await service.applyGridState(state: Partial<SlickGridState>): Promise<void> |
| 112 | +``` |
| 113 | + |
| 114 | +### Example: custom LLM integration |
| 115 | + |
| 116 | +```ts |
| 117 | +import { WebMcpService } from '@slickgrid-universal/web-mcp'; |
| 118 | + |
| 119 | +const mcpService = new WebMcpService(); |
| 120 | +// mcpService is already init'd via externalResources |
| 121 | + |
| 122 | +async function onUserQuery(userQuery: string) { |
| 123 | + const schema = mcpService.getStructuredSchema(); |
| 124 | + const currentState = mcpService.getGridState(); |
| 125 | + |
| 126 | + const response = await callMyLlm({ |
| 127 | + query: userQuery, |
| 128 | + schema, |
| 129 | + currentState, |
| 130 | + }); |
| 131 | + |
| 132 | + await mcpService.applyGridState(response.newState); |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +## Prompting |
| 139 | + |
| 140 | +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: |
| 141 | + |
| 142 | +- **Include the current grid state** so the LLM understands what is already applied. |
| 143 | +- **Include a few sample rows** (or the full dataset for small data) so the LLM understands the data format and domain values. |
| 144 | +- **Ask for an `explanation` string** alongside the state change — your UI can show users what changed. |
| 145 | +- **List the available features** (filtering, sorting, column visibility) so the LLM knows what it can and cannot do. |
| 146 | +- **Include domain context** inline, e.g. _"In this dataset, 'priority' values are 'Low', 'Medium' and 'High'"_. |
| 147 | +- **Ask for only the changed state**, not the full state, so that unchanged properties are not accidentally reset. |
| 148 | + |
| 149 | +### Starter system prompt |
| 150 | + |
| 151 | +```ts |
| 152 | +const systemPrompt = ` |
| 153 | +You are an expert data analyst working with a data grid. |
| 154 | +Respond to user requests by returning a JSON object with the following shape: |
| 155 | +
|
| 156 | +{ |
| 157 | + "newState": { /* partial grid state — only include what changed */ }, |
| 158 | + "propertiesToIgnore": [ /* list state keys you did NOT change */ ], |
| 159 | + "explanation": "short human-readable description of changes" |
| 160 | +} |
| 161 | +
|
| 162 | +Available state keys: "filters", "sorters", "visibleColumnIds". |
| 163 | +
|
| 164 | +Current grid schema (columns and their capabilities): |
| 165 | +${JSON.stringify(schema, null, 2)} |
| 166 | +
|
| 167 | +Current grid state: |
| 168 | +${JSON.stringify(currentState, null, 2)} |
| 169 | +`; |
| 170 | +``` |
| 171 | + |
| 172 | +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. |
| 173 | + |
| 174 | +--- |
| 175 | + |
| 176 | +## Schema validation |
| 177 | + |
| 178 | +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: |
| 179 | + |
| 180 | +```ts |
| 181 | +import Ajv from 'ajv'; |
| 182 | + |
| 183 | +const ajv = new Ajv(); |
| 184 | +const validate = ajv.compile({ |
| 185 | + type: 'object', |
| 186 | + properties: { |
| 187 | + filters: { type: 'array' }, |
| 188 | + sorters: { type: 'array' }, |
| 189 | + visibleColumnIds: { type: 'array', items: { type: 'string' } }, |
| 190 | + }, |
| 191 | + additionalProperties: false, |
| 192 | +}); |
| 193 | + |
| 194 | +const parsed = JSON.parse(llmResponse); |
| 195 | +if (!validate(parsed.newState)) { |
| 196 | + console.error('LLM returned invalid grid state', validate.errors); |
| 197 | + return; |
| 198 | +} |
| 199 | + |
| 200 | +await mcpService.applyGridState(parsed.newState); |
| 201 | +``` |
| 202 | + |
| 203 | +--- |
| 204 | + |
| 205 | +## Handling schema size |
| 206 | + |
| 207 | +`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: |
| 208 | + |
| 209 | +- **Filter the schema** before sending — strip columns that should not be AI-manipulable: |
| 210 | + ```ts |
| 211 | + const schema = mcpService.getStructuredSchema().filter(col => col.filterable || col.sortable); |
| 212 | + ``` |
| 213 | +- **Add column descriptions sparingly** — if you augment schema entries with free-text descriptions, keep them concise. |
| 214 | +- **Monitor total prompt size** — aim to leave at least 20–25 % of the context window for the model's response. |
| 215 | + |
| 216 | +--- |
| 217 | + |
| 218 | +## Extending |
| 219 | + |
| 220 | +Override `_registerDefaultTools()` to add custom tools or replace the built-in ones: |
| 221 | + |
| 222 | +```ts |
| 223 | +import { WebMcpService, type WebMcpTool } from '@slickgrid-universal/web-mcp'; |
| 224 | + |
| 225 | +class MyMcpService extends WebMcpService { |
| 226 | + protected override _registerDefaultTools(modelContext: { registerTool: (t: WebMcpTool) => void }): void { |
| 227 | + super._registerDefaultTools(modelContext); |
| 228 | + |
| 229 | + modelContext.registerTool({ |
| 230 | + name: `highlight_row_${this._grid.getUID()}`, |
| 231 | + description: 'Highlights a specific row by its id.', |
| 232 | + inputSchema: { |
| 233 | + type: 'object', |
| 234 | + properties: { rowId: { type: 'number' } }, |
| 235 | + required: ['rowId'], |
| 236 | + }, |
| 237 | + execute: async ({ rowId }) => { |
| 238 | + // custom logic here |
| 239 | + return { status: 'success' }; |
| 240 | + }, |
| 241 | + }); |
| 242 | + } |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +--- |
| 247 | + |
| 248 | +## Notes |
| 249 | + |
| 250 | +- `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). |
| 251 | +- **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. |
| 252 | +- **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. |
| 253 | + |
| 254 | +### Try it locally |
| 255 | + |
| 256 | +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. |
| 257 | + |
| 258 | +--- |
| 259 | + |
| 260 | +## Playwright / MCP example |
| 261 | + |
| 262 | +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): |
| 263 | + |
| 264 | +1. Call `get_slickgrid_schema_<uid>` to discover column ids and types. |
| 265 | +2. Locate the `columnId` for the column you want to filter (e.g. `cost`). |
| 266 | +3. Call `filter_slickgrid_<uid>` (or `apply_slickgrid_state_<uid>`) with a validated payload. |
| 267 | + |
| 268 | +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. |
| 269 | + |
| 270 | +```ts |
| 271 | +// Playwright (browser) context example — runs inside the page |
| 272 | +const uid = 'slickgrid_123456'; |
| 273 | +const mc = (navigator as any).modelContext; |
| 274 | + |
| 275 | +// 1) discover schema |
| 276 | +const schema = await mc.invokeTool(`get_slickgrid_schema_${uid}`, {}); |
| 277 | + |
| 278 | +// 2) find the cost column |
| 279 | +const costCol = schema.find((c: any) => c.field === 'cost' || (c.name && c.name.toLowerCase().includes('cost'))); |
| 280 | +if (!costCol) throw new Error('cost column not found'); |
| 281 | + |
| 282 | +// 3) apply a filter: cost < 50 |
| 283 | +await mc.invokeTool(`filter_slickgrid_${uid}`, { |
| 284 | + columnId: costCol.id, |
| 285 | + search: '50', |
| 286 | + operator: 'LT', |
| 287 | +}); |
| 288 | +``` |
| 289 | + |
| 290 | +Tool payload (JSON) for the example above: |
| 291 | + |
| 292 | +```json |
| 293 | +{ |
| 294 | + "columnId": "cost", |
| 295 | + "search": "50", |
| 296 | + "operator": "LT" |
| 297 | +} |
| 298 | +``` |
| 299 | + |
| 300 | +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. |
0 commit comments