@synapsor/runner 0.1.0-alpha.1 → 0.1.0-alpha.11
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/README.md +426 -19
- package/TRADEMARKS.md +23 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +20 -8723
- package/dist/runner.mjs +12958 -0
- package/docs/README.md +53 -0
- package/docs/app-owned-executors.md +21 -0
- package/docs/cloud-mode.md +24 -0
- package/docs/current-scope.md +24 -0
- package/docs/dependency-license-inventory.md +35 -0
- package/docs/getting-started-own-database.md +460 -0
- package/docs/http-mcp.md +276 -0
- package/docs/licensing.md +36 -0
- package/docs/limitations.md +95 -0
- package/docs/local-mode.md +351 -0
- package/docs/mcp-audit.md +152 -0
- package/docs/mcp-client-setup.md +270 -0
- package/docs/openai-agents-sdk.md +57 -0
- package/docs/recipes.md +61 -0
- package/docs/release-notes.md +158 -0
- package/docs/security-boundary.md +94 -0
- package/docs/troubleshooting-first-run.md +248 -0
- package/docs/use-your-own-database.md +18 -0
- package/docs/writeback-executors.md +220 -0
- package/examples/app-owned-writeback/README.md +120 -0
- package/examples/app-owned-writeback/business-actions.md +221 -0
- package/examples/app-owned-writeback/command-handler.mjs +46 -0
- package/examples/app-owned-writeback/node-fastify-handler.mjs +55 -0
- package/examples/app-owned-writeback/python-fastapi-handler.py +57 -0
- package/examples/dangerous-mcp-tools.json +88 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +82 -0
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +197 -0
- package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
- package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +99 -0
- package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +157 -0
- package/examples/openai-agents-http/README.md +64 -0
- package/examples/openai-agents-http/agent.py +54 -0
- package/examples/openai-agents-http/requirements.txt +1 -0
- package/examples/openai-agents-stdio/README.md +66 -0
- package/examples/openai-agents-stdio/agent.py +72 -0
- package/examples/openai-agents-stdio/requirements.txt +1 -0
- package/examples/reference-support-billing-app/README.md +137 -0
- package/examples/reference-support-billing-app/docker-compose.yml +13 -0
- package/examples/reference-support-billing-app/mcp-client.generic.json +11 -0
- package/examples/reference-support-billing-app/schema.sql +68 -0
- package/examples/reference-support-billing-app/scripts/run-demo.sh +7 -0
- package/examples/reference-support-billing-app/seed.sql +33 -0
- package/examples/reference-support-billing-app/synapsor.runner.json +241 -0
- package/fixtures/benchmark/mcp-efficiency.json +53 -0
- package/fixtures/benchmark/mcp-efficiency.txt +25 -0
- package/fixtures/protocol/MANIFEST.json +54 -0
- package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
- package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
- package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
- package/fixtures/protocol/runner-registration.v1.json +22 -0
- package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
- package/package.json +14 -4
- package/recipes/accounts.trial_extension.json +42 -0
- package/recipes/billing.late_fee_waiver.json +46 -0
- package/recipes/credits.account_credit.json +45 -0
- package/recipes/orders.refund_review.json +57 -0
- package/recipes/support.ticket_resolution.json +51 -0
- package/dist/bin.cjs +0 -13
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
const { Pool } = await loadPg();
|
|
6
|
+
|
|
7
|
+
const port = Number(process.env.BILLING_APP_HANDLER_PORT || "8787");
|
|
8
|
+
const expectedToken = process.env.BILLING_APP_HANDLER_TOKEN || "dev-handler-token";
|
|
9
|
+
const databaseUrl = process.env.BILLING_APP_WRITE_URL;
|
|
10
|
+
|
|
11
|
+
if (!databaseUrl) {
|
|
12
|
+
console.error("BILLING_APP_WRITE_URL is required.");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pool = new Pool({ connectionString: databaseUrl });
|
|
17
|
+
|
|
18
|
+
const server = http.createServer(async (request, response) => {
|
|
19
|
+
try {
|
|
20
|
+
if (request.method === "GET" && request.url === "/healthz") {
|
|
21
|
+
return writeJson(response, 200, { ok: true });
|
|
22
|
+
}
|
|
23
|
+
if (request.method !== "POST" || request.url !== "/synapsor/writeback") {
|
|
24
|
+
return writeJson(response, 404, { status: "failed", safe_error_code: "NOT_FOUND", source_database_mutated: false });
|
|
25
|
+
}
|
|
26
|
+
if (request.headers.authorization !== `Bearer ${expectedToken}`) {
|
|
27
|
+
return writeJson(response, 401, { status: "failed", safe_error_code: "UNAUTHORIZED", source_database_mutated: false });
|
|
28
|
+
}
|
|
29
|
+
const body = await readJson(request);
|
|
30
|
+
const receipt = await applyAccountCredit(body);
|
|
31
|
+
return writeJson(response, 200, receipt);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return writeJson(response, 500, {
|
|
35
|
+
status: "failed",
|
|
36
|
+
safe_error_code: "HANDLER_EXCEPTION",
|
|
37
|
+
source_database_mutated: false,
|
|
38
|
+
details: { message: message.slice(0, 300) },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
server.listen(port, "127.0.0.1", () => {
|
|
44
|
+
console.error(`Billing app handler listening on http://127.0.0.1:${port}/synapsor/writeback`);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
process.once("SIGINT", shutdown);
|
|
48
|
+
process.once("SIGTERM", shutdown);
|
|
49
|
+
|
|
50
|
+
async function shutdown() {
|
|
51
|
+
server.close();
|
|
52
|
+
await pool.end();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function applyAccountCredit(request) {
|
|
57
|
+
const changeSet = request.change_set || request;
|
|
58
|
+
if (changeSet.action !== "billing.propose_account_credit") {
|
|
59
|
+
return {
|
|
60
|
+
status: "failed",
|
|
61
|
+
safe_error_code: "UNSUPPORTED_ACTION",
|
|
62
|
+
source_database_mutated: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const proposalId = String(request.proposal_id || "");
|
|
67
|
+
const idempotencyKey = String(request.idempotency_key || proposalId);
|
|
68
|
+
const tenantId = String(changeSet.scope?.tenant_id || changeSet.tenant_guard?.value || changeSet.guards?.tenant?.value || "");
|
|
69
|
+
const principal = String(changeSet.principal?.id || changeSet.runner_hint || "approved_operator");
|
|
70
|
+
const invoiceId = String(changeSet.scope?.object_id || changeSet.target?.primary_key?.value || changeSet.source?.primary_key?.value || "");
|
|
71
|
+
const amountCents = Number(changeSet.patch?.credit_requested_cents);
|
|
72
|
+
const reason = String(changeSet.patch?.credit_reason || "approved account credit");
|
|
73
|
+
const expectedVersion = changeSet.guards?.expected_version?.value;
|
|
74
|
+
|
|
75
|
+
if (!proposalId || !tenantId || !invoiceId || !Number.isInteger(amountCents) || amountCents <= 0 || !expectedVersion) {
|
|
76
|
+
return {
|
|
77
|
+
status: "failed",
|
|
78
|
+
safe_error_code: "BAD_WRITEBACK_REQUEST",
|
|
79
|
+
source_database_mutated: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const client = await pool.connect();
|
|
84
|
+
try {
|
|
85
|
+
await client.query("BEGIN");
|
|
86
|
+
|
|
87
|
+
const duplicate = await client.query(
|
|
88
|
+
"SELECT id FROM public.account_credits WHERE idempotency_key = $1",
|
|
89
|
+
[idempotencyKey],
|
|
90
|
+
);
|
|
91
|
+
if (duplicate.rowCount && duplicate.rows[0]) {
|
|
92
|
+
await client.query("COMMIT");
|
|
93
|
+
return {
|
|
94
|
+
status: "already_applied",
|
|
95
|
+
rows_affected: 0,
|
|
96
|
+
source_database_mutated: false,
|
|
97
|
+
details: {
|
|
98
|
+
credit_id: duplicate.rows[0].id,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const invoiceResult = await client.query(
|
|
104
|
+
"SELECT id, tenant_id, customer_id, updated_at FROM public.invoices WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
|
|
105
|
+
[invoiceId, tenantId],
|
|
106
|
+
);
|
|
107
|
+
const invoice = invoiceResult.rows[0];
|
|
108
|
+
if (!invoice) {
|
|
109
|
+
await client.query("ROLLBACK");
|
|
110
|
+
return {
|
|
111
|
+
status: "conflict",
|
|
112
|
+
rows_affected: 0,
|
|
113
|
+
safe_error_code: "INVOICE_NOT_FOUND_OR_WRONG_TENANT",
|
|
114
|
+
source_database_mutated: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const currentVersion = timestampString(invoice.updated_at);
|
|
119
|
+
const expected = timestampString(expectedVersion);
|
|
120
|
+
if (currentVersion !== expected) {
|
|
121
|
+
await client.query("ROLLBACK");
|
|
122
|
+
return {
|
|
123
|
+
status: "conflict",
|
|
124
|
+
rows_affected: 0,
|
|
125
|
+
previous_version: currentVersion,
|
|
126
|
+
safe_error_code: "ROW_CHANGED_AFTER_PROPOSAL",
|
|
127
|
+
source_database_mutated: false,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const creditId = `CR-${proposalId.replace(/[^A-Za-z0-9]/g, "").slice(-12) || Date.now()}`;
|
|
132
|
+
await client.query(
|
|
133
|
+
`INSERT INTO public.account_credits
|
|
134
|
+
(id, tenant_id, invoice_id, customer_id, amount_cents, reason, idempotency_key, created_by)
|
|
135
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
136
|
+
[creditId, tenantId, invoiceId, invoice.customer_id, amountCents, reason, idempotencyKey, principal],
|
|
137
|
+
);
|
|
138
|
+
const update = await client.query(
|
|
139
|
+
`UPDATE public.invoices
|
|
140
|
+
SET credit_requested_cents = $1,
|
|
141
|
+
credit_reason = $2,
|
|
142
|
+
credited_cents = credited_cents + $1,
|
|
143
|
+
updated_at = now()
|
|
144
|
+
WHERE id = $3 AND tenant_id = $4
|
|
145
|
+
RETURNING updated_at`,
|
|
146
|
+
[amountCents, reason, invoiceId, tenantId],
|
|
147
|
+
);
|
|
148
|
+
await client.query("COMMIT");
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
status: "applied",
|
|
152
|
+
rows_affected: 2,
|
|
153
|
+
previous_version: currentVersion,
|
|
154
|
+
new_version: timestampString(update.rows[0]?.updated_at),
|
|
155
|
+
source_database_mutated: true,
|
|
156
|
+
details: {
|
|
157
|
+
effects: [
|
|
158
|
+
{ type: "db.insert", table: "account_credits", id: creditId },
|
|
159
|
+
{ type: "db.update", table: "invoices", id: invoiceId },
|
|
160
|
+
{ type: "event", name: "billing.account_credit_created" },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
166
|
+
throw error;
|
|
167
|
+
} finally {
|
|
168
|
+
client.release();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function timestampString(value) {
|
|
173
|
+
if (value instanceof Date) return value.toISOString().replace(".000Z", "Z");
|
|
174
|
+
return new Date(String(value)).toISOString().replace(".000Z", "Z");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readJson(request) {
|
|
178
|
+
const chunks = [];
|
|
179
|
+
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
180
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
181
|
+
return text ? JSON.parse(text) : {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function writeJson(response, statusCode, body) {
|
|
185
|
+
response.statusCode = statusCode;
|
|
186
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
187
|
+
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function loadPg() {
|
|
191
|
+
try {
|
|
192
|
+
return await import("pg");
|
|
193
|
+
} catch {
|
|
194
|
+
const requireFromRunnerPackage = createRequire(new URL("../../apps/runner/package.json", import.meta.url));
|
|
195
|
+
return requireFromRunnerPackage("pg");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16
|
|
4
|
+
container_name: synapsor_runner_billing_app_handler
|
|
5
|
+
environment:
|
|
6
|
+
POSTGRES_DB: synapsor_billing_app_handler
|
|
7
|
+
POSTGRES_USER: synapsor_admin
|
|
8
|
+
POSTGRES_PASSWORD: synapsor_admin_password
|
|
9
|
+
ports:
|
|
10
|
+
- "55437:5432"
|
|
11
|
+
volumes:
|
|
12
|
+
- ./schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
|
|
13
|
+
- ./seed.sql:/docker-entrypoint-initdb.d/002_seed.sql:ro
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS public.tenants (
|
|
2
|
+
id text PRIMARY KEY,
|
|
3
|
+
name text NOT NULL,
|
|
4
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
5
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
CREATE TABLE IF NOT EXISTS public.customers (
|
|
9
|
+
id text PRIMARY KEY,
|
|
10
|
+
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
11
|
+
name text NOT NULL,
|
|
12
|
+
plan text NOT NULL,
|
|
13
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
14
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS public.invoices (
|
|
18
|
+
id text PRIMARY KEY,
|
|
19
|
+
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
20
|
+
customer_id text NOT NULL REFERENCES public.customers(id),
|
|
21
|
+
status text NOT NULL,
|
|
22
|
+
balance_cents integer NOT NULL,
|
|
23
|
+
late_fee_cents integer NOT NULL,
|
|
24
|
+
waiver_reason text,
|
|
25
|
+
credit_requested_cents integer NOT NULL DEFAULT 0,
|
|
26
|
+
credit_reason text,
|
|
27
|
+
credited_cents integer NOT NULL DEFAULT 0,
|
|
28
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS public.account_credits (
|
|
32
|
+
id text PRIMARY KEY,
|
|
33
|
+
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
34
|
+
invoice_id text NOT NULL REFERENCES public.invoices(id),
|
|
35
|
+
customer_id text NOT NULL REFERENCES public.customers(id),
|
|
36
|
+
amount_cents integer NOT NULL CHECK (amount_cents > 0),
|
|
37
|
+
reason text NOT NULL,
|
|
38
|
+
idempotency_key text NOT NULL UNIQUE,
|
|
39
|
+
created_by text NOT NULL,
|
|
40
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
DO $$
|
|
44
|
+
BEGIN
|
|
45
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synapsor_reader') THEN
|
|
46
|
+
CREATE ROLE synapsor_reader LOGIN PASSWORD 'synapsor_reader_password';
|
|
47
|
+
END IF;
|
|
48
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synapsor_writer') THEN
|
|
49
|
+
CREATE ROLE synapsor_writer LOGIN PASSWORD 'synapsor_writer_password';
|
|
50
|
+
END IF;
|
|
51
|
+
END
|
|
52
|
+
$$;
|
|
53
|
+
|
|
54
|
+
GRANT CONNECT ON DATABASE synapsor_billing_app_handler TO synapsor_reader, synapsor_writer;
|
|
55
|
+
GRANT USAGE ON SCHEMA public TO synapsor_reader, synapsor_writer;
|
|
56
|
+
GRANT CREATE ON SCHEMA public TO synapsor_writer;
|
|
57
|
+
GRANT SELECT ON public.tenants, public.customers, public.invoices, public.account_credits TO synapsor_reader, synapsor_writer;
|
|
58
|
+
GRANT UPDATE (late_fee_cents, waiver_reason, credit_requested_cents, credit_reason, credited_cents, updated_at) ON public.invoices TO synapsor_writer;
|
|
59
|
+
GRANT INSERT ON public.account_credits TO synapsor_writer;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
|
5
|
+
EXAMPLE_DIR="$ROOT/examples/mcp-postgres-billing-app-handler"
|
|
6
|
+
COMPOSE_FILE="$EXAMPLE_DIR/docker-compose.yml"
|
|
7
|
+
CONFIG="$EXAMPLE_DIR/synapsor.runner.json"
|
|
8
|
+
STORE="$ROOT/tmp/billing-app-handler/local.db"
|
|
9
|
+
BIN="${SYNAPSOR_RUNNER_BIN:-$ROOT/bin/synapsor-runner}"
|
|
10
|
+
HANDLER_LOG="$ROOT/tmp/billing-app-handler/handler.log"
|
|
11
|
+
|
|
12
|
+
cleanup() {
|
|
13
|
+
if [[ -n "${HANDLER_PID:-}" ]]; then
|
|
14
|
+
kill "$HANDLER_PID" >/dev/null 2>&1 || true
|
|
15
|
+
wait "$HANDLER_PID" >/dev/null 2>&1 || true
|
|
16
|
+
fi
|
|
17
|
+
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
|
18
|
+
}
|
|
19
|
+
trap cleanup EXIT
|
|
20
|
+
|
|
21
|
+
mkdir -p "$ROOT/tmp/billing-app-handler"
|
|
22
|
+
rm -f "$STORE" "$HANDLER_LOG"
|
|
23
|
+
|
|
24
|
+
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
|
25
|
+
docker compose -f "$COMPOSE_FILE" up -d >/dev/null
|
|
26
|
+
|
|
27
|
+
for _ in $(seq 1 90); do
|
|
28
|
+
invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices" 2>/dev/null || true)"
|
|
29
|
+
if [[ "$invoice_count" == "2" ]]; then
|
|
30
|
+
sleep 1
|
|
31
|
+
invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices" 2>/dev/null || true)"
|
|
32
|
+
if [[ "$invoice_count" == "2" ]]; then
|
|
33
|
+
break
|
|
34
|
+
fi
|
|
35
|
+
fi
|
|
36
|
+
sleep 1
|
|
37
|
+
done
|
|
38
|
+
invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices")"
|
|
39
|
+
if [[ "$invoice_count" != "2" ]]; then
|
|
40
|
+
echo "Postgres fixture did not finish seeding invoices." >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_password@localhost:55437/synapsor_billing_app_handler"
|
|
45
|
+
export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
|
|
46
|
+
export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
|
|
47
|
+
export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
|
|
48
|
+
export SYNAPSOR_TENANT_ID="acme"
|
|
49
|
+
export SYNAPSOR_PRINCIPAL="local_billing_operator"
|
|
50
|
+
|
|
51
|
+
node "$EXAMPLE_DIR/app-handler.mjs" >"$HANDLER_LOG" 2>&1 &
|
|
52
|
+
HANDLER_PID="$!"
|
|
53
|
+
|
|
54
|
+
for _ in $(seq 1 90); do
|
|
55
|
+
if node -e "fetch('http://127.0.0.1:8787/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
|
|
56
|
+
break
|
|
57
|
+
fi
|
|
58
|
+
sleep 1
|
|
59
|
+
done
|
|
60
|
+
node -e "fetch('http://127.0.0.1:8787/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
61
|
+
|
|
62
|
+
"$BIN" config validate --config "$CONFIG" >/dev/null
|
|
63
|
+
"$BIN" tools preview --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/tools.txt"
|
|
64
|
+
grep -F "billing.propose_account_credit" "$ROOT/tmp/billing-app-handler/tools.txt" >/dev/null
|
|
65
|
+
grep -F "execute_sql absent" "$ROOT/tmp/billing-app-handler/tools.txt" >/dev/null
|
|
66
|
+
|
|
67
|
+
printf '{"invoice_id":"INV-3001","amount_cents":2500,"reason":"support-approved credit"}\n' > "$ROOT/tmp/billing-app-handler/credit-input.json"
|
|
68
|
+
"$BIN" propose billing.propose_account_credit \
|
|
69
|
+
--input "$ROOT/tmp/billing-app-handler/credit-input.json" \
|
|
70
|
+
--config "$CONFIG" \
|
|
71
|
+
--store "$STORE" > "$ROOT/tmp/billing-app-handler/propose-credit.txt"
|
|
72
|
+
grep -F "Source DB changed:" "$ROOT/tmp/billing-app-handler/propose-credit.txt" >/dev/null
|
|
73
|
+
grep -F "no" "$ROOT/tmp/billing-app-handler/propose-credit.txt" >/dev/null
|
|
74
|
+
|
|
75
|
+
credit_count_before="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.account_credits")"
|
|
76
|
+
if [[ "$credit_count_before" != "0" ]]; then
|
|
77
|
+
echo "expected no account credits before approval, got $credit_count_before" >&2
|
|
78
|
+
exit 1
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
"$BIN" proposals approve latest --yes --store "$STORE" >/dev/null
|
|
82
|
+
"$BIN" apply latest --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/apply-credit.txt"
|
|
83
|
+
grep -F "App-owned writeback applied." "$ROOT/tmp/billing-app-handler/apply-credit.txt" >/dev/null
|
|
84
|
+
grep -F "source database changed by handler: yes" "$ROOT/tmp/billing-app-handler/apply-credit.txt" >/dev/null
|
|
85
|
+
|
|
86
|
+
credit_count_after="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.account_credits WHERE invoice_id = 'INV-3001' AND amount_cents = 2500")"
|
|
87
|
+
if [[ "$credit_count_after" != "1" ]]; then
|
|
88
|
+
echo "expected one inserted account credit, got $credit_count_after" >&2
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
"$BIN" apply latest --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/apply-credit-retry.txt"
|
|
93
|
+
grep -F "App-owned writeback already applied." "$ROOT/tmp/billing-app-handler/apply-credit-retry.txt" >/dev/null
|
|
94
|
+
|
|
95
|
+
"$BIN" replay show latest --store "$STORE" > "$ROOT/tmp/billing-app-handler/replay.txt"
|
|
96
|
+
grep -F "billing.propose_account_credit" "$ROOT/tmp/billing-app-handler/replay.txt" >/dev/null
|
|
97
|
+
|
|
98
|
+
echo "App-owned billing handler demo passed."
|
|
99
|
+
echo "Verified: proposal first, source unchanged before approval, account credit inserted by app handler, idempotent retry, replay."
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
INSERT INTO public.tenants (id, name, created_at, updated_at)
|
|
2
|
+
VALUES
|
|
3
|
+
('acme', 'Acme Robotics', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
4
|
+
('globex', 'Globex Labs', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
|
|
5
|
+
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at;
|
|
6
|
+
|
|
7
|
+
INSERT INTO public.customers (id, tenant_id, name, plan, created_at, updated_at)
|
|
8
|
+
VALUES
|
|
9
|
+
('cust_acme_1', 'acme', 'Acme Robotics', 'enterprise', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
10
|
+
('cust_globex_1', 'globex', 'Globex Labs', 'builder', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
|
|
11
|
+
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, name = EXCLUDED.name, plan = EXCLUDED.plan, updated_at = EXCLUDED.updated_at;
|
|
12
|
+
|
|
13
|
+
INSERT INTO public.invoices (
|
|
14
|
+
id,
|
|
15
|
+
tenant_id,
|
|
16
|
+
customer_id,
|
|
17
|
+
status,
|
|
18
|
+
balance_cents,
|
|
19
|
+
late_fee_cents,
|
|
20
|
+
waiver_reason,
|
|
21
|
+
credit_requested_cents,
|
|
22
|
+
credit_reason,
|
|
23
|
+
credited_cents,
|
|
24
|
+
updated_at
|
|
25
|
+
)
|
|
26
|
+
VALUES
|
|
27
|
+
('INV-3001', 'acme', 'cust_acme_1', 'overdue', 25500, 5500, NULL, 0, NULL, 0, '2026-06-20T14:31:08Z'),
|
|
28
|
+
('INV-9001', 'globex', 'cust_globex_1', 'overdue', 25500, 5500, NULL, 0, NULL, 0, '2026-06-20T14:31:08Z')
|
|
29
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
30
|
+
tenant_id = EXCLUDED.tenant_id,
|
|
31
|
+
customer_id = EXCLUDED.customer_id,
|
|
32
|
+
status = EXCLUDED.status,
|
|
33
|
+
balance_cents = EXCLUDED.balance_cents,
|
|
34
|
+
late_fee_cents = EXCLUDED.late_fee_cents,
|
|
35
|
+
waiver_reason = EXCLUDED.waiver_reason,
|
|
36
|
+
credit_requested_cents = EXCLUDED.credit_requested_cents,
|
|
37
|
+
credit_reason = EXCLUDED.credit_reason,
|
|
38
|
+
credited_cents = EXCLUDED.credited_cents,
|
|
39
|
+
updated_at = EXCLUDED.updated_at;
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
"timeout_ms": 5000
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"capabilities": [
|
|
44
|
+
{
|
|
45
|
+
"name": "billing.inspect_invoice",
|
|
46
|
+
"kind": "read",
|
|
47
|
+
"source": "billing_postgres",
|
|
48
|
+
"context": "local_operator",
|
|
49
|
+
"target": {
|
|
50
|
+
"schema": "public",
|
|
51
|
+
"table": "invoices",
|
|
52
|
+
"primary_key": "id",
|
|
53
|
+
"tenant_key": "tenant_id"
|
|
54
|
+
},
|
|
55
|
+
"args": {
|
|
56
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
57
|
+
},
|
|
58
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
59
|
+
"visible_columns": [
|
|
60
|
+
"id",
|
|
61
|
+
"tenant_id",
|
|
62
|
+
"customer_id",
|
|
63
|
+
"status",
|
|
64
|
+
"balance_cents",
|
|
65
|
+
"late_fee_cents",
|
|
66
|
+
"waiver_reason",
|
|
67
|
+
"credit_requested_cents",
|
|
68
|
+
"credit_reason",
|
|
69
|
+
"credited_cents",
|
|
70
|
+
"updated_at"
|
|
71
|
+
],
|
|
72
|
+
"evidence": "required",
|
|
73
|
+
"max_rows": 1
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "billing.propose_late_fee_waiver",
|
|
77
|
+
"kind": "proposal",
|
|
78
|
+
"source": "billing_postgres",
|
|
79
|
+
"context": "local_operator",
|
|
80
|
+
"target": {
|
|
81
|
+
"schema": "public",
|
|
82
|
+
"table": "invoices",
|
|
83
|
+
"primary_key": "id",
|
|
84
|
+
"tenant_key": "tenant_id"
|
|
85
|
+
},
|
|
86
|
+
"args": {
|
|
87
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 },
|
|
88
|
+
"reason": { "type": "string", "required": true, "max_length": 500 }
|
|
89
|
+
},
|
|
90
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
91
|
+
"visible_columns": [
|
|
92
|
+
"id",
|
|
93
|
+
"tenant_id",
|
|
94
|
+
"customer_id",
|
|
95
|
+
"status",
|
|
96
|
+
"balance_cents",
|
|
97
|
+
"late_fee_cents",
|
|
98
|
+
"waiver_reason",
|
|
99
|
+
"updated_at"
|
|
100
|
+
],
|
|
101
|
+
"evidence": "required",
|
|
102
|
+
"max_rows": 1,
|
|
103
|
+
"patch": {
|
|
104
|
+
"late_fee_cents": { "fixed": 0 },
|
|
105
|
+
"waiver_reason": { "from_arg": "reason" }
|
|
106
|
+
},
|
|
107
|
+
"allowed_columns": ["late_fee_cents", "waiver_reason"],
|
|
108
|
+
"numeric_bounds": {
|
|
109
|
+
"late_fee_cents": { "minimum": 0, "maximum": 10000 }
|
|
110
|
+
},
|
|
111
|
+
"conflict_guard": { "column": "updated_at" },
|
|
112
|
+
"approval": { "mode": "human", "required_role": "billing_lead" }
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "billing.propose_account_credit",
|
|
116
|
+
"kind": "proposal",
|
|
117
|
+
"source": "billing_postgres",
|
|
118
|
+
"context": "local_operator",
|
|
119
|
+
"executor": "billing_app_handler",
|
|
120
|
+
"target": {
|
|
121
|
+
"schema": "public",
|
|
122
|
+
"table": "invoices",
|
|
123
|
+
"primary_key": "id",
|
|
124
|
+
"tenant_key": "tenant_id"
|
|
125
|
+
},
|
|
126
|
+
"args": {
|
|
127
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 },
|
|
128
|
+
"amount_cents": { "type": "number", "required": true, "minimum": 1, "maximum": 10000 },
|
|
129
|
+
"reason": { "type": "string", "required": true, "max_length": 500 }
|
|
130
|
+
},
|
|
131
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
132
|
+
"visible_columns": [
|
|
133
|
+
"id",
|
|
134
|
+
"tenant_id",
|
|
135
|
+
"customer_id",
|
|
136
|
+
"status",
|
|
137
|
+
"balance_cents",
|
|
138
|
+
"credited_cents",
|
|
139
|
+
"credit_requested_cents",
|
|
140
|
+
"credit_reason",
|
|
141
|
+
"updated_at"
|
|
142
|
+
],
|
|
143
|
+
"evidence": "required",
|
|
144
|
+
"max_rows": 1,
|
|
145
|
+
"patch": {
|
|
146
|
+
"credit_requested_cents": { "from_arg": "amount_cents" },
|
|
147
|
+
"credit_reason": { "from_arg": "reason" }
|
|
148
|
+
},
|
|
149
|
+
"allowed_columns": ["credit_requested_cents", "credit_reason"],
|
|
150
|
+
"numeric_bounds": {
|
|
151
|
+
"credit_requested_cents": { "minimum": 1, "maximum": 10000 }
|
|
152
|
+
},
|
|
153
|
+
"conflict_guard": { "column": "updated_at" },
|
|
154
|
+
"approval": { "mode": "human", "required_role": "billing_lead" }
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# OpenAI Agents SDK + Synapsor Runner Streamable HTTP MCP
|
|
2
|
+
|
|
3
|
+
This example shows an OpenAI Agents SDK app connecting to a long-running
|
|
4
|
+
Synapsor Runner Streamable HTTP MCP server.
|
|
5
|
+
|
|
6
|
+
Use HTTP when your agent runs as an app/server and should connect to Runner
|
|
7
|
+
over a local/private network endpoint instead of launching a stdio child
|
|
8
|
+
process.
|
|
9
|
+
|
|
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.
|
|
19
|
+
|
|
20
|
+
The model still sees a semantic action. It does not receive raw SQL, database
|
|
21
|
+
URLs, write credentials, approval tools, or commit tools.
|
|
22
|
+
|
|
23
|
+
## Terminal 1: Start Synapsor Runner HTTP MCP
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export DATABASE_URL="<postgres-or-mysql-read-url>"
|
|
27
|
+
export SYNAPSOR_TENANT_ID="acme"
|
|
28
|
+
export SYNAPSOR_PRINCIPAL="openai_agent_demo"
|
|
29
|
+
export SYNAPSOR_RUNNER_HTTP_TOKEN="dev-token"
|
|
30
|
+
|
|
31
|
+
npx -y -p @synapsor/runner@alpha synapsor-runner mcp serve-streamable-http \
|
|
32
|
+
--config ./synapsor.runner.json \
|
|
33
|
+
--store ./.synapsor/local.db \
|
|
34
|
+
--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN \
|
|
35
|
+
--alias-mode openai
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Terminal 2: Run The Agent
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m venv .venv
|
|
42
|
+
. .venv/bin/activate
|
|
43
|
+
pip install -r requirements.txt
|
|
44
|
+
|
|
45
|
+
export OPENAI_API_KEY="..."
|
|
46
|
+
export SYNAPSOR_RUNNER_HTTP_URL="http://127.0.0.1:8766/mcp"
|
|
47
|
+
export SYNAPSOR_RUNNER_HTTP_TOKEN="dev-token"
|
|
48
|
+
export SYNAPSOR_INVOICE_ID="INV-3001"
|
|
49
|
+
|
|
50
|
+
python agent.py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Expected behavior:
|
|
54
|
+
|
|
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`;
|
|
58
|
+
- Synapsor applies trusted tenant/principal context from the server process;
|
|
59
|
+
- the response includes scoped data and evidence handles;
|
|
60
|
+
- no SQL/write/approval tool is exposed to the model;
|
|
61
|
+
- evidence/query audit are saved in the local Runner store.
|
|
62
|
+
|
|
63
|
+
For production-like deployment, keep HTTP MCP behind private networking/TLS,
|
|
64
|
+
bearer auth, and rate limits. See [HTTP MCP](../../docs/http-mcp.md).
|