Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -57,4 +58,4 @@
"publishConfig": {
"access": "public"
}
}
}
80 changes: 80 additions & 0 deletions src/receipt-guard.ts
Original file line number Diff line number Diff line change
@@ -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<RequireReceiptModule>;

let modulePromise: Promise<RequireReceiptModule> | undefined;
function loadModule(): Promise<RequireReceiptModule> {
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<string, Promise<ReceiptGate>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getGate(key: string, action: (target: any) => string): Promise<ReceiptGate> {
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<void>
): Promise<RunResult> {
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<number | string>,
receipt: unknown,
fn: () => Promise<void>
): Promise<RunResult> {
const gate = await getGate('batch_delete_rows', (t: {
tableId: unknown;
rowIds: ReadonlyArray<number | string>;
}) => {
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);
}
68 changes: 57 additions & 11 deletions src/tools/row.ts
Original file line number Diff line number Diff line change
@@ -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 [
Expand Down Expand Up @@ -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']
}
Expand Down Expand Up @@ -178,7 +191,8 @@ export function getRowToolSchemas(): Tool[] {
items: {
type: 'number'
}
}
},
authorization_receipt: authorizationReceiptSchema
},
required: ['table_id', 'row_ids']
}
Expand Down Expand Up @@ -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)) {
Expand All @@ -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}`);
Expand Down
51 changes: 51 additions & 0 deletions src/types/require-receipt.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };

/** Result of gate.run: ok carries the fn result; rejection carries the 428 body. */
export type RunResult<T = unknown> =
| { ok: true; receiptId: string; outcome?: string; signer?: string; result: T }
| { ok: false; status: number; body: Record<string, unknown> };

/** 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<T = unknown>(receipt: unknown, ctx: GateContext, fn: () => Promise<T> | T): Promise<RunResult<T>>;
run<T = unknown>(receipt: unknown, fn: () => Promise<T> | T): Promise<RunResult<T>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
boundActionFor(target: any): string;
}

export function makeReceiptGate(opts: ReceiptGateOptions): ReceiptGate;
}