melchizedek-agents 1.4.0 → 1.5.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 (44) hide show
  1. package/.env.example +15 -0
  2. package/CHANGELOG.md +77 -0
  3. package/DOCUMENTATION.md +43 -2
  4. package/config/agents/syndicate.schema.json +80 -6
  5. package/db/hardening.sql +38 -12
  6. package/dist/lib/a2a/app.js +2 -1
  7. package/dist/lib/chatgpt/state.js +4 -4
  8. package/dist/lib/compile.js +15 -2
  9. package/dist/lib/index.d.ts +2 -0
  10. package/dist/lib/index.js +2 -0
  11. package/dist/lib/loadSyndicate.js +4 -0
  12. package/dist/lib/memory/providers.js +2 -1
  13. package/dist/lib/models/claudeAdapter.js +2 -1
  14. package/dist/lib/models/credentialSource.d.ts +28 -0
  15. package/dist/lib/models/credentialSource.js +49 -0
  16. package/dist/lib/models/endpoints.js +2 -1
  17. package/dist/lib/models/gateway.js +2 -1
  18. package/dist/lib/models/gatewayAdapter.js +2 -1
  19. package/dist/lib/models/geminiAdapter.js +2 -1
  20. package/dist/lib/models/gptAdapter.js +2 -1
  21. package/dist/lib/models/grokAdapter.js +2 -1
  22. package/dist/lib/models/kimiAdapter.js +2 -1
  23. package/dist/lib/models/providerMap.js +3 -2
  24. package/dist/lib/observability/embeddings.js +2 -1
  25. package/dist/lib/observability/lineage.js +11 -3
  26. package/dist/lib/observability/supabaseSpanExporter.d.ts +19 -1
  27. package/dist/lib/observability/supabaseSpanExporter.js +37 -1
  28. package/dist/lib/observability/tracer.d.ts +9 -0
  29. package/dist/lib/observability/tracer.js +45 -0
  30. package/dist/lib/paths.d.ts +31 -0
  31. package/dist/lib/paths.js +76 -0
  32. package/dist/lib/storage/postgres/index.d.ts +25 -0
  33. package/dist/lib/storage/postgres/index.js +10 -1
  34. package/dist/lib/syndicateSchema.d.ts +26 -4
  35. package/dist/lib/syndicateSchema.js +45 -1
  36. package/dist/lib/tools/generateImageTool.js +6 -6
  37. package/dist/lib/tools/inspectImageTool.d.ts +2 -0
  38. package/dist/lib/tools/inspectImageTool.js +17 -10
  39. package/dist/lib/tools/mcpToolFactory.d.ts +34 -1
  40. package/dist/lib/tools/mcpToolFactory.js +117 -17
  41. package/dist/lib/tools/taskTools.js +3 -3
  42. package/dist/lib/tools/xApiSearchTool.js +2 -1
  43. package/dist/scripts/a2a_server.js +5 -0
  44. package/package.json +7 -4
package/.env.example CHANGED
@@ -136,6 +136,21 @@ SUPABASE_SERVICE_ROLE_KEY=
136
136
  # adk_telemetry table. Provision it first: run db/telemetry.sql in the Supabase
137
137
  # SQL Editor, then re-run db/hardening.sql. See DOCUMENTATION.md §5.
138
138
  TELEMETRY_SUPABASE=false
139
+ # Or, with DATABASE_URL set, write the same ledger to that Postgres instead
140
+ # (db/telemetry.sql applied there). Use one sink, not both.
141
+ TELEMETRY_POSTGRES=false
142
+
143
+ # ── Optional: local MCP servers over stdio ───────────────────────────────────
144
+ # Comma-separated `command` values an mcp_servers entry may start (exact
145
+ # match, e.g. "npx,uvx"). Unset: no stdio server runs. See ADR 0132.
146
+ MELCHIZEDEK_STDIO_MCP=
147
+
148
+ # ── Optional: where the engine writes ─────────────────────────────────────────
149
+ # One directory for the on-disk defaults (outputs/, outputs/tasks.json,
150
+ # chatgpt-signin.json). OUTPUTS_DIR, MELCHIZEDEK_TASKS_FILE and
151
+ # MELCHIZEDEK_CHATGPT_SIGNIN_FILE still win. Unset: ./outputs and
152
+ # ~/.melchizedek as before. See ADR 0134.
153
+ MELCHIZEDEK_DATA_DIR=
139
154
 
140
155
  # ── Optional: A2A Server ──────────────────────────────────────────────────────
141
156
  # A2A_SERVER_SECRET: Bearer token used to authenticate incoming A2A requests.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,83 @@ the starter pack and the templates), not the repo's full history.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 1.5.0 — 2026-10-15
