diff --git a/package-lock.json b/package-lock.json index 3df78a6..07f79df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { - "name": "@mcp-servers/baserow", + "name": "@ayyazzafar/mcp-baserow", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@mcp-servers/baserow", + "name": "@ayyazzafar/mcp-baserow", "version": "0.1.0", "license": "MIT", "dependencies": { + "@emilia-protocol/require-receipt": "^0.4.0", "@modelcontextprotocol/sdk": "^1.0.4", "axios": "^1.7.9", "dotenv": "^16.4.7" @@ -21,6 +22,15 @@ "node": ">=22.0.0" } }, + "node_modules/@emilia-protocol/require-receipt": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emilia-protocol/require-receipt/-/require-receipt-0.4.0.tgz", + "integrity": "sha512-ECl95tEF3LFPAfXyQb1BrcvoRF/oVXyu4TKAsFGE2g3axnuFoOpvio5Y6KgObajWqi2sevmrU8MEcDd04DFWdg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", diff --git a/package.json b/package.json index 01b67f9..904015b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "homepage": "https://github.com/ayyazzafar/mcp-baserow#readme", "dependencies": { + "@emilia-protocol/require-receipt": "^0.4.0", "@modelcontextprotocol/sdk": "^1.0.4", "axios": "^1.7.9", "dotenv": "^16.4.7" @@ -57,4 +58,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/src/receipt-guard.ts b/src/receipt-guard.ts new file mode 100644 index 0000000..2aeb346 --- /dev/null +++ b/src/receipt-guard.ts @@ -0,0 +1,80 @@ +import type { ReceiptGate, RunResult } from '@emilia-protocol/require-receipt'; + +// @emilia-protocol/require-receipt is ESM-only while this server compiles to +// CommonJS, so load it via a real dynamic import() that tsc won't downlevel to +// require(). Cache the module after the first load. +type RequireReceiptModule = typeof import('@emilia-protocol/require-receipt'); +const dynamicImport = new Function( + 'specifier', + 'return import(specifier)' +) as (specifier: string) => Promise; + +let modulePromise: Promise | undefined; +function loadModule(): Promise { + if (!modulePromise) { + modulePromise = dynamicImport('@emilia-protocol/require-receipt'); + } + return modulePromise; +} + +// All the hardening (per-target binding, verify, replay refusal, consume-after- +// success, sanitized {reason} rejections) now lives in the canonical +// makeReceiptGate. This file just builds one gate per irreversible action — each +// `action` is a function so the EXACT bound action string is derived here — and +// caches it across the async module load. NOTE: allowInlineKey accepts the +// receipt's own key (proves integrity, not trust); in production pin trustedKeys +// to the issuers you trust and drop allowInlineKey. +const gates = new Map>(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getGate(key: string, action: (target: any) => string): Promise { + let gate = gates.get(key); + if (!gate) { + gate = loadModule().then(({ makeReceiptGate }) => + makeReceiptGate({ action, allowInlineKey: true, maxAgeSec: 900 }) + ); + gates.set(key, gate); + } + return gate; +} + +/** + * Demand a verifiable EMILIA authorization receipt for a single-row delete, + * bound to THIS exact table + row: a receipt approving `baserow.row.delete:5:11` + * cannot delete row 99. gate.run verifies+reserves, runs `fn`, then consumes the + * receipt only AFTER it succeeds — if `fn` throws the approval is released (stays + * retryable) and the error propagates. Replay is refused; a verification failure + * returns a sanitized Receipt Required challenge ({ rejected: { reason } }). + */ +export async function runDeleteRowGuarded( + tableId: number | string, + rowId: number | string, + receipt: unknown, + fn: () => Promise +): Promise { + const gate = await getGate( + 'delete_row', + (t: { tableId: unknown; rowId: unknown }) => `baserow.row.delete:${t.tableId}:${t.rowId}` + ); + return gate.run(receipt, { target: { tableId, rowId } }, fn); +} + +/** + * Same semantics for a batch-row delete, bound to THIS exact table + set of rows. + * row ids are sorted numerically so the binding is order-independent: a receipt + * approving {3,5,9} authorizes exactly {3,5,9}, never a different set. + */ +export async function runBatchDeleteRowsGuarded( + tableId: number | string, + rowIds: ReadonlyArray, + receipt: unknown, + fn: () => Promise +): Promise { + const gate = await getGate('batch_delete_rows', (t: { + tableId: unknown; + rowIds: ReadonlyArray; + }) => { + const sorted = [...t.rowIds].map(Number).sort((a, b) => a - b); + return `baserow.rows.batch_delete:${t.tableId}:${sorted.join(',')}`; + }); + return gate.run(receipt, { target: { tableId, rowIds } }, fn); +} diff --git a/src/tools/row.ts b/src/tools/row.ts index d9a1c35..5f0c94e 100644 --- a/src/tools/row.ts +++ b/src/tools/row.ts @@ -1,5 +1,17 @@ import { BaserowClient } from '../baserow-client.js'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { runDeleteRowGuarded, runBatchDeleteRowsGuarded } from '../receipt-guard.js'; + +// Reusable schema for the EMILIA authorization receipt carried as a tool argument +// (the MCP stdio equivalent of an HTTP receipt header). Optional in the schema so +// a missing receipt returns a structured Receipt Required challenge rather than a +// schema validation error. +const authorizationReceiptSchema = { + type: 'object', + description: + 'An EMILIA authorization receipt proving a named human approved this exact deletion. Required to execute.', + additionalProperties: true +} as const; export function getRowToolSchemas(): Tool[] { return [ @@ -106,7 +118,8 @@ export function getRowToolSchemas(): Tool[] { row_id: { type: 'number', description: 'The ID of the row to delete' - } + }, + authorization_receipt: authorizationReceiptSchema }, required: ['table_id', 'row_id'] } @@ -178,7 +191,8 @@ export function getRowToolSchemas(): Tool[] { items: { type: 'number' } - } + }, + authorization_receipt: authorizationReceiptSchema }, required: ['table_id', 'row_ids'] } @@ -234,16 +248,31 @@ export async function handleRowTools( }); break; - case 'baserow_delete_row': + case 'baserow_delete_row': { if (!args?.table_id || !args?.row_id) { throw new Error('table_id and row_id are required'); } - await client.deleteRow(args.table_id, args.row_id); + // Bind the receipt to THIS exact row, not just "a delete": a receipt + // approving baserow.row.delete:5:11 cannot delete row 99 in table 5. The + // gate verifies+reserves, runs the delete, then consumes the receipt only + // AFTER it succeeds (failure releases it, keeping the approval retryable). + const guard = await runDeleteRowGuarded( + args.table_id, + args.row_id, + args?.authorization_receipt, + () => client.deleteRow(args.table_id, args.row_id) + ); + if (!guard.ok) { + result = guard.body; + break; + } result = { success: true, - message: `Row ${args.row_id} deleted successfully` + message: `Row ${args.row_id} deleted successfully`, + authorization_receipt_id: guard.receiptId }; break; + } case 'baserow_batch_create_rows': if (!args?.table_id || !args?.rows || !Array.isArray(args.rows)) { @@ -265,19 +294,36 @@ export async function handleRowTools( }); break; - case 'baserow_batch_delete_rows': + case 'baserow_batch_delete_rows': { if (!args?.table_id || !args?.row_ids || !Array.isArray(args.row_ids)) { throw new Error('table_id and row_ids array are required'); } - await client.batchDeleteRows({ - table_id: args.table_id, - row_ids: args.row_ids - }); + // Bind the receipt to THIS exact table + set of rows. The gate folds the + // numerically-sorted row ids into the bound action so the binding is + // order-independent: a receipt approving {3,5,9} authorizes exactly + // {3,5,9}, never a different set. Consume-after-success / replay refusal / + // sanitized rejection all come from the gate. + const guard = await runBatchDeleteRowsGuarded( + args.table_id, + args.row_ids, + args?.authorization_receipt, + () => + client.batchDeleteRows({ + table_id: args.table_id, + row_ids: args.row_ids + }) + ); + if (!guard.ok) { + result = guard.body; + break; + } result = { success: true, - message: `${args.row_ids.length} rows deleted successfully` + message: `${args.row_ids.length} rows deleted successfully`, + authorization_receipt_id: guard.receiptId }; break; + } default: throw new Error(`Unknown row tool: ${toolName}`); diff --git a/src/types/require-receipt.d.ts b/src/types/require-receipt.d.ts new file mode 100644 index 0000000..4a3f9f7 --- /dev/null +++ b/src/types/require-receipt.d.ts @@ -0,0 +1,51 @@ +// Minimal local type declarations for @emilia-protocol/require-receipt (^0.4.0). +// The published package ships no .d.ts, so we declare only the surface we use: +// the canonical hardened gate. See: https://www.emiliaprotocol.ai/agent-guard + +declare module '@emilia-protocol/require-receipt' { + /** Options for the canonical hardened gate (makeReceiptGate). */ + export interface ReceiptGateOptions { + /** base action_type, or a fn deriving the fully-bound action from the target */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + action: string | ((target: any) => string); + /** base64url SPKI-DER issuer keys you trust (recommended in production) */ + trustedKeys?: string[]; + /** also accept the receipt's own inline key (proves integrity, NOT trust) */ + allowInlineKey?: boolean; + maxAgeSec?: number; + allowedOutcomes?: string[]; + statusCode?: number; + manifestUrl?: string; + assuranceClass?: string; + /** consumed-receipt store; defaults to in-memory (process-local) */ + store?: { has: (id: string) => boolean; add: (id: string) => void }; + } + + /** The resource the receipt must be bound to. */ + export interface GateContext { + target?: unknown; + } + + /** Verify+reserve result from gate.check (ok), else a Receipt Required 428 body. */ + export type CheckResult = + | { ok: true; receiptId: string; outcome?: string; signer?: string; subject?: string; boundAction: string } + | { ok: false; status: number; body: Record }; + + /** Result of gate.run: ok carries the fn result; rejection carries the 428 body. */ + export type RunResult = + | { ok: true; receiptId: string; outcome?: string; signer?: string; result: T } + | { ok: false; status: number; body: Record }; + + /** The hardened Receipt-Required gate returned by makeReceiptGate. */ + export interface ReceiptGate { + check(receipt: unknown, ctx?: GateContext): CheckResult; + commit(receiptId: string): void; + release(receiptId: string): void; + run(receipt: unknown, ctx: GateContext, fn: () => Promise | T): Promise>; + run(receipt: unknown, fn: () => Promise | T): Promise>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + boundActionFor(target: any): string; + } + + export function makeReceiptGate(opts: ReceiptGateOptions): ReceiptGate; +}