@alaarab/ogrid-mcp 2.16.0 → 2.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Ala Arab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -68,7 +68,7 @@ const grid = useHeadlessGrid({
68
68
  | Field | Description |
69
69
  |---|---|
70
70
  | `rows` | Current page rows after sort + filter |
71
- | `allFilteredRows` | Full filtered+sorted set (client mode) or current page (server mode) |
71
+ | `allFilteredRows` | Every filtered+sorted row across pages (client mode), or the current page (server mode) |
72
72
  | `columns` | Resolved columns |
73
73
  | `totalCount` | Post-filter total |
74
74
  | `totalPages` | Total pages at current page size |
@@ -246,13 +246,14 @@ useEffect(() => {
246
246
  | `columns` | `IColumnDef<T>[]` |
247
247
  | `onCellEdit` | `(events: ICellValueChangedEvent<T>[]) => void` |
248
248
  | `clipboard?` | `{ readText, writeText }` override (defaults to `navigator.clipboard`) |
249
+ | `onClipboardError?` | `(error: unknown) => void`, called when a clipboard read/write fails (e.g. permission denied) |
249
250
 
250
251
  ### Returns
251
252
 
252
253
  | Field | Description |
253
254
  |---|---|
254
- | `copyRange()`, `cutRange()`, `pasteRange()` | Async actions |
255
- | `canPaste` | `boolean` (feature detection) |
255
+ | `copyRange()`, `cutRange()`, `pasteRange()` | Async actions. They resolve (never reject) when the clipboard is unavailable or denied; see `onClipboardError`. |
256
+ | `canPaste` | `boolean` (feature detection). `false` until mounted, so server-rendered markup hydrates cleanly. |
256
257
  | `activeCutRange`, `activeCopyRange` | Marching-ants tracking |
257
258
  | `clearClipboard()` | Dismiss markers (bind to Escape) |
258
259
 
@@ -61,8 +61,9 @@ The `OGrid` component has the same props across all React UI packages. To switch
61
61
  exportToCsv<T>(
62
62
  items: T[],
63
63
  columns: CsvColumn[],
64
- getValue: (item: T, columnId: string) => string,
65
- filename?: string
64
+ getValue: (item: T, columnId: string) => unknown,
65
+ filename?: string,
66
+ formulaOptions?: FormulaExportOptions
66
67
  ): void
67
68
  ```
68
69
 
@@ -71,13 +72,15 @@ exportToCsv<T>(
71
72
  | `items` | `T[]` | The row data to export |
72
73
  | `columns` | `CsvColumn[]` | Array of `{ columnId, name }` defining which columns to include and their header names |
73
74
  | `getValue` | `(item, columnId) => string` | Function to extract the display value for each cell |
74
- | `filename` | `string` (optional) | Download filename (default: `'export.csv'`) |
75
+ | `filename` | `string` (optional) | Download filename (default: `export_YYYY-MM-DD.csv`) |
76
+ | `formulaOptions` | `FormulaExportOptions` (optional) | Formula export settings, plus `preventFormulaInjection` (default `true`) |
75
77
 
76
78
  The function:
77
79
  1. Builds a CSV header row from `columns[].name`.
78
80
  2. Iterates over each item and calls `getValue` for each column.
79
- 3. Escapes values containing commas, quotes, or newlines.
80
- 4. Triggers a browser download with the specified filename.
81
+ 3. Escapes values containing commas, quotes, or line breaks.
82
+ 4. Guards against CSV (formula) injection: text starting with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with `'`, so a spreadsheet opening the file shows it as text instead of running it. Plain numbers such as `-5` are left alone. Pass `{ preventFormulaInjection: false }` to turn this off, for example when a trusted consumer needs the raw text. Formulas written with `exportMode: 'formulas'` are never prefixed.
83
+ 5. Triggers a browser download with the specified filename.
81
84
 
82
85
  ### Context Menu Integration
83
86
 
@@ -64,6 +64,13 @@ function SingleSheet({ file }: { file: Blob }) {
64
64
  | `density` | `'compact' \| 'normal' \| 'comfortable'` | `'compact'` | Row height. |
65
65
  | `onSheetChange` | `(name: string) => void` | — | Fired when the user clicks a sheet tab. |
66
66
  | `headerRow` | `'auto' \| 'header' \| 'none'` | `'auto'` | Whether to promote row 1 to the header. `'auto'` detects strings-only top rows; `'header'` forces it; `'none'` uses A/B/C letters. |
67
+ | `limits` | `{ maxRows?, maxCols?, maxCells? }` | 1,048,576 rows / 1,000 cols / 5,000,000 cells | Load caps per sheet. A small file can declare a huge used range; anything past the caps is dropped and a notice is shown above the grid. |
68
+
69
+ ### Untrusted files
70
+
71
+ Only populated cells are read, and each sheet is capped by `limits`. `sheetToGridData(sheet, { maxRows, maxCols, maxCells })` applies the same caps and returns `truncated: { rowCount, columnCount }` (the full size) when it cut the sheet.
72
+
73
+ `@alaarab/ogrid-react-xlsx` requires React 18 or 19.
67
74
 
68
75
  ### Format support
69
76
 
@@ -25,18 +25,18 @@
25
25
  *
26
26
  * This module contains NO Node.js-specific imports - safe to bundle in browsers.
27
27
  */
28
- interface BridgeColumnInfo {
28
+ export interface BridgeColumnInfo {
29
29
  columnId: string;
30
30
  headerName?: string;
31
31
  type?: string;
32
32
  }
33
- interface BridgeCommand {
33
+ export interface BridgeCommand {
34
34
  id: string;
35
35
  type: 'update_cell' | 'set_filter' | 'clear_filters' | 'set_sort' | 'go_to_page';
36
36
  payload: Record<string, unknown>;
37
37
  }
38
38
  /** Minimal subset of IOGridApi used by the bridge. */
39
- interface BridgeGridApi {
39
+ export interface BridgeGridApi {
40
40
  updateSort?: (model: Array<{
41
41
  columnId: string;
42
42
  direction: 'asc' | 'desc';
@@ -46,7 +46,7 @@ interface BridgeGridApi {
46
46
  goToPage?: (page: number) => void;
47
47
  getSelectedRows?: () => unknown[];
48
48
  }
49
- interface ConnectGridOptions {
49
+ export interface ConnectGridOptions {
50
50
  /** Unique identifier for this grid instance (shown in list_grids). */
51
51
  gridId: string;
52
52
  /** Returns the currently displayed rows. Called on every state push. */
@@ -76,12 +76,10 @@ interface ConnectGridOptions {
76
76
  /** How often to push state and poll commands (ms, default: 500). */
77
77
  pollIntervalMs?: number;
78
78
  }
79
- interface BridgeConnection {
79
+ export interface BridgeConnection {
80
80
  /** Stop polling and disconnect. */
81
81
  disconnect: () => void;
82
82
  /** Manually push current state immediately. */
83
83
  push: () => Promise<void>;
84
84
  }
85
- declare function connectGridToBridge(options: ConnectGridOptions): BridgeConnection;
86
-
87
- export { type BridgeColumnInfo, type BridgeCommand, type BridgeConnection, type BridgeGridApi, type ConnectGridOptions, connectGridToBridge };
85
+ export declare function connectGridToBridge(options: ConnectGridOptions): BridgeConnection;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * OGrid Live Testing Bridge
3
+ *
4
+ * An HTTP server (default port 7890) that running OGrid instances connect to.
5
+ * The MCP server holds a BridgeStore in-process and queries it directly when
6
+ * tools like list_grids / get_grid_state / send_grid_command are invoked.
7
+ *
8
+ * Protocol (used by bridge-client.ts in the browser):
9
+ * POST /grids/connect - register / heartbeat
10
+ * PUT /grids/:id/state - push current grid state
11
+ * GET /grids/:id/commands - poll for pending commands
12
+ * POST /grids/:id/commands/:cmdId/result - post command result
13
+ *
14
+ * Internal (used by MCP tools via BridgeStore directly):
15
+ * bridgeStore.listGrids()
16
+ * bridgeStore.getState(gridId)
17
+ * bridgeStore.enqueueCommand(gridId, cmd)
18
+ * bridgeStore.waitForResult(cmdId, timeoutMs)
19
+ */
20
+ import { type IncomingMessage } from 'node:http';
21
+ export interface GridColumnInfo {
22
+ columnId: string;
23
+ headerName?: string;
24
+ type?: string;
25
+ }
26
+ export interface GridStateSnapshot {
27
+ gridId: string;
28
+ connectedAt: number;
29
+ lastSeen: number;
30
+ rowCount: number;
31
+ totalCount: number;
32
+ page: number;
33
+ pageSize: number;
34
+ pageCount: number;
35
+ data: unknown[];
36
+ columns: GridColumnInfo[];
37
+ sortModel: Array<{
38
+ columnId: string;
39
+ direction: 'asc' | 'desc';
40
+ }>;
41
+ filterModel: Record<string, unknown>;
42
+ selectedRowIndices: number[];
43
+ }
44
+ export type GridCommandType = 'update_cell' | 'set_filter' | 'clear_filters' | 'set_sort' | 'go_to_page';
45
+ export interface GridCommand {
46
+ id: string;
47
+ type: GridCommandType;
48
+ payload: Record<string, unknown>;
49
+ createdAt: number;
50
+ status: 'pending' | 'completed' | 'error';
51
+ result?: unknown;
52
+ error?: string;
53
+ }
54
+ export declare class BridgeStore {
55
+ private readonly grids;
56
+ private readonly commandQueues;
57
+ private readonly commandResults;
58
+ private cmdSeq;
59
+ /** Drop stale grids, commands and results so a long-running bridge doesn't grow without bound. */
60
+ prune(now?: number): void;
61
+ upsertGrid(gridId: string, partial: Partial<GridStateSnapshot>): void;
62
+ popPendingCommands(gridId: string): GridCommand[];
63
+ resolveCommand(cmdId: string, result: unknown, error?: string): void;
64
+ listGrids(): GridStateSnapshot[];
65
+ getState(gridId: string): GridStateSnapshot | undefined;
66
+ enqueueCommand(gridId: string, type: GridCommandType, payload: Record<string, unknown>): GridCommand | null;
67
+ waitForResult(cmdId: string, timeoutMs?: number): Promise<GridCommand>;
68
+ }
69
+ /** Grid snapshots include row data, so allow a generous but finite body. */
70
+ export declare const MAX_BODY_BYTES: number;
71
+ /**
72
+ * Why a request must be refused, or null if it may proceed.
73
+ *
74
+ * CORS alone doesn't protect the bridge: "simple" requests (GET, or POST with
75
+ * a text/plain body) reach the server without a preflight, and their side
76
+ * effects happen even though the page can't read the response. So:
77
+ * - any Origin that isn't localhost is refused outright;
78
+ * - the Host must be loopback (blocks DNS rebinding, where a public name
79
+ * resolves to 127.0.0.1);
80
+ * - writes must be application/json, which forces a browser preflight.
81
+ */
82
+ export declare function rejectReason(req: IncomingMessage): string | null;
83
+ export declare function startBridgeServer(store: BridgeStore, port?: number): Promise<() => Promise<void>>;
@@ -0,0 +1,24 @@
1
+ export interface CodeBlock {
2
+ language: string;
3
+ code: string;
4
+ framework?: string;
5
+ }
6
+ export interface DocEntry {
7
+ path: string;
8
+ title: string;
9
+ description: string;
10
+ category: string;
11
+ content: string;
12
+ codeBlocks: CodeBlock[];
13
+ }
14
+ export interface DocsIndex {
15
+ entries: DocEntry[];
16
+ search(query: string, limit?: number): DocEntry[];
17
+ getByPath(path: string): DocEntry | undefined;
18
+ getByCategory(category: string): DocEntry[];
19
+ getCodeExamples(query: string, framework?: string): Array<{
20
+ entry: DocEntry;
21
+ block: CodeBlock;
22
+ }>;
23
+ }
24
+ export declare function loadDocsIndex(docsDir: string): DocsIndex;
@@ -0,0 +1 @@
1
+ export {};
package/dist/esm/index.js CHANGED
@@ -858,6 +858,8 @@ Command: ${JSON.stringify({ type, payload }, null, 2)}`
858
858
  }
859
859
  return server2;
860
860
  }
861
+ var GRID_TTL_MS = 10 * 6e4;
862
+ var COMMAND_TTL_MS = 5 * 6e4;
861
863
  var BridgeStore = class {
862
864
  constructor() {
863
865
  this.grids = /* @__PURE__ */ new Map();
@@ -865,6 +867,22 @@ var BridgeStore = class {
865
867
  this.commandResults = /* @__PURE__ */ new Map();
866
868
  this.cmdSeq = 0;
867
869
  }
870
+ /** Drop stale grids, commands and results so a long-running bridge doesn't grow without bound. */
871
+ prune(now = Date.now()) {
872
+ for (const [id, grid] of this.grids) {
873
+ if (now - grid.lastSeen > GRID_TTL_MS) {
874
+ this.grids.delete(id);
875
+ this.commandQueues.delete(id);
876
+ }
877
+ }
878
+ for (const [id, queue] of this.commandQueues) {
879
+ const live = queue.filter((c) => now - c.createdAt <= COMMAND_TTL_MS);
880
+ if (live.length !== queue.length) this.commandQueues.set(id, live);
881
+ }
882
+ for (const [id, cmd] of this.commandResults) {
883
+ if (now - cmd.createdAt > COMMAND_TTL_MS) this.commandResults.delete(id);
884
+ }
885
+ }
868
886
  // ---- Called by HTTP handler ----
869
887
  upsertGrid(gridId, partial) {
870
888
  const existing = this.grids.get(gridId);
@@ -908,6 +926,7 @@ var BridgeStore = class {
908
926
  cmd.result = result;
909
927
  cmd.error = error;
910
928
  this.commandResults.set(cmdId, { ...cmd });
929
+ queue.splice(queue.indexOf(cmd), 1);
911
930
  return;
912
931
  }
913
932
  }
@@ -940,6 +959,7 @@ var BridgeStore = class {
940
959
  const poll = () => {
941
960
  const cmd = this.commandResults.get(cmdId);
942
961
  if (cmd) {
962
+ this.commandResults.delete(cmdId);
943
963
  resolve(cmd);
944
964
  return;
945
965
  }
@@ -953,10 +973,22 @@ var BridgeStore = class {
953
973
  });
954
974
  }
955
975
  };
976
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
977
+ var PayloadTooLargeError = class extends Error {
978
+ };
956
979
  function readBody(req) {
957
980
  return new Promise((resolve, reject) => {
958
981
  const chunks = [];
959
- req.on("data", (c) => chunks.push(c));
982
+ let size = 0;
983
+ req.on("data", (c) => {
984
+ size += c.length;
985
+ if (size > MAX_BODY_BYTES) {
986
+ req.destroy();
987
+ reject(new PayloadTooLargeError(`Body exceeds ${MAX_BODY_BYTES} bytes`));
988
+ return;
989
+ }
990
+ chunks.push(c);
991
+ });
960
992
  req.on("end", () => {
961
993
  try {
962
994
  resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8") || "null"));
@@ -968,10 +1000,22 @@ function readBody(req) {
968
1000
  });
969
1001
  }
970
1002
  var LOCALHOST_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
1003
+ var LOCALHOST_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
971
1004
  function corsOrigin(req) {
972
1005
  const origin = req.headers.origin;
973
1006
  return origin && LOCALHOST_ORIGIN.test(origin) ? origin : "";
974
1007
  }
1008
+ function rejectReason(req) {
1009
+ const host = req.headers.host ?? "";
1010
+ if (!LOCALHOST_HOST.test(host)) return "Host must be localhost";
1011
+ const origin = req.headers.origin;
1012
+ if (origin && !LOCALHOST_ORIGIN.test(origin)) return "Origin not allowed";
1013
+ if (req.method === "POST" || req.method === "PUT") {
1014
+ const type = (req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
1015
+ if (type !== "application/json") return "Content-Type must be application/json";
1016
+ }
1017
+ return null;
1018
+ }
975
1019
  function send(req, res, status, body) {
976
1020
  const json = JSON.stringify(body);
977
1021
  const headers = {
@@ -1001,6 +1045,12 @@ function startBridgeServer(store, port = 7890) {
1001
1045
  res.end();
1002
1046
  return;
1003
1047
  }
1048
+ const refused = rejectReason(req);
1049
+ if (refused) {
1050
+ send(req, res, 403, { error: refused });
1051
+ return;
1052
+ }
1053
+ store.prune();
1004
1054
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
1005
1055
  const parts = url.pathname.replace(/^\//, "").split("/");
1006
1056
  try {
@@ -1043,6 +1093,10 @@ function startBridgeServer(store, port = 7890) {
1043
1093
  }
1044
1094
  send(req, res, 404, { error: "Not found" });
1045
1095
  } catch (err) {
1096
+ if (err instanceof PayloadTooLargeError) {
1097
+ send(req, res, 413, { error: err.message });
1098
+ return;
1099
+ }
1046
1100
  send(req, res, 500, { error: String(err) });
1047
1101
  }
1048
1102
  });
@@ -0,0 +1,4 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { DocsIndex } from './docsLoader.js';
3
+ import type { BridgeStore } from './bridge.js';
4
+ export declare function createOGridMcpServer(index: DocsIndex, bridge?: BridgeStore): McpServer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-mcp",
3
- "version": "2.16.0",
3
+ "version": "2.17.1",
4
4
  "description": "MCP server for OGrid documentation",
5
5
  "type": "module",
6
6
  "repository": {
@@ -12,12 +12,22 @@
12
12
  "ogrid-mcp": "./dist/esm/index.js"
13
13
  },
14
14
  "main": "./dist/esm/index.js",
15
+ "types": "dist/esm/index.d.ts",
16
+ "typesVersions": {
17
+ "*": {
18
+ "bridge-client": [
19
+ "dist/esm/bridge-client.d.ts"
20
+ ]
21
+ }
22
+ },
15
23
  "exports": {
16
24
  ".": {
25
+ "types": "./dist/esm/index.d.ts",
17
26
  "import": "./dist/esm/index.js",
18
27
  "default": "./dist/esm/index.js"
19
28
  },
20
29
  "./bridge-client": {
30
+ "types": "./dist/esm/bridge-client.d.ts",
21
31
  "import": "./dist/esm/bridge-client.js",
22
32
  "default": "./dist/esm/bridge-client.js"
23
33
  }
@@ -28,18 +38,18 @@
28
38
  ],
29
39
  "scripts": {
30
40
  "prebuild": "node scripts/bundle-docs.mjs",
31
- "build": "tsup",
41
+ "build": "tsup && tsc -p tsconfig.json --emitDeclarationOnly --outDir dist/esm && node ../../scripts/fix-dts-extensions.mjs dist/esm",
32
42
  "dev": "tsup --watch",
33
43
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
44
  "test": "bun test --preload ../../bun-test.setup.ts"
35
45
  },
36
46
  "dependencies": {
37
- "@modelcontextprotocol/sdk": "^1.29.0",
38
- "zod": "^4.4.3"
47
+ "@modelcontextprotocol/sdk": "^1.30.0",
48
+ "zod": "^4.5.4"
39
49
  },
40
50
  "devDependencies": {
41
51
  "tsup": "^8.5.1",
42
- "typescript": "^6.0.3"
52
+ "typescript": "^7.0.2"
43
53
  },
44
54
  "engines": {
45
55
  "node": ">=18"