10
+
11
+ A minor release that makes the engine embeddable in a single-process
12
+ application with no database server and no keys in its environment, and
13
+ adds local MCP servers. Embedded Postgres (PGlite) runs the schema and
14
+ `postgresStorage` unchanged (ADR 0130); the telemetry ledger can be written
15
+ to any Postgres through the storage's own pool (ADR 0131); an `mcp_servers`
16
+ entry can start a local server over stdio, behind an operator allowlist
17
+ (ADR 0132); an application supplies credentials from its own store through
18
+ one source asked by variable name (ADR 0133); and one variable,
19
+ `MELCHIZEDEK_DATA_DIR`, roots the engine's files (ADR 0134). Nothing
20
+ changes for an existing deployment that sets none of the new options. Two
21
+ fixes ride along: `inspect_image` with `OUTPUTS_DIR` set, and the ledger's
22
+ `engine_version` for an installed engine.
23
+
24
+ ### Added
25
+
26
+ - **`postgresStorage({ turnLock })` and `inProcessTurnLock` exported from
27
+ `melchizedek-agents/storage/postgres` (ADR 0130).** The storage uses the
28
+ given lock instead of its advisory lock and opens no turn-lock pool. Pass
29
+ `inProcessTurnLock()` when one process owns an embedded database such as
30
+ PGlite behind pglite-socket, where every connection shares one session and
31
+ an advisory lock excludes nothing. The default is unchanged.
32
+
33
+ - **The telemetry ledger on any Postgres (ADR 0131).**
34
+ `postgresStorage({ ledger: true })` writes `adk_turns`, `adk_telemetry` and
35
+ `adk_payloads` through the storage's own pool, with the rows, payload
36
+ policy and redaction the Supabase sink uses; `close()` flushes it. The A2A
37
+ bin turns it on with `TELEMETRY_POSTGRES=true` beside `DATABASE_URL`.
38
+ New exports from `melchizedek-agents/observability/*`: `attachLedger`
39
+ (tracer), `postgresLedgerClient`, `LedgerClient` and `LEDGER_TABLES`
40
+ (supabaseSpanExporter).
41
+
42
+ - **Local MCP servers over stdio (ADR 0132).** An `mcp_servers` entry may give
43
+ `command` (with `args`, `env`, `pass_env`, `cwd`) instead of `url`; the
44
+ server is started over stdio, one process per spec for the life of the
45
+ engine. It runs only when `MELCHIZEDEK_STDIO_MCP` lists the exact command.
46
+ The child gets a minimal environment plus `pass_env` by name and `env`
47
+ literals; a credential-looking `env` key is refused at load. New exports
48
+ from `melchizedek-agents/tools/mcpToolFactory`: `loadStdioMcpTools`,
49
+ `stdioCommandAllowed`, `stdioEnvironment`, `StdioMcpSpec`; `McpServerConfig.url`
50
+ is now optional. `pass_env` reads through the credential source (below)
51
+ before the environment, so a key held in an application's keychain reaches
52
+ the server by name.
53
+
54
+ - **`setCredentialSource`, `credentialValue` and `CredentialSource` in the
55
+ barrel (ADR 0133).** An embedding application supplies keys from its own
56
+ store, asked by environment variable name (`ANTHROPIC_API_KEY`, …) before
57
+ `process.env`, at each use and never cached. Every key read in the engine
58
+ goes through it: the five adapters, the gateway, provider presence and
59
+ `providerStatuses()`, endpoints, embeddings, the memory providers and the
60
+ Gemini image and vision tools. Without a source, behaviour is unchanged.
61
+
62
+ - **`MELCHIZEDEK_DATA_DIR` (ADR 0134).** When set, the engine's on-disk
63
+ defaults go under it: `outputs/` (generated images, the telemetry spool),
64
+ `outputs/tasks.json` and `chatgpt-signin.json`. `OUTPUTS_DIR`,
65
+ `MELCHIZEDEK_TASKS_FILE` and `MELCHIZEDEK_CHATGPT_SIGNIN_FILE` still win;
66
+ without any, the defaults are unchanged.
67
+
68
+ ### Fixed
69
+
70
+ - **`db/hardening.sql` applies on Postgres without Supabase's API roles
71
+ (ADR 0130).** It revoked from `anon` and `authenticated` by name and failed
72
+ where they do not exist (plain Postgres, PGlite). It now revokes from
73
+ whichever of the two exist, through helpers in `pg_temp` that leave nothing
74
+ behind; on Supabase the result is the same as before. RLS and the `PUBLIC`
75
+ revokes apply everywhere.
76
+
77
+ - **`inspect_image` reads the image `generate_image` reported when
78
+ `OUTPUTS_DIR` is set (ADR 0134).** It resolved `outputs/<file>` against the
79
+ working directory and refused it whenever the outputs directory was
80
+ elsewhere. A relative path is now read inside the outputs directory; the
81
+ confinement is unchanged.
82
+ - **The ledger's `engine_version` is the engine's (ADR 0134).** It read
83
+ `package.json` and the git commit from the working directory, so an
84
+ installed engine reported the embedding application's version and commit.
85
+
9
86
  ## 1.4.0 — 2026-10-13
10
87
 
11
88
  A minor release with two additions and no breaking change. OAuth consent
package/DOCUMENTATION.md CHANGED
@@ -150,7 +150,7 @@ Field reference:
150
150
  | `mcp_auth` | subagent | `{ oauth2 }`: the OAuth grant the MCP server takes, the same block as an OpenAPI `auth.oauth2`. `client_credentials` sends the server's own token; `authorization_code` sends each user's own token on their own connection (the consent pause asks for it) and needs `mcp_tools` ([ADR 0112](./wiki/decisions/0112-oauth-grants-declared-beside-the-tool.md)). With `client_registration: dynamic` and no client id or endpoints, the authorization server is discovered from the MCP server and this deployment registers itself as a client, every discovered host held to `MELCHIZEDEK_OAUTH_HOSTS` (ADR 0124). |
