@synapsor/runner 0.1.0-alpha.10 → 0.1.0-alpha.13

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.
Files changed (54) hide show
  1. package/README.md +203 -21
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/runner.mjs +1103 -115
  4. package/docs/README.md +38 -0
  5. package/docs/app-owned-executors.md +26 -0
  6. package/docs/capability-authoring.md +265 -0
  7. package/docs/cloud-mode.md +24 -0
  8. package/docs/current-scope.md +24 -0
  9. package/docs/dependency-license-inventory.md +35 -0
  10. package/docs/doctor.md +98 -0
  11. package/docs/handler-helper.md +200 -0
  12. package/docs/http-mcp.md +35 -1
  13. package/docs/licensing.md +36 -0
  14. package/docs/local-mode.md +13 -2
  15. package/docs/mcp-client-setup.md +39 -0
  16. package/docs/openai-agents-sdk.md +57 -0
  17. package/docs/release-notes.md +76 -2
  18. package/docs/release-policy.md +86 -0
  19. package/docs/result-envelope-v2.md +148 -0
  20. package/docs/rfcs/001-result-envelope-v2.md +143 -0
  21. package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
  22. package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
  23. package/docs/store-lifecycle.md +83 -0
  24. package/docs/use-your-own-database.md +18 -0
  25. package/docs/writeback-executors.md +29 -0
  26. package/examples/app-owned-writeback/README.md +1 -0
  27. package/examples/mcp-postgres-billing-app-handler/README.md +86 -0
  28. package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +125 -0
  29. package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
  30. package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
  31. package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
  32. package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
  33. package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
  34. package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
  35. package/examples/openai-agents-http/README.md +10 -2
  36. package/examples/openai-agents-stdio/README.md +8 -4
  37. package/examples/openai-agents-stdio/agent.py +2 -0
  38. package/fixtures/benchmark/mcp-efficiency.json +53 -0
  39. package/fixtures/benchmark/mcp-efficiency.txt +25 -0
  40. package/fixtures/protocol/MANIFEST.json +54 -0
  41. package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
  42. package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
  43. package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
  44. package/fixtures/protocol/runner-registration.v1.json +22 -0
  45. package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
  46. package/package.json +4 -1
  47. package/schemas/change-set.v1.schema.json +140 -0
  48. package/schemas/execution-receipt.v1.schema.json +34 -0
  49. package/schemas/onboarding-selection.v1.schema.json +125 -0
  50. package/schemas/runner-registration.v1.schema.json +48 -0
  51. package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
  52. package/schemas/synapsor.app-handler-request.v1.json +119 -0
  53. package/schemas/synapsor.runner.schema.json +412 -0
  54. package/schemas/writeback-job.v1.schema.json +121 -0
