@synapsor/runner 0.1.0-alpha.8 → 0.1.0

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 (67) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +391 -25
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/runner.mjs +2945 -193
  6. package/docs/README.md +40 -0
  7. package/docs/app-owned-executors.md +38 -0
  8. package/docs/capability-authoring.md +265 -0
  9. package/docs/cloud-mode.md +24 -0
  10. package/docs/current-scope.md +29 -0
  11. package/docs/dependency-license-inventory.md +35 -0
  12. package/docs/doctor.md +98 -0
  13. package/docs/getting-started-own-database.md +131 -46
  14. package/docs/handler-helper.md +228 -0
  15. package/docs/http-mcp.md +85 -17
  16. package/docs/licensing.md +36 -0
  17. package/docs/local-mode.md +44 -25
  18. package/docs/mcp-audit.md +8 -8
  19. package/docs/mcp-client-setup.md +59 -21
  20. package/docs/openai-agents-sdk.md +57 -0
  21. package/docs/recipes.md +6 -6
  22. package/docs/release-notes.md +327 -0
  23. package/docs/release-policy.md +125 -0
  24. package/docs/result-envelope-v2.md +151 -0
  25. package/docs/rfcs/001-result-envelope-v2.md +143 -0
  26. package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
  27. package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
  28. package/docs/store-lifecycle.md +83 -0
  29. package/docs/troubleshooting-first-run.md +6 -6
  30. package/docs/use-your-own-database.md +18 -0
  31. package/docs/writeback-executors.md +92 -1
  32. package/examples/app-owned-writeback/README.md +128 -0
  33. package/examples/app-owned-writeback/business-actions.md +221 -0
  34. package/examples/app-owned-writeback/command-handler.mjs +55 -0
  35. package/examples/app-owned-writeback/node-fastify-handler.mjs +64 -0
  36. package/examples/app-owned-writeback/python-fastapi-handler.py +66 -0
  37. package/examples/mcp-postgres-billing-app-handler/README.md +94 -0
  38. package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +123 -0
  39. package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
  40. package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
  41. package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
  42. package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
  43. package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
  44. package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
  45. package/examples/openai-agents-http/README.md +19 -12
  46. package/examples/openai-agents-http/agent.py +29 -65
  47. package/examples/openai-agents-stdio/README.md +10 -6
  48. package/examples/openai-agents-stdio/agent.py +4 -2
  49. package/examples/reference-support-billing-app/README.md +16 -16
  50. package/examples/reference-support-billing-app/mcp-client.generic.json +1 -1
  51. package/fixtures/benchmark/mcp-efficiency.json +53 -0
  52. package/fixtures/benchmark/mcp-efficiency.txt +25 -0
  53. package/fixtures/protocol/MANIFEST.json +54 -0
  54. package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
  55. package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
  56. package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
  57. package/fixtures/protocol/runner-registration.v1.json +22 -0
  58. package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
  59. package/package.json +6 -1
  60. package/schemas/change-set.v1.schema.json +140 -0
  61. package/schemas/execution-receipt.v1.schema.json +34 -0
  62. package/schemas/onboarding-selection.v1.schema.json +132 -0
  63. package/schemas/runner-registration.v1.schema.json +48 -0
  64. package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
  65. package/schemas/synapsor.app-handler-request.v1.json +119 -0
  66. package/schemas/synapsor.runner.schema.json +415 -0
  67. 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
+ }
@@ -1,17 +1,21 @@
1
- # OpenAI Agents SDK + Synapsor Runner over HTTP
1
+ # OpenAI Agents SDK + Synapsor Runner Streamable HTTP MCP
2
2
 
3
- This example shows an OpenAI Agents SDK app calling a long-running Synapsor
4
- Runner HTTP MCP service.
3
+ This example shows an OpenAI Agents SDK app connecting to a long-running
4
+ Synapsor Runner Streamable HTTP MCP server.
5
5
 
6
6
  Use HTTP when your agent runs as an app/server and should connect to Runner
7
7
  over a local/private network endpoint instead of launching a stdio child
8
8
  process.
9
9
 
10
- Runner's HTTP alpha endpoint is a small authenticated JSON-RPC surface for
11
- `tools/list`, `tools/call`, and `resources/read`. It is not the full MCP
12
- Streamable HTTP transport and does not implement `initialize`/SSE. This example
13
- therefore uses the OpenAI Agents SDK for the agent and wraps Synapsor's HTTP
14
- JSON-RPC endpoint as an OpenAI function tool.
10
+ This example uses `synapsor-runner mcp serve-streamable-http`, the
11
+ spec-compatible HTTP MCP transport with `initialize` and session behavior. Use
12
+ `synapsor-runner mcp serve-http` only when you intentionally want the smaller
13
+ JSON-RPC bridge and an app-owned wrapper.
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.
15
19
 
16
20
  The model still sees a semantic action. It does not receive raw SQL, database
17
21
  URLs, write credentials, approval tools, or commit tools.
@@ -24,10 +28,11 @@ export SYNAPSOR_TENANT_ID="acme"
24
28
  export SYNAPSOR_PRINCIPAL="openai_agent_demo"
25
29
  export SYNAPSOR_RUNNER_HTTP_TOKEN="dev-token"
26
30
 
27
- npx -y -p @synapsor/runner@alpha synapsor-runner mcp serve-http \
31
+ npx -y -p @synapsor/runner synapsor-runner mcp serve-streamable-http \
28
32
  --config ./synapsor.runner.json \
29
33
  --store ./.synapsor/local.db \
30
- --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
34
+ --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN \
35
+ --alias-mode openai
31
36
  ```
32
37
 
33
38
  ## Terminal 2: Run The Agent
@@ -38,7 +43,7 @@ python -m venv .venv
38
43
  pip install -r requirements.txt
39
44
 
40
45
  export OPENAI_API_KEY="..."
41
- export SYNAPSOR_RUNNER_HTTP_URL="http://127.0.0.1:8765/mcp"
46
+ export SYNAPSOR_RUNNER_HTTP_URL="http://127.0.0.1:8766/mcp"
42
47
  export SYNAPSOR_RUNNER_HTTP_TOKEN="dev-token"
43
48
  export SYNAPSOR_INVOICE_ID="INV-3001"
44
49
 
@@ -47,7 +52,9 @@ python agent.py
47
52
 
48
53
  Expected behavior:
49
54
 
50
- - 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`;
51
58
  - Synapsor applies trusted tenant/principal context from the server process;
52
59
  - the response includes scoped data and evidence handles;
53
60
  - no SQL/write/approval tool is exposed to the model;