151
151
  | `mcp_server_url` | subagent | Discover this subagent's tools from a remote MCP server at load time (§3). SSRF-guarded; `ALLOW_PRIVATE_MCP=true` permits localhost for development. |
152
152
  | `mcp_transport` | subagent | How `mcp_server_url` is reached: `auto` (default: Streamable HTTP, falling back to the legacy SSE transport on the spec's signal), `streamable_http` or `sse` ([ADR 0124](./wiki/decisions/0124-mcp-client-streamable-http-several-servers-and-dynamic-registration.md)). |
153
- | `mcp_servers` | any agent | Several MCP servers: a list of `{ name, url, tools, auth?, transport? }`. `tools` is required on each; a tool name on two servers, or shared with the agent's own tools, is refused at load. `require_approval` may name any server's tool. Not with `mcp_server_url` (ADR 0124). |
153
+ | `mcp_servers` | any agent | Several MCP servers: a list of `{ name, url, tools, auth?, transport? }`, or for a local server `{ name, command, args?, env?, pass_env?, cwd?, tools }` started over stdio when `MELCHIZEDEK_STDIO_MCP` lists the command (ADR 0132). `tools` is required on each; a tool name on two servers, or shared with the agent's own tools, is refused at load. `require_approval` may name any server's tool. Not with `mcp_server_url` (ADR 0124). |
154
154
  | `openapi` | any agent | HTTP APIs as tools, each from an OpenAPI 3 spec file (§3, OpenAPI tools): `spec`, and optionally `operations` (default: the GET operations only), `auth` (from environment variables), `base_url`, `prefix`. |
155
155
  | `code_execution` | any Gemini agent | `"gemini"`: the model writes Python and Gemini runs it in Google's server-side sandbox, returning the output to the model; nothing runs on this host. For arithmetic, data and checks a model gets wrong in its head. Gemini models only ([ADR 0033](./wiki/decisions/0033-context-task-code.md)). |
156
156
  | `context` | orchestrator | Compacts a long conversation: when the last request's prompt passed `compact_after_tokens`, earlier turns become one summary (written by `summary_model`, default the agent's own) and the last `keep_recent_events` stay verbatim. The full history stays stored; only what the model reads shrinks. The orchestrator of a delegate syndicate only: a dispatch route already reads a bounded projection, a workflow node sees only its input. |
@@ -207,6 +207,23 @@ loopback/private hosts unless `ALLOW_PRIVATE_MCP=true` (SSRF guard);
207
207
  treat any remote MCP server as an untrusted tool vendor whose results
208
208
  are data, never instructions.
209
209
 
210
+ A local MCP server is an `mcp_servers` entry with `command` instead of
211
+ `url`, started over stdio (ADR 0132):
212
+
213
+ ```yaml
214
+ mcp_servers:
215
+ - name: github
216
+ command: npx
217
+ args: ["-y", "@modelcontextprotocol/server-github"]
218
+ pass_env: [GITHUB_PERSONAL_ACCESS_TOKEN] # copied by name; never a value here
219
+ tools: [search_issues, get_issue]
220
+ ```
221
+
222
+ It runs only when the operator lists the exact command,
223
+ `MELCHIZEDEK_STDIO_MCP=npx`. The child sees a minimal environment plus
224
+ the `pass_env` names and the non-secret `env` values, nothing else of the
225
+ engine's. One process serves every turn and is restarted if it exits.
226
+
210
227
  **OpenAPI tools** turn any HTTP API with an OpenAPI 3 spec into an
211
228
  agent's tools, with no tool code (`lib/tools/openapiTools.ts`, on the
212
229
  engine's own parser and caller in `lib/tools/openapi/`;
@@ -302,6 +319,14 @@ nothing is changed, and two applies started together run one after the other.
302
319
  Rolling back a migration that succeeded is a restore from backup (§6), since
303
320
  the migrations only move forward.
304
321
 
322
+ The same install runs on plain Postgres without Supabase's `anon` and
323
+ `authenticated` roles (the hardening skips the revokes by role name there), and
324
+ on embedded Postgres: an application can run PGlite with pgvector in-process,
325
+ serve it with pglite-socket, and pass `postgresStorage({ connectionString,
326
+ turnLock: inProcessTurnLock() })`. Every connection to PGlite shares one
327
+ session, so the default advisory turn lock would exclude nothing; one process
328
+ owns the data directory (ADR 0130).
329
+
305
330
  The tables go in the `public` schema by default. To keep them out of reach of
306
331
  a REST layer (Supabase exposes `public`), install into a private schema and
307
332
  run the server with the same setting:
@@ -568,13 +593,29 @@ helpers. A project that only calls models imports from there. Its
568
593
  `resolveAdapter` gives a Gemini id `GeminiAdapter`; `resolveModel` is not
569
594
  in it.
570
595
 