@@ -0,0 +1,437 @@
1
+ import crypto from "node:crypto";
2
+ import { Pool } from "pg";
3
+ import { z } from "zod";
4
+ const safeIdentifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
5
+ const scalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
6
+ export const appHandlerRequestV1Schema = z.object({
7
+ protocol_version: z.string().optional(),
8
+ schema_version: z.string().optional(),
9
+ proposal_id: z.string().min(1),
10
+ idempotency_key: z.string().min(1),
11
+ issued_at: z.string().optional(),
12
+ signature: z.string().optional(),
13
+ }).passthrough();
14
+ export const appHandlerReceiptV1Schema = z.object({
15
+ status: z.enum(["applied", "already_applied", "conflict", "failed"]),
16
+ rows_affected: z.number().int().nonnegative().default(0),
17
+ source_database_mutated: z.boolean().default(false),
18
+ previous_version: scalarSchema.optional(),
19
+ new_version: scalarSchema.optional(),
20
+ safe_error_code: z.string().nullable().optional(),
21
+ details: z.record(z.unknown()).optional(),
22
+ });
23
+ export function createWritebackHandler(options) {
24
+ const database = options.database ?? createConfiguredDatabase(options);
25
+ return async (request, response) => {
26
+ const result = await handleWritebackHttpRequest(request, options, database);
27
+ response.statusCode = result.statusCode;
28
+ response.setHeader("content-type", "application/json; charset=utf-8");
29
+ response.end(`${JSON.stringify(result.receipt, null, 2)}\n`);
30
+ };
31
+ }
32
+ export async function handleWritebackHttpRequest(request, options, database = options.database ?? createConfiguredDatabase(options)) {
33
+ try {
34
+ if (request.method && request.method !== "POST") {
35
+ return { statusCode: 405, receipt: failedReceipt("METHOD_NOT_ALLOWED") };
36
+ }
37
+ const rawBody = await readRawBody(request);
38
+ const auth = verifyRequestAuth({
39
+ headers: request.headers,
40
+ rawBody,
41
+ options,
42
+ });
43
+ if (!auth.ok)
44
+ return auth;
45
+ let body;
46
+ try {
47
+ body = JSON.parse(rawBody || "{}");
48
+ }
49
+ catch {
50
+ return { statusCode: 400, receipt: failedReceipt("BAD_JSON") };
51
+ }
52
+ const parsed = appHandlerRequestV1Schema.safeParse(body);
53
+ if (!parsed.success) {
54
+ return { statusCode: 400, receipt: failedReceipt("BAD_WRITEBACK_REQUEST") };
55
+ }
56
+ let baseJob;
57
+ try {
58
+ baseJob = normalizeHandlerJob(parsed.data);
59
+ if (!supportedProtocolVersion(baseJob.protocolVersion)) {
60
+ return { statusCode: 400, receipt: failedReceipt("UNSUPPORTED_PROTOCOL_VERSION") };
61
+ }
62
+ }
63
+ catch {
64
+ return { statusCode: 400, receipt: failedReceipt("BAD_WRITEBACK_REQUEST") };
65
+ }
66
+ const apply = options.capabilities[baseJob.action];
67
+ if (!apply) {
68
+ return { statusCode: 400, receipt: failedReceipt("UNSUPPORTED_ACTION") };
69
+ }
70
+ const receipt = await database.withTransaction(async (tx) => {
71
+ const duplicate = await tx.findReceipt(baseJob.idempotencyKey);
72
+ if (duplicate?.status === "applied" || duplicate?.status === "already_applied") {
73
+ return {
74
+ ...duplicate,
75
+ status: "already_applied",
76
+ rows_affected: 0,
77
+ source_database_mutated: false,
78
+ };
79
+ }
80
+ const row = await tx.lockTarget(baseJob);
81
+ if (!row) {
82
+ const conflict = conflictReceipt("ROW_NOT_FOUND_OR_WRONG_TENANT");
83
+ await tx.recordReceipt(baseJob, conflict);
84
+ return conflict;
85
+ }
86
+ const currentVersion = row[baseJob.expectedVersion.column];
87
+ if (!versionValuesMatch(currentVersion, baseJob.expectedVersion.value)) {
88
+ const conflict = conflictReceipt("ROW_CHANGED_AFTER_PROPOSAL", scalarOrNull(currentVersion));
89
+ await tx.recordReceipt(baseJob, conflict);
90
+ return conflict;
91
+ }
92
+ const job = { ...baseJob, row };
93
+ const effects = await apply(job, tx).catch(() => {
94
+ throw new SafeHandlerError("HANDLER_BUSINESS_ERROR");
95
+ });
96
+ const receipt = appliedReceipt(job, effects);
97
+ await tx.recordReceipt(job, receipt);
98
+ return receipt;
99
+ });
100
+ return { statusCode: receipt.status === "failed" ? 500 : 200, receipt };
101
+ }
102
+ catch (error) {
103
+ return {
104
+ statusCode: 500,
105
+ receipt: failedReceipt(error instanceof SafeHandlerError ? error.code : "HANDLER_EXCEPTION"),
106
+ };
107
+ }
108
+ }
109
+ class SafeHandlerError extends Error {
110
+ code;
111
+ constructor(code) {
112
+ super(code);
113
+ this.code = code;
114
+ }
115
+ }
116
+ function supportedProtocolVersion(value) {
117
+ return value === "1.0" || value === "synapsor.handler-writeback.v1";
118
+ }
119
+ function createConfiguredDatabase(options) {
120
+ if (!options.source)
121
+ throw new Error("createWritebackHandler requires source or database");
122
+ if (options.source.engine !== "postgres")
123
+ throw new Error("Only postgres app-owned handler helper source is implemented in this alpha");
124
+ const env = options.env ?? process.env;
125
+ const writeUrl = env[options.source.writeUrlEnv];
126
+ if (!writeUrl)
127
+ throw new Error(`${options.source.writeUrlEnv} is not set`);
128
+ return new PostgresWritebackHandlerDatabase(writeUrl, options.source.receiptTable);
129
+ }
130
+ export class PostgresWritebackHandlerDatabase {
131
+ pool;
132
+ receiptSchema;
133
+ receiptTable;
134
+ constructor(connectionString, receiptTable) {
135
+ this.pool = new Pool({ connectionString });
136
+ this.receiptSchema = receiptTable?.schema ?? "public";
137
+ this.receiptTable = receiptTable?.table ?? "synapsor_handler_receipts";
138
+ }
139
+ async withTransaction(fn) {
140
+ const client = await this.pool.connect();
141
+ try {
142
+ await client.query("BEGIN");
143
+ const tx = new PostgresWritebackHandlerTransaction(client, this.receiptSchema, this.receiptTable);
144
+ await tx.ensureReceiptTable();
145
+ const result = await fn(tx);
146
+ await client.query("COMMIT");
147
+ return result;
148
+ }
149
+ catch (error) {
150
+ await client.query("ROLLBACK").catch(() => undefined);
151
+ throw error;
152
+ }
153
+ finally {
154
+ client.release();
155
+ }
156
+ }
157
+ async close() {
158
+ await this.pool.end();
159
+ }
160
+ }
161
+ class PostgresWritebackHandlerTransaction {
162
+ client;
163
+ receiptSchema;
164
+ receiptTable;
165
+ constructor(client, receiptSchema, receiptTable) {
166
+ this.client = client;
167
+ this.receiptSchema = receiptSchema;
168
+ this.receiptTable = receiptTable;
169
+ }
170
+ async ensureReceiptTable() {
171
+ await this.client.query(`
172
+ CREATE TABLE IF NOT EXISTS ${this.receiptTableName()} (
173
+ idempotency_key text PRIMARY KEY,
174
+ proposal_id text NOT NULL,
175
+ action text NOT NULL,
176
+ status text NOT NULL,
177
+ receipt_json jsonb NOT NULL,
178
+ created_at timestamptz NOT NULL DEFAULT now(),
179
+ completed_at timestamptz
180
+ )
181
+ `);
182
+ }
183
+ async query(sql, values = []) {
184
+ const result = await this.client.query(sql, values);
185
+ return { rows: result.rows, rowCount: result.rowCount ?? 0 };
186
+ }
187
+ async insert(table, values, options = {}) {
188
+ const entries = Object.entries(values);
189
+ if (!entries.length)
190
+ throw new Error("insert values must not be empty");
191
+ const columns = entries.map(([column]) => quoteIdentifier(column));
192
+ const params = entries.map((_, index) => `$${index + 1}`);
193
+ const returning = options.returning?.length ? ` RETURNING ${options.returning.map(quoteIdentifier).join(", ")}` : "";
194
+ const result = await this.client.query(`INSERT INTO ${qualifiedName(options.schema ?? "public", table)} (${columns.join(", ")}) VALUES (${params.join(", ")})${returning}`, entries.map(([, value]) => value));
195
+ return { rows: result.rows, rowCount: result.rowCount ?? 0 };
196
+ }
197
+ async update(table, where, values, options = {}) {
198
+ const valueEntries = Object.entries(values);
199
+ const whereEntries = Object.entries(where);
200
+ if (!valueEntries.length)
201
+ throw new Error("update values must not be empty");
202
+ if (!whereEntries.length)
203
+ throw new Error("update where must not be empty");
204
+ const params = [];
205
+ const set = valueEntries.map(([column, value]) => {
206
+ params.push(value);
207
+ return `${quoteIdentifier(column)} = $${params.length}`;
208
+ });
209
+ const clauses = whereEntries.map(([column, value]) => {
210
+ params.push(value);
211
+ return `${quoteIdentifier(column)} = $${params.length}`;
212
+ });
213
+ const returning = options.returning?.length ? ` RETURNING ${options.returning.map(quoteIdentifier).join(", ")}` : "";
214
+ const result = await this.client.query(`UPDATE ${qualifiedName(options.schema ?? "public", table)} SET ${set.join(", ")} WHERE ${clauses.join(" AND ")}${returning}`, params);
215
+ return { rows: result.rows, rowCount: result.rowCount ?? 0 };
216
+ }
217
+ async findReceipt(idempotencyKey) {
218
+ const result = await this.client.query(`SELECT receipt_json FROM ${this.receiptTableName()} WHERE idempotency_key = $1 FOR UPDATE`, [idempotencyKey]);
219
+ const raw = result.rows[0]?.receipt_json;
220
+ return isRecord(raw) ? coerceReceipt(raw) : undefined;
221
+ }
222
+ async lockTarget(job) {
223
+ const result = await this.client.query(`SELECT * FROM ${qualifiedName(job.target.schema, job.target.table)}
224
+ WHERE ${quoteIdentifier(job.target.primaryKey.column)} = $1
225
+ AND ${quoteIdentifier(job.tenantGuard.column)} = $2
226
+ FOR UPDATE`, [job.target.primaryKey.value, job.tenantGuard.value]);
227
+ return result.rows[0];
228
+ }
229
+ async recordReceipt(job, receipt) {
230
+ await this.client.query(`INSERT INTO ${this.receiptTableName()} (idempotency_key, proposal_id, action, status, receipt_json, completed_at)
231
+ VALUES ($1, $2, $3, $4, $5::jsonb, now())
232
+ ON CONFLICT (idempotency_key)
233
+ DO UPDATE SET status = EXCLUDED.status, receipt_json = EXCLUDED.receipt_json, completed_at = EXCLUDED.completed_at`, [job.idempotencyKey, job.proposalId, job.action, receipt.status, JSON.stringify(receipt)]);
234
+ }
235
+ receiptTableName() {
236
+ return qualifiedName(this.receiptSchema, this.receiptTable);
237
+ }
238
+ }
239
+ function normalizeHandlerJob(request) {
240
+ const changeSet = isRecord(request.change_set) ? request.change_set : request;
241
+ const source = isRecord(changeSet.source) ? changeSet.source : {};
242
+ const target = isRecord(changeSet.target) ? changeSet.target : source;
243
+ const guards = isRecord(changeSet.guards) ? changeSet.guards : {};
244
+ const scope = isRecord(changeSet.scope) ? changeSet.scope : {};
245
+ const principal = isRecord(changeSet.principal) ? changeSet.principal : {};
246
+ const primaryKey = isRecord(target.primary_key) ? target.primary_key : isRecord(source.primary_key) ? source.primary_key : {};
247
+ const tenantGuard = isRecord(guards.tenant) ? guards.tenant : isRecord(changeSet.tenant_guard) ? changeSet.tenant_guard : {};
248
+ const expectedVersion = isRecord(guards.expected_version) ? guards.expected_version : {};
249
+ const patch = scalarRecord(changeSet.patch);
250
+ const action = stringValue(changeSet.action ?? request.action, "action");
251
+ const schema = safeIdentifierValue(target.schema ?? source.schema, "target.schema");
252
+ const table = safeIdentifierValue(target.table ?? source.table, "target.table");
253
+ const primaryKeyColumn = safeIdentifierValue(primaryKey.column, "target.primary_key.column");
254
+ const primaryKeyValue = scalarValue(primaryKey.value ?? scope.object_id, "target.primary_key.value");
255
+ const tenantColumn = safeIdentifierValue(tenantGuard.column, "guards.tenant.column");
256
+ const tenantValue = scalarValue(tenantGuard.value ?? scope.tenant_id, "guards.tenant.value");
257
+ const expectedColumn = safeIdentifierValue(expectedVersion.column, "guards.expected_version.column");
258
+ const expectedValue = scalarValue(expectedVersion.value, "guards.expected_version.value");
259
+ return {
260
+ protocolVersion: String(request.protocol_version ?? request.schema_version ?? "1.0"),
261
+ proposalId: stringValue(request.proposal_id, "proposal_id"),
262
+ idempotencyKey: stringValue(request.idempotency_key, "idempotency_key"),
263
+ issuedAt: typeof request.issued_at === "string" ? request.issued_at : undefined,
264
+ action,
265
+ tenantId: String(tenantValue),
266
+ objectId: String(primaryKeyValue),
267
+ principal: String(principal.id ?? changeSet.runner_hint ?? "approved_operator"),
268
+ target: {
269
+ schema,
270
+ table,
271
+ primaryKey: { column: primaryKeyColumn, value: primaryKeyValue },
272
+ },
273
+ tenantGuard: { column: tenantColumn, value: tenantValue },
274
+ expectedVersion: { column: expectedColumn, value: expectedValue },
275
+ patch,
276
+ raw: request,
277
+ };
278
+ }
279
+ function verifyRequestAuth(input) {
280
+ const env = input.options.env ?? process.env;
281
+ const tokenEnv = input.options.tokenEnv;
282
+ if (tokenEnv) {
283
+ const expected = env[tokenEnv];
284
+ if (!expected)
285
+ return { ok: false, statusCode: 500, receipt: failedReceipt("HANDLER_TOKEN_MISSING") };
286
+ if (!validBearer(input.headers.authorization, expected)) {
287
+ return { ok: false, statusCode: 401, receipt: failedReceipt("UNAUTHORIZED") };
288
+ }
289
+ }
290
+ const signingSecretEnv = input.options.signingSecretEnv;
291
+ if (signingSecretEnv) {
292
+ const secret = env[signingSecretEnv];
293
+ if (!secret)
294
+ return { ok: false, statusCode: 500, receipt: failedReceipt("HANDLER_SIGNING_SECRET_MISSING") };
295
+ const signature = headerValue(input.headers["x-synapsor-signature"]);
296
+ const issuedAt = headerValue(input.headers["x-synapsor-issued-at"]);
297
+ if (!signature || !validSignature(input.rawBody, secret, signature)) {
298
+ return { ok: false, statusCode: 401, receipt: failedReceipt("INVALID_SIGNATURE") };
299
+ }
300
+ if (!issuedAt || !issuedAtWithinSkew(issuedAt, input.options.issuedAtSkewMs ?? 5 * 60 * 1000)) {
301
+ return { ok: false, statusCode: 401, receipt: failedReceipt("INVALID_ISSUED_AT") };
302
+ }
303
+ }
304
+ return { ok: true };
305
+ }
306
+ function appliedReceipt(job, effects) {
307
+ const details = {
308
+ ...(effects?.details ?? {}),
309
+ ...(effects?.effects ? { effects: effects.effects } : {}),
310
+ };
311
+ return {
312
+ status: "applied",
313
+ rows_affected: Math.max(1, Number(effects?.rowsAffected ?? effects?.effects?.length ?? 1)),
314
+ previous_version: scalarOrNull(job.row[job.expectedVersion.column]),
315
+ new_version: effects?.newVersion ?? scalarOrNull(job.row[job.expectedVersion.column]),
316
+ source_database_mutated: true,
317
+ safe_error_code: null,
318
+ ...(Object.keys(details).length ? { details } : {}),
319
+ };
320
+ }
321
+ function conflictReceipt(code, previousVersion) {
322
+ return {
323
+ status: "conflict",
324
+ rows_affected: 0,
325
+ source_database_mutated: false,
326
+ safe_error_code: code,
327
+ ...(previousVersion !== undefined ? { previous_version: previousVersion } : {}),
328
+ };
329
+ }
330
+ function failedReceipt(code) {
331
+ return {
332
+ status: "failed",
333
+ rows_affected: 0,
334
+ source_database_mutated: false,
335
+ safe_error_code: code,
336
+ };
337
+ }
338
+ function coerceReceipt(value) {
339
+ const parsed = appHandlerReceiptV1Schema.safeParse(value);
340
+ return parsed.success ? parsed.data : undefined;
341
+ }
342
+ function validBearer(header, expected) {
343
+ const value = headerValue(header);
344
+ if (!value?.startsWith("Bearer "))
345
+ return false;
346
+ const actual = Buffer.from(value.slice("Bearer ".length));
347
+ const wanted = Buffer.from(expected);
348
+ return actual.length === wanted.length && crypto.timingSafeEqual(actual, wanted);
349
+ }
350
+ export function signHandlerRequest(rawBody, secret) {
351
+ return `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
352
+ }
353
+ function validSignature(rawBody, secret, signature) {
354
+ const expected = Buffer.from(signHandlerRequest(rawBody, secret));
355
+ const actual = Buffer.from(signature);
356
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
357
+ }
358
+ function issuedAtWithinSkew(value, skewMs) {
359
+ const time = new Date(value).getTime();
360
+ if (Number.isNaN(time))
361
+ return false;
362
+ return Math.abs(Date.now() - time) <= skewMs;
363
+ }
364
+ function headerValue(value) {
365
+ return Array.isArray(value) ? value[0] : value;
366
+ }
367
+ async function readRawBody(request) {
368
+ const chunks = [];
369
+ let bytes = 0;
370
+ for await (const chunk of request) {
371
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
372
+ bytes += buffer.length;
373
+ if (bytes > 1024 * 1024)
374
+ throw new Error("handler body exceeds 1 MiB");
375
+ chunks.push(buffer);
376
+ }
377
+ return Buffer.concat(chunks).toString("utf8");
378
+ }
379
+ function scalarRecord(input) {
380
+ if (!isRecord(input))
381
+ throw new Error("patch must be an object");
382
+ const result = {};
383
+ for (const [key, value] of Object.entries(input)) {
384
+ if (!safeIdentifier.test(key))
385
+ throw new Error(`unsafe patch column: ${key}`);
386
+ result[key] = scalarValue(value, `patch.${key}`);
387
+ }
388
+ if (!Object.keys(result).length)
389
+ throw new Error("patch must not be empty");
390
+ return result;
391
+ }
392
+ function scalarValue(value, name) {
393
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
394
+ return value;
395
+ throw new Error(`${name} must be a scalar`);
396
+ }
397
+ function scalarOrNull(value) {
398
+ if (value instanceof Date)
399
+ return value.toISOString();
400
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
401
+ return value;
402
+ if (value === undefined)
403
+ return null;
404
+ return String(value);
405
+ }
406
+ function stringValue(value, name) {
407
+ if (typeof value === "string" && value.length > 0)
408
+ return value;
409
+ throw new Error(`${name} must be a non-empty string`);
410
+ }
411
+ function safeIdentifierValue(value, name) {
412
+ const text = stringValue(value, name);
413
+ if (!safeIdentifier.test(text))
414
+ throw new Error(`${name} must be a safe identifier`);
415
+ return text;
416
+ }
417
+ function versionValuesMatch(actual, expected) {
418
+ if (actual instanceof Date)
419
+ return versionValuesMatch(actual.toISOString(), expected);
420
+ const actualDate = new Date(String(actual));
421
+ const expectedDate = new Date(String(expected));
422
+ if (!Number.isNaN(actualDate.getTime()) && !Number.isNaN(expectedDate.getTime())) {
423
+ return actualDate.getTime() === expectedDate.getTime();
424
+ }
425
+ return String(actual) === String(expected);
426
+ }
427
+ function quoteIdentifier(identifier) {
428
+ if (!safeIdentifier.test(identifier))
429
+ throw new Error(`unsafe postgres identifier: ${identifier}`);
430
+ return `"${identifier}"`;
431
+ }
432
+ function qualifiedName(schema, table) {
433
+ return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
434
+ }
435
+ function isRecord(value) {
436
+ return typeof value === "object" && value !== null && !Array.isArray(value);
437
+ }
@@ -0,0 +1,158 @@
1
+ {
2
+ "version": 1,
3
+ "mode": "review",
4
+ "storage": {
5
+ "sqlite_path": "./tmp/billing-app-handler/local.db"
6
+ },
7
+ "sources": {
8
+ "billing_postgres": {
9
+ "engine": "postgres",
10
+ "read_url_env": "BILLING_APP_READ_URL",
11
+ "write_url_env": "BILLING_APP_WRITE_URL",
12
+ "statement_timeout_ms": 3000
13
+ }
14
+ },
15
+ "trusted_context": {
16
+ "provider": "environment",
17
+ "values": {
18
+ "tenant_id_env": "SYNAPSOR_TENANT_ID",
19
+ "principal_env": "SYNAPSOR_PRINCIPAL"
20
+ }
21
+ },
22
+ "contexts": {
23
+ "local_operator": {
24
+ "provider": "environment",
25
+ "values": {
26
+ "tenant_id_env": "SYNAPSOR_TENANT_ID",
27
+ "principal_env": "SYNAPSOR_PRINCIPAL"
28
+ }
29
+ }
30
+ },
31
+ "executors": {
32
+ "billing_app_handler": {
33
+ "type": "http_handler",
34
+ "url_env": "BILLING_APP_HANDLER_URL",
35
+ "method": "POST",
36
+ "auth": {
37
+ "type": "bearer_env",
38
+ "token_env": "BILLING_APP_HANDLER_TOKEN"
39
+ },
40
+ "signing_secret_env": "BILLING_APP_HANDLER_SIGNING_SECRET",
41
+ "timeout_ms": 5000
42
+ }
43
+ },
44
+ "capabilities": [
45
+ {
46
+ "name": "billing.inspect_invoice",
47
+ "kind": "read",
48
+ "source": "billing_postgres",
49
+ "context": "local_operator",
50
+ "target": {
51
+ "schema": "public",
52
+ "table": "invoices",
53
+ "primary_key": "id",
54
+ "tenant_key": "tenant_id"
55
+ },
56
+ "args": {
57
+ "invoice_id": { "type": "string", "required": true, "max_length": 128 }
58
+ },
59
+ "lookup": { "id_from_arg": "invoice_id" },
60
+ "visible_columns": [
61
+ "id",
62
+ "tenant_id",
63
+ "customer_id",
64
+ "status",
65
+ "balance_cents",
66
+ "late_fee_cents",
67
+ "waiver_reason",
68
+ "credit_requested_cents",
69
+ "credit_reason",
70
+ "credited_cents",
71
+ "updated_at"
72
+ ],
73
+ "evidence": "required",
74
+ "max_rows": 1
75
+ },
76
+ {
77
+ "name": "billing.propose_late_fee_waiver",
78
+ "kind": "proposal",
79
+ "source": "billing_postgres",
80
+ "context": "local_operator",
81
+ "target": {
82
+ "schema": "public",
83
+ "table": "invoices",
84
+ "primary_key": "id",
85
+ "tenant_key": "tenant_id"
86
+ },
87
+ "args": {
88
+ "invoice_id": { "type": "string", "required": true, "max_length": 128 },
89
+ "reason": { "type": "string", "required": true, "max_length": 500 }
90
+ },
91
+ "lookup": { "id_from_arg": "invoice_id" },
92
+ "visible_columns": [
93
+ "id",
94
+ "tenant_id",
95
+ "customer_id",
96
+ "status",
97
+ "balance_cents",
98
+ "late_fee_cents",
99
+ "waiver_reason",
100
+ "updated_at"
101
+ ],
102
+ "evidence": "required",
103
+ "max_rows": 1,
104
+ "patch": {
105
+ "late_fee_cents": { "fixed": 0 },
106
+ "waiver_reason": { "from_arg": "reason" }
107
+ },
108
+ "allowed_columns": ["late_fee_cents", "waiver_reason"],
109
+ "numeric_bounds": {
110
+ "late_fee_cents": { "minimum": 0, "maximum": 10000 }
111
+ },
112
+ "conflict_guard": { "column": "updated_at" },
113
+ "approval": { "mode": "human", "required_role": "billing_lead" }
114
+ },
115
+ {
116
+ "name": "billing.propose_account_credit",
117
+ "kind": "proposal",
118
+ "source": "billing_postgres",
119
+ "context": "local_operator",
120
+ "executor": "billing_app_handler",
121
+ "target": {
122
+ "schema": "public",
123
+ "table": "invoices",
124
+ "primary_key": "id",
125
+ "tenant_key": "tenant_id"
126
+ },
127
+ "args": {
128
+ "invoice_id": { "type": "string", "required": true, "max_length": 128 },
129
+ "amount_cents": { "type": "number", "required": true, "minimum": 1, "maximum": 10000 },
130
+ "reason": { "type": "string", "required": true, "max_length": 500 }
131
+ },
132
+ "lookup": { "id_from_arg": "invoice_id" },
133
+ "visible_columns": [
134
+ "id",
135
+ "tenant_id",
136
+ "customer_id",
137
+ "status",
138
+ "balance_cents",
139
+ "credited_cents",
140
+ "credit_requested_cents",
141
+ "credit_reason",
142
+ "updated_at"
143
+ ],
144
+ "evidence": "required",
145
+ "max_rows": 1,
146
+ "patch": {
147
+ "credit_requested_cents": { "from_arg": "amount_cents" },
148
+ "credit_reason": { "from_arg": "reason" }
149
+ },
150
+ "allowed_columns": ["credit_requested_cents", "credit_reason"],
151
+ "numeric_bounds": {
152
+ "credit_requested_cents": { "minimum": 1, "maximum": 10000 }
153
+ },
154
+ "conflict_guard": { "column": "updated_at" },
155
+ "approval": { "mode": "human", "required_role": "billing_lead" }
156
+ }
157
+ ]
158
+ }
@@ -12,6 +12,11 @@ spec-compatible HTTP MCP transport with `initialize` and session behavior. Use
12
12
  `synapsor-runner mcp serve-http` only when you intentionally want the smaller
13
13
  JSON-RPC bridge and an app-owned wrapper.
14
14
 
15
+ OpenAI function names cannot contain dots, so start Runner with
16
+ `--alias-mode openai`. The model sees aliases such as
17
+ `billing__inspect_invoice`; Runner keeps `billing.inspect_invoice` in MCP
18
+ metadata and maps calls back to the canonical Synapsor capability.
19
+
15
20
  The model still sees a semantic action. It does not receive raw SQL, database
16
21
  URLs, write credentials, approval tools, or commit tools.
17
22
 
@@ -26,7 +31,8 @@ export SYNAPSOR_RUNNER_HTTP_TOKEN="dev-token"
26
31
  npx -y -p @synapsor/runner@alpha synapsor-runner mcp serve-streamable-http \
27
32
  --config ./synapsor.runner.json \
28
33
  --store ./.synapsor/local.db \
29
- --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
34
+ --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN \
35
+ --alias-mode openai
30
36
  ```
31
37
 
32
38
  ## Terminal 2: Run The Agent
@@ -46,7 +52,9 @@ python agent.py
46
52
 
47
53
  Expected behavior:
48
54
 
49
- - the agent calls `billing.inspect_invoice` through Synapsor HTTP MCP;
55
+ - the agent calls the OpenAI-safe alias `billing__inspect_invoice` through
56
+ Synapsor HTTP MCP;
57
+ - Runner maps that alias back to canonical `billing.inspect_invoice`;
50
58
  - Synapsor applies trusted tenant/principal context from the server process;
51
59
  - the response includes scoped data and evidence handles;
52
60
  - no SQL/write/approval tool is exposed to the model;
@@ -4,7 +4,10 @@ This example shows an OpenAI Agents SDK app launching Synapsor Runner as a
4
4
  local stdio MCP server.
5
5
 
6
6
  Use stdio when the agent process can start the MCP server on the same machine.
7
- The model sees Synapsor semantic tools such as `billing.inspect_invoice`. It
7
+ The model sees Synapsor semantic tools through OpenAI-safe aliases such as
8
+ `billing__inspect_invoice`. Runner keeps the canonical Synapsor capability
9
+ name, such as `billing.inspect_invoice`, in MCP metadata and maps calls back to
10
+ it. The model
8
11
  does not receive raw SQL, database URLs, write credentials, approval tools, or
9
12
  commit tools.
10
13
 
@@ -52,11 +55,12 @@ export SYNAPSOR_INVOICE_ID="INV-3001"
52
55
 
53
56
  Expected behavior:
54
57
 
55
- - the agent can inspect the scoped invoice through Synapsor;
58
+ - the agent can inspect the scoped invoice through Synapsor using an
59
+ OpenAI-safe tool alias;
60
+ - Runner maps the alias back to the canonical Synapsor capability;
56
61
  - the agent cannot run SQL;
57
62
  - the agent cannot approve or commit writes;
58
63
  - evidence/query audit are saved in the local Runner store.
59
64
 
60
65
  If your installed OpenAI Agents SDK does not expose `MCPServerStdio`, update the
61
- SDK or use the HTTP example, which wraps Synapsor HTTP MCP with a small JSON-RPC
62
- client.
66
+ SDK or use the Streamable HTTP example.
@@ -35,6 +35,8 @@ async def main() -> None:
35
35
  config_path,
36
36
  "--store",
37
37
  store_path,
38
+ "--alias-mode",
39
+ "openai",
38
40
  ],
39
41
  "env": {
40
42
  **os.environ,