596
+ An application that keeps keys outside the environment (an OS keychain,
597
+ a secret manager) sets one credential source, asked by the same variable
598
+ names at each use, before `process.env` (ADR 0133):
599
+
600
+ ```ts
601
+ import { setCredentialSource } from 'melchizedek-agents';
602
+ setCredentialSource((name) => keychain.get(name)); // e.g. 'ANTHROPIC_API_KEY'
603
+ ```
604
+
605
+ Every key read goes through it: the adapters, the gateway, provider
606
+ status, embeddings and the image tools. A key removed from the source
607
+ stops being sent on the next request.
608
+
571
609
  Every model request also emits an `llm.request` OpenTelemetry span
572
610
  (provider, model, input/output/thinking tokens, latency). Scripts print it
573
611
  as an `[OTEL_SPAN_JSON]` line; `melchizedek-chat` and the `syndicate:*`
574
612
  scripts keep those lines off unless `OTEL_CONSOLE_SPANS=true`. Set
575
613
  `TELEMETRY_SUPABASE=true` to persist
576
614
  spans to the `adk_telemetry` table (run `db/telemetry.sql`, then
577
- re-run `db/hardening.sql` — schema below):
615
+ re-run `db/hardening.sql` — schema below). On `DATABASE_URL`, set
616
+ `TELEMETRY_POSTGRES=true` instead to write the same rows through the
617
+ storage's own pool; an application that builds its own storage passes
618
+ `postgresStorage({ ledger: true })` (ADR 0131). Use one sink:
578
619
 
579
620
  `db/telemetry.sql` (idempotent) creates three tables: `adk_turns` — one
580
621
  row per turn with input, output, the responding agent, the plan-dispatch
@@ -583,9 +583,47 @@
583
583
  "description": "A short name for the server, unique on this agent: logs and errors name it."
584
584
  },
585
585
  "url": {
586
+ "description": "The MCP server's URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard. Give url or command, not both.",
586
587
  "type": "string",
587
- "format": "uri",
588
- "description": "The MCP server's URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard."
588
+ "format": "uri"
589
+ },
590
+ "command": {
591
+ "description": "A local MCP server to start and speak to over stdio (ADR 0132), instead of url: the program to run. It runs only when the operator lists this exact value in MELCHIZEDEK_STDIO_MCP. One process per distinct command, args, env and cwd, kept for the life of the engine.",
592
+ "type": "string",
593
+ "minLength": 1
594
+ },
595
+ "args": {
596
+ "description": "The command's arguments (stdio only).",
597
+ "maxItems": 64,
598
+ "type": "array",
599
+ "items": {
600
+ "type": "string"
601
+ }
602
+ },
603
+ "env": {
604
+ "description": "Non-secret variables set for the command (stdio only). A credential is never written here: name it in pass_env.",
605
+ "type": "object",
606
+ "propertyNames": {
607
+ "type": "string",
608
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$"
609
+ },
610
+ "additionalProperties": {
611
+ "type": "string"
612
+ }
613
+ },
614
+ "pass_env": {
615
+ "description": "Variables copied by name from the engine's environment into the command's (stdio only); the command sees no other variable of the engine's beyond a minimal default set (PATH, HOME, …). A name that is unset fails the compile.",
616
+ "maxItems": 32,
617
+ "type": "array",
618
+ "items": {
619
+ "type": "string",
620
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$"
621
+ }
622
+ },
623
+ "cwd": {
624
+ "description": "The command's working directory (stdio only); relative to the YAML file's directory.",
625
+ "type": "string",
626
+ "minLength": 1
589
627
  },
590
628
  "tools": {
591
629
  "minItems": 1,
@@ -694,7 +732,6 @@
694
732
  },
695
733
  "required": [
696
734
  "name",
697
- "url",
698
735
  "tools"
699
736
  ],
700
737
  "additionalProperties": false,
@@ -1570,9 +1607,47 @@
1570
1607
  "description": "A short name for the server, unique on this agent: logs and errors name it."
1571
1608
  },
1572
1609
  "url": {
1610
+ "description": "The MCP server's URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard. Give url or command, not both.",
1573
1611
  "type": "string",
1574
- "format": "uri",
1575
- "description": "The MCP server's URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard."
1612
+ "format": "uri"
1613
+ },
1614
+ "command": {
1615
+ "description": "A local MCP server to start and speak to over stdio (ADR 0132), instead of url: the program to run. It runs only when the operator lists this exact value in MELCHIZEDEK_STDIO_MCP. One process per distinct command, args, env and cwd, kept for the life of the engine.",
1616
+ "type": "string",
1617
+ "minLength": 1
1618
+ },
1619
+ "args": {
1620
+ "description": "The command's arguments (stdio only).",
1621
+ "maxItems": 64,
1622
+ "type": "array",
1623
+ "items": {
1624
+ "type": "string"
1625
+ }
1626
+ },
1627
+ "env": {
1628
+ "description": "Non-secret variables set for the command (stdio only). A credential is never written here: name it in pass_env.",
1629
+ "type": "object",
1630
+ "propertyNames": {
1631
+ "type": "string",
1632
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$"
1633
+ },
1634
+ "additionalProperties": {
1635
+ "type": "string"
1636
+ }
1637
+ },
1638
+ "pass_env": {
1639
+ "description": "Variables copied by name from the engine's environment into the command's (stdio only); the command sees no other variable of the engine's beyond a minimal default set (PATH, HOME, …). A name that is unset fails the compile.",
1640
+ "maxItems": 32,
1641
+ "type": "array",
1642
+ "items": {
1643
+ "type": "string",
1644
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$"
1645
+ }
1646
+ },
1647
+ "cwd": {
1648
+ "description": "The command's working directory (stdio only); relative to the YAML file's directory.",
1649
+ "type": "string",
1650
+ "minLength": 1
1576
1651
  },
1577
1652
  "tools": {
1578
1653
  "minItems": 1,
@@ -1681,7 +1756,6 @@
1681
1756
  },
1682
1757
  "required": [
1683
1758
  "name",
1684
- "url",
1685
1759
  "tools"
1686
1760
  ],
1687
1761
  "additionalProperties": false,
package/db/hardening.sql CHANGED
@@ -30,6 +30,27 @@
30
30
  -- The A2A server checks at boot whether this file has been applied and
31
31
  -- prints a prominent warning if not (see lib/persistence/supabaseProvider.ts).
32
32
 
33
+ -- ── The API roles ────────────────────────────────────────────────────────
34
+ -- anon and authenticated are Supabase's PostgREST roles. Plain Postgres and
35
+ -- embedded Postgres (PGlite) have no REST API and normally no such roles;
36
+ -- there, the PUBLIC revokes and RLS below are the whole lockdown, and the
37
+ -- revokes by role name are skipped rather than failing. These two helpers
38
+ -- live in pg_temp: they exist for this session only and leave nothing behind.
39
+ CREATE OR REPLACE FUNCTION pg_temp.melchizedek_api_roles() RETURNS text
40
+ LANGUAGE sql AS $$
41
+ SELECT string_agg(quote_ident(rolname), ', ' ORDER BY rolname)
42
+ FROM pg_roles WHERE rolname IN ('anon', 'authenticated');
43
+ $$;
44
+
45
+ CREATE OR REPLACE FUNCTION pg_temp.melchizedek_revoke_api(target text) RETURNS void
46
+ LANGUAGE plpgsql AS $$
47
+ DECLARE roles text := pg_temp.melchizedek_api_roles();
48
+ BEGIN
49
+ IF roles IS NOT NULL THEN
50
+ EXECUTE format('REVOKE ALL ON %s FROM %s', target, roles);
51
+ END IF;
52
+ END $$;
53
+
33
54
  -- ── Tier 1: lock the public API paths ────────────────────────────────────
34
55
 
35
56
  -- Enable RLS. With RLS on and NO policies defined, anon/authenticated get
@@ -40,8 +61,11 @@ ALTER TABLE adk_sessions ENABLE ROW LEVEL SECURITY;
40
61
  -- Belt and suspenders: also revoke the default table privileges from the
41
62
  -- API roles, so even a future accidentally-created permissive policy
42
63
  -- cannot re-expose the tables.
43
- REVOKE ALL ON adk_memory_facts FROM anon, authenticated;
44
- REVOKE ALL ON adk_sessions FROM anon, authenticated;
64
+ DO $$
65
+ BEGIN
66
+ PERFORM pg_temp.melchizedek_revoke_api('adk_memory_facts');
67
+ PERFORM pg_temp.melchizedek_revoke_api('adk_sessions');
68
+ END $$;
45
69
 
46
70
  -- Optional observability ledger (db/telemetry.sql). Guarded: the tables only
47
71
  -- exist when the operator opted into TELEMETRY_SUPABASE. adk_turns holds
@@ -54,14 +78,14 @@ BEGIN
54
78
  'adk_session_events', 'adk_a2a_tasks', 'melchizedek_usage', 'melchizedek_audit'] LOOP
55
79
  IF to_regclass('public.' || t) IS NOT NULL THEN
56
80
  EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
57
- EXECUTE format('REVOKE ALL ON %I FROM anon, authenticated', t);
81
+ PERFORM pg_temp.melchizedek_revoke_api(format('%I', t));
58
82
  END IF;
59
83
  END LOOP;
60
84
  -- The production view and the retention function read/delete those
61
85
  -- tables on behalf of the caller; neither belongs on the public API.
62
86
  FOREACH t IN ARRAY ARRAY['adk_turns_production', 'adk_kpi_daily', 'adk_kpi_routes_daily', 'adk_kpi_judges_daily', 'adk_kpi_hourly'] LOOP
63
87
  IF to_regclass('public.' || t) IS NOT NULL THEN
64
- EXECUTE format('REVOKE ALL ON %I FROM anon, authenticated', t);
88
+ PERFORM pg_temp.melchizedek_revoke_api(format('%I', t));
65
89
  END IF;
66
90
  END LOOP;
67
91
  -- Function privileges for these are handled in the FUNCTIONS block below.
@@ -73,7 +97,7 @@ DO $$
73
97
  BEGIN
74
98
  IF to_regclass('public.melchizedek_schema_version') IS NOT NULL THEN
75
99
  EXECUTE 'ALTER TABLE melchizedek_schema_version ENABLE ROW LEVEL SECURITY';
76
- EXECUTE 'REVOKE ALL ON melchizedek_schema_version FROM anon, authenticated';
100
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_schema_version');
77
101
  END IF;
78
102
  END $$;
79
103
 
@@ -84,29 +108,29 @@ DO $$
84
108
  BEGIN
85
109
  IF to_regclass('public.adk_agent_registry') IS NOT NULL THEN
86
110
  EXECUTE 'ALTER TABLE adk_agent_registry ENABLE ROW LEVEL SECURITY';
87
- EXECUTE 'REVOKE ALL ON adk_agent_registry FROM anon, authenticated';
111
+ PERFORM pg_temp.melchizedek_revoke_api('adk_agent_registry');
88
112
  END IF;
89
113
  -- The task tools' lists and job queue (migration 0009).
90
114
  IF to_regclass('public.melchizedek_tasks') IS NOT NULL THEN
91
115
  EXECUTE 'ALTER TABLE melchizedek_tasks ENABLE ROW LEVEL SECURITY';
92
116
  EXECUTE 'ALTER TABLE melchizedek_task_owners ENABLE ROW LEVEL SECURITY';
93
- EXECUTE 'REVOKE ALL ON melchizedek_tasks, melchizedek_task_owners FROM anon, authenticated';
117
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_tasks, melchizedek_task_owners');
94
118
  END IF;
95
119
  -- The memory processed marker (migration 0007): identifiers only.
96
120
  IF to_regclass('public.melchizedek_memory_ingest') IS NOT NULL THEN
97
121
  EXECUTE 'ALTER TABLE melchizedek_memory_ingest ENABLE ROW LEVEL SECURITY';
98
- EXECUTE 'REVOKE ALL ON melchizedek_memory_ingest FROM anon, authenticated';
122
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_memory_ingest');
99
123
  END IF;
100
124
  -- Third-party tokens held for tools (migration 0013): ciphertext only,
101
125
  -- still nothing an API role should read.
102
126
  IF to_regclass('public.melchizedek_tool_credentials') IS NOT NULL THEN
103
127
  EXECUTE 'ALTER TABLE melchizedek_tool_credentials ENABLE ROW LEVEL SECURITY';
104
- EXECUTE 'REVOKE ALL ON melchizedek_tool_credentials FROM anon, authenticated';
128
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_tool_credentials');
105
129
  END IF;
106
130
  -- Its history (migration 0005): every definition an id has held.
107
131
  IF to_regclass('public.adk_agent_registry_versions') IS NOT NULL THEN
108
132
  EXECUTE 'ALTER TABLE adk_agent_registry_versions ENABLE ROW LEVEL SECURITY';
109
- EXECUTE 'REVOKE ALL ON adk_agent_registry_versions FROM anon, authenticated';
133
+ PERFORM pg_temp.melchizedek_revoke_api('adk_agent_registry_versions');
110
134
  END IF;
111
135
  END $$;
112
136
 
@@ -141,7 +165,8 @@ BEGIN
141
165
  'melchizedek_memory_dimensions', 'melchizedek_prune_memory_ingest',
142
166
  'melchizedek_prune_memory', 'melchizedek_prune_audit')
143
167
  LOOP
144
- EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', fn);
168
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC', fn);
169
+ PERFORM pg_temp.melchizedek_revoke_api(format('FUNCTION %s', fn));
145
170
  IF has_service_role THEN
146
171
  EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
147
172
  END IF;
@@ -170,9 +195,10 @@ AS $$
170
195
  'melchizedek_audit', 'melchizedek_tool_credentials');
171
196
  $$;
172
197
 
173
- REVOKE ALL ON FUNCTION melchizedek_rls_status() FROM PUBLIC, anon, authenticated;
198
+ REVOKE ALL ON FUNCTION melchizedek_rls_status() FROM PUBLIC;
174
199
  DO $$
175
200
  BEGIN
201
+ PERFORM pg_temp.melchizedek_revoke_api('FUNCTION melchizedek_rls_status()');
176
202
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
177
203
  GRANT EXECUTE ON FUNCTION melchizedek_rls_status() TO service_role;
178
204
  END IF;
@@ -68,6 +68,7 @@ import { setOAuthHosts } from '../tools/oauthHosts.js';
68
68
  import { setCredentialHosts } from '../tools/credentialHosts.js';
69
69
  import { syndicateCredentialHostProblems } from '../tools/credentialUses.js';
70
70
  import { syndicateOAuthHostProblems } from '../tools/oauthTools.js';
71
+ import { credentialValue } from '../models/credentialSource.js';
71
72
  const SURFACE_HEADERS = [
72
73
  ['x-surface', 'name'],
73
74
  ['x-surface-guild', 'guild'],
@@ -335,7 +336,7 @@ export async function createA2AApp(options) {
335
336
  // Embeddings and fact extraction use the SERVER's Gemini key: memory is
336
337
  // operator infrastructure, like tools.
337
338
  const services = await createSupabaseServices({
338
- apiKey: process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY || '',
339
+ apiKey: credentialValue('GOOGLE_GENAI_API_KEY') || credentialValue('GEMINI_API_KEY') || '',
339
340
  withMemory: true,
340
341
  extractor: options.memory?.extractor,
341
342
  embedder: options.memory?.embedder,
@@ -21,8 +21,9 @@
21
21
  * times and the file's path.
22
22
  */
23
23
  import { existsSync, readFileSync, statSync } from 'node:fs';
24
- import { homedir } from 'node:os';
25
24
  import { dirname, join, resolve } from 'node:path';
25
+ import { chatGptSignInFile } from '../paths.js';
26
+ import { credentialEnv } from '../models/credentialSource.js';
26
27
  /** Where the credential file lives, when not the default (a path; never a value). */
27
28
  export const CHATGPT_SIGNIN_FILE_ENV = 'MELCHIZEDEK_CHATGPT_SIGNIN_FILE';
28
29
  /** `off` ignores a stored sign-in (a served process on a machine that also signed in). */
@@ -31,8 +32,7 @@ export const CHATGPT_SIGNIN_ENV = 'MELCHIZEDEK_CHATGPT_SIGNIN';
31
32
  export const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
32
33
  /** The credential file: MELCHIZEDEK_CHATGPT_SIGNIN_FILE, else ~/.melchizedek/chatgpt-signin.json. */
33
34
  export function signInFile(env = process.env) {
34
- const set = env[CHATGPT_SIGNIN_FILE_ENV]?.trim();
35
- return set ? resolve(set) : join(homedir(), '.melchizedek', 'chatgpt-signin.json');
35
+ return chatGptSignInFile(env);
36
36
  }
37
37
  /** The git work tree a path sits inside, if any (an ancestor holding `.git`). */
38
38
  export function insideGitWorkTree(file) {
@@ -100,7 +100,7 @@ export function chatGptSignInStatus(env = process.env) {
100
100
  * OPENAI_PLATFORM other than direct, no OPENAI_BASE_URL proxy, which must
101
101
  * never receive the token).
102
102
  */
103
- export function chatGptSignInRoutesOpenAi(env = process.env) {
103
+ export function chatGptSignInRoutesOpenAi(env = credentialEnv()) {
104
104
  if (env.OPENAI_API_KEY?.trim())
105
105
  return false;
106
106
  const platform = env.OPENAI_PLATFORM?.trim().toLowerCase();
@@ -42,7 +42,7 @@ import { REASONING_OLDER_SPELLING } from './syndicateSchema.js';
42
42
  import { reasoningConfig } from './models/reasoning.js';
43
43
  import { instructionFor, toEngineAgent } from './agentDialect.js';
44
44
  import { resolveTools as resolveNamedTools } from './toolRegistry.js';
45
- import { createMcpTools } from './tools/mcpToolFactory.js';
45
+ import { createMcpTools, loadStdioMcpTools } from './tools/mcpToolFactory.js';
46
46
  import { requireApproval, toolOf } from './tools/tool.js';
47
47
  import { examplesInstructionTool } from './tools/examples.js';
48
48
  import { capabilitySummary, describeCapabilities } from './models/capabilities.js';
@@ -145,6 +145,17 @@ export function examplesTool(examples) {
145
145
  const tool = examplesInstructionTool(examples);
146
146
  return tool ? [tool] : [];
147
147
  }
148
+ /** A stdio server's tools (ADR 0132), narrowed to the names the entry lists. */
149
+ async function stdioOfferedBy(server, opts) {
150
+ const label = `${server.name} (stdio: ${server.command})`;
151
+ opts.log?.(`Loading MCP tools: ${label}`);
152
+ const offered = await loadStdioMcpTools(server.name, { command: server.command, args: server.args, env: server.env, pass_env: server.pass_env, cwd: server.cwd });
153
+ const allowed = new Set(server.tools);
154
+ const missing = server.tools.filter((n) => !offered.some((t) => t.name === n));
155
+ if (missing.length)
156
+ opts.log?.(`MCP ${label} does not offer ${missing.map((n) => `'${n}'`).join(', ')}`);
157
+ return offered.filter((t) => allowed.has(t.name));
158
+ }
148
159
  async function resolveAgentTools(agent, opts) {
149
160
  const tools = [...resolveNamedTools(agent.tools, opts.onUnknownTool), ...examplesTool(agent.examples)];
150
161
  // OpenAPI operations become tools here, so require_approval can name them.
@@ -185,7 +196,9 @@ async function resolveAgentTools(agent, opts) {
185
196
  // mcp_servers (ADR 0124): the loader refused a declared name twice; a
186
197
  // server that answers with a name already taken is refused here too, so
187
198
  // no server's tool can stand in for another's or the agent's own.
188
- const servers = await Promise.all((agent.mcp_servers ?? []).map((server) => offeredBy(`${server.name} (${server.url})`, server.url, server.tools, server.auth?.oauth2, server.transport)));
199
+ const servers = await Promise.all((agent.mcp_servers ?? []).map((server) => server.command !== undefined
200
+ ? stdioOfferedBy(server, opts)
201
+ : offeredBy(`${server.name} (${server.url})`, server.url, server.tools, server.auth?.oauth2, server.transport)));
189
202
  for (const [i, offered] of servers.entries()) {
190
203
  const server = agent.mcp_servers[i];
191
204
  for (const mcpTool of offered) {
@@ -57,3 +57,5 @@ export type { MemoryStore } from './memory/store.ts';
57
57
  export { validateSyndicateConfig, syndicateJsonSchema, SyndicateValidationError } from './syndicateSchema.ts';
58
58
  export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logProviderStatuses, resolveModel, describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.ts';
59
59
  export { loadEnv } from './loadEnv.ts';
60
+ export { setCredentialSource, credentialValue } from './models/credentialSource.ts';
61
+ export type { CredentialSource } from './models/credentialSource.ts';
package/dist/lib/index.js CHANGED
@@ -48,3 +48,5 @@ export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logP
48
48
  // subpath until 0.16.0.
49
49
  describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.js';
50
50
  export { loadEnv } from './loadEnv.js';
51
+ // Where credentials are read, by environment variable name (ADR 0133).
52
+ export { setCredentialSource, credentialValue } from './models/credentialSource.js';
@@ -261,6 +261,10 @@ function anchorSpecPaths(config, dir) {
261
261
  for (const entry of agent?.openapi ?? [])
262
262
  if (!path.isAbsolute(entry.spec))
263
263
  entry.spec = path.resolve(dir, entry.spec);
264
+ // A stdio server's working directory, like a spec, is the YAML's (ADR 0132).
265
+ for (const entry of agent?.mcp_servers ?? [])
266
+ if (entry.cwd && !path.isAbsolute(entry.cwd))
267
+ entry.cwd = path.resolve(dir, entry.cwd);
264
268
  };
265
269
  anchor(config.orchestrator);
266
270
  for (const sub of config.subagents ?? [])
@@ -37,6 +37,7 @@ import { providerForModel } from '../models/providerMap.js';
37
37
  import { resolveModel } from '../models/registry.js';
38
38
  import { startEngineSpan, traceLlmGeneration } from '../observability/tracer.js';
39
39
  import { trimTrailingSlashes } from '../models/urls.js';
40
+ import { credentialEnv } from '../models/credentialSource.js';
40
41
  /**
41
42
  * Extraction on any model id. The key passed here reaches only a model of
42
43
  * the matching provider (resolveModel's BYOK rule); other providers read
@@ -194,7 +195,7 @@ export const DEFAULT_EMBEDDING_MODELS = {
194
195
  * already passes for memory; it is used only when the configured extraction
195
196
  * model or embedder is Gemini.
196
197
  */
197
- export function memoryProvidersFromEnv(env = process.env, geminiKey) {
198
+ export function memoryProvidersFromEnv(env = credentialEnv(), geminiKey) {
198
199
  const extractionModel = env.MEMORY_EXTRACTION_MODEL?.trim() || MEMORY_EXTRACTION_MODEL;
199
200
  const extractor = modelExtractor({
200
201
  model: extractionModel,
@@ -47,6 +47,7 @@ import { currentTurnStart, providerStateOf, withProviderState } from './provider
47
47
  import { errorDecision, errorText } from './errorResponse.js';
48
48
  import { adaptiveThinkingFor, claudeGeneration, claudeReasoningOf, claudeUrlImagesOn, THINKING_BINDING_BETA } from './claudeModels.js';
49
49
  import { isAboveHigh } from './reasoning.js';
50
+ import { credentialValue } from './credentialSource.js';
50
51
  /** The provider id this adapter reports and writes its state under (lib/models/providerMap.ts). */
51
52
  export const ANTHROPIC_PROVIDER = 'anthropic';
52
53
  /** The providerState kind this adapter writes: the signed blocks that preceded a part. */
@@ -217,7 +218,7 @@ export class ClaudeAdapter {
217
218
  yield setupFailure('ENDPOINT_MISCONFIGURED', messageOf(err));
218
219
  return;
219
220
  }
220
- const spec = claudeClientSpec(endpoint, this.#apiKey || endpoint.apiKey || process.env.ANTHROPIC_API_KEY);
221
+ const spec = claudeClientSpec(endpoint, this.#apiKey || endpoint.apiKey || credentialValue('ANTHROPIC_API_KEY'));
221
222
  if ('error' in spec) {
222
223
  yield setupFailure(endpoint.platform === 'direct' ? 'MISSING_API_KEY' : 'ENDPOINT_MISCONFIGURED', spec.error);
223
224
  return;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * lib/models/credentialSource.ts — where the engine reads a credential by
3
+ * its environment variable name (ADR 0133).
4
+ *
5
+ * By default every provider key, gateway key and embedding key is read from
6
+ * `process.env`. An embedding application that holds keys elsewhere (an OS
7
+ * keychain, a secret manager) sets a source once:
8
+ *
9
+ * setCredentialSource((name) => keychain.get(name));
10
+ *
11
+ * and every read below asks the source first, by the same names the
12
+ * environment uses (ANTHROPIC_API_KEY, MODEL_GATEWAY_API_KEY, …), at the
13
+ * moment of use: nothing is cached, so a key the user removes stops working
14
+ * on the next call. A source that returns undefined for a name falls back to
15
+ * the environment. A dependency-free leaf, so providerMap.ts stays one.
16
+ */
17
+ /** Returns the credential held under an environment variable name, or undefined. Must not throw. */
18
+ export type CredentialSource = (name: string) => string | undefined;
19
+ /** Sets (or, with undefined, clears) the process's credential source. */
20
+ export declare function setCredentialSource(next: CredentialSource | undefined): void;
21
+ /** The credential named `name`: the source's, else `env`'s. A source that throws counts as holding nothing. */
22
+ export declare function credentialValue(name: string, env?: NodeJS.ProcessEnv): string | undefined;
23
+ /**
24
+ * `env` as the engine's env-reading helpers see it: a read by name asks the
25
+ * source first. For functions that take an `env` parameter; enumeration and
26
+ * spreading see only `env`'s own variables.
27
+ */
28
+ export declare function credentialEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;