@oracle-agent/oracle 0.3.2 → 0.3.3

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 CHANGED
@@ -199,10 +199,14 @@ Oracle's default pack is deliberately broad but disarmed:
199
199
 
200
200
  - **Trader** — best-execution route comparison, quote/prepare, simulation, and
201
201
  receipt checks; no set-and-forget custody.
202
- - **Builder** protocol, NFT, gacha, DEX, and launchpad scaffolds with unsigned
203
- deploy/admin transactions.
202
+ - **Builder** - chain-family fungible-token and NFT-collection launch plans,
203
+ protocol, gacha, DEX, and launchpad scaffolds with unsigned deploy/admin actions.
204
+ Unsupported chain adapters fail closed instead of pretending one deploy fits all.
204
205
  - **Analyzer** — token, contract, venue, portfolio, market, and risk research with
205
- evidence labels.
206
+ evidence labels. `/balance` or plain `balance` runs one deterministic snapshot
207
+ across every configured EVM chain plus Solana, Bitcoin, Hyperliquid, and
208
+ discoverable NFTs. Profile-local observations power history and SVG value graphs;
209
+ unavailable values stay null instead of becoming fake zeroes.
206
210
  - **On-chain scanner** — chain-config scanners for tokens, pools, launches,
207
211
  risk, exits, and smart-wallet boards.
208
212
  - **Meme-token sniper** — fast launch/liquidity monitoring across configured
@@ -273,7 +277,8 @@ Oracle is a library. Drive it with Claude, GPT, Gemini, Grok, a local model, or
273
277
  a plain script — the tools are ordinary functions plus an MCP server:
274
278
 
275
279
  ```bash
276
- npx oracle-data-mcp # works with any MCP client
280
+ npx oracle-data # local read plane on 127.0.0.1:8787
281
+ npx oracle-data-mcp # MCP stdio server (any MCP client); needs oracle-data up
277
282
  ```
278
283
 
279
284
  It is better under [Hermes](https://claude-code.nousresearch.com/docs), because
@@ -294,7 +299,9 @@ Oracle ships an installable 8-lane mesh for Hermes:
294
299
 
295
300
  ```bash
296
301
  oracle-init # dry run -- shows exactly what it would do
297
- oracle-init --apply # create profiles, install SOULs + skills, wire MCP
302
+ oracle-init --apply # create profiles, install SOULs + skills, wire MCP config
303
+ npx oracle-data # keep running — MCP tools call the local read plane on :8787
304
+ hermes -p oracle chat # router lane; inherits whatever model Hermes already uses
298
305
  ```
299
306
 
300
307
  Lanes: `oracle` (router), `polymarket-agent`, `hyperliquid-agent`,
package/SETUP.md CHANGED
@@ -103,6 +103,39 @@ export BASE_RPC_URL=https://...
103
103
  export SOLANA_RPC_URL=https://...
104
104
  ```
105
105
 
106
+ `/balance` and plain `balance` need only public addresses. Set any families the
107
+ wallet uses, then start `oracle-data` from the same environment:
108
+
109
+ ```bash
110
+ export ORACLE_EVM_ADDRESS=0x...
111
+ export ORACLE_SOLANA_ADDRESS=...
112
+ export ORACLE_BITCOIN_ADDRESS=bc1...
113
+ export ORACLE_HYPERLIQUID_ADDRESS=0x... # optional, defaults to EVM address
114
+ npx oracle-data
115
+ ```
116
+
117
+ Missing address families are reported as `not-configured`, failed providers as
118
+ `unavailable`, and unsupported chain families as `unsupported`. They are never
119
+ reported as zero. EVM native balances cover every configured EVM chain. Solana
120
+ adds SPL and Token-2022 accounts, Bitcoin adds Runes and inscriptions when an
121
+ address indexer is configured, Hyperliquid adds spot and perp account state, and
122
+ NFT inventory adds OpenSea-supported EVM/Solana collections plus Bitcoin
123
+ inscriptions where owner indexers are available.
124
+
125
+ Every `portfolio_snapshot` call appends one compact observation to
126
+ `$HERMES_HOME/state/oracle/portfolio-history.jsonl` with mode `0600`. It stores a
127
+ public-address fingerprint, coverage, value breakdown, and warnings, not keys,
128
+ prepared transactions, or raw NFT metadata. Override the internal location only
129
+ when needed:
130
+
131
+ ```bash
132
+ export ORACLE_PORTFOLIO_HISTORY_FILE="$HOME/.local/state/oracle/portfolio-history.jsonl"
133
+ ```
134
+
135
+ `portfolio_history` reads those observations. `portfolio_value_graph` returns an
136
+ SVG chart of known priced value and omits unavailable observations instead of
137
+ plotting them as zero.
138
+
106
139
  ---
107
140
 
108
141
  ## Where keys are stored
@@ -202,7 +235,8 @@ Gemini, Grok, a local Llama, or your own script at it. The tools are plain
202
235
  functions and an MCP server.
203
236
 
204
237
  ```bash
205
- npx oracle-data-mcp # MCP stdio server, works with any MCP client
238
+ npx oracle-data # local read plane on 127.0.0.1:8787
239
+ npx oracle-data-mcp # MCP stdio server, works with any MCP client (needs oracle-data up)
206
240
  ```
207
241
 
208
242
  **Why [Hermes](https://claude-code.nousresearch.com/docs) is the better host: per-profile routing.**
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "id": "oracle-full-crypto",
3
3
  "name": "Oracle Full Crypto Pack",
4
- "version": 1,
4
+ "version": 4,
5
5
  "audience": "public-users",
6
- "description": "Default capability set for public Oracle: trader, builder, analyzer, multichain scanner, meme-token sniping, RFQ routing, tokenized-asset buys, NFT mint gas-war limits, Solana, Bitcoin inscriptions, NFT/gacha/DEX launches, and protocol build under bound grants. User signs everything.",
6
+ "description": "Default capability set for public Oracle: trader, builder, analyzer, deterministic /balance aggregation, multichain scanner, meme-token sniping, RFQ routing, tokenized-asset buys, NFT mint gas-war limits, Solana, Bitcoin inscriptions, chain-family token/NFT collections, gacha/DEX launches, and protocol build under bound grants. User signs everything.",
7
7
  "posture": {
8
8
  "default": "DISARMED",
9
9
  "custody": "self-custodial",
@@ -62,6 +62,8 @@
62
62
  "oracle-protocol-builder",
63
63
  "oracle-contract-research",
64
64
  "oracle-protocol-security",
65
+ "oracle-multichain-token-launch",
66
+ "oracle-multichain-nft-launch",
65
67
  "oracle-nft-gacha-launch",
66
68
  "oracle-dex-launch",
67
69
  "protocol-api-key-integration",
@@ -80,6 +82,8 @@
80
82
  "oracle-nft-gacha-launch",
81
83
  "oracle-dex-launch",
82
84
  "oracle-protocol-security",
85
+ "oracle-multichain-token-launch",
86
+ "oracle-multichain-nft-launch",
83
87
  "oracle-nft-mint-gas-war"
84
88
  ],
85
89
  "analysis": [
@@ -88,6 +92,7 @@
88
92
  "oracle-smart-wallet-scanner",
89
93
  "oracle-meme-token-sniper",
90
94
  "oracle-best-execution",
95
+ "balance",
91
96
  "oracle-chain-graphs-telegram-cards",
92
97
  "oracle-rfq-tokenized-assets"
93
98
  ]
@@ -109,7 +114,9 @@
109
114
  "write-deploy-scripts",
110
115
  "prepare-unsigned-deploy-txs",
111
116
  "security-checklist",
112
- "launchpad-public-surface"
117
+ "launchpad-public-surface",
118
+ "multichain-token-launch",
119
+ "multichain-nft-collection-launch"
113
120
  ],
114
121
  "user_signs": true,
115
122
  "grant_actions_example": [
@@ -119,7 +126,7 @@
119
126
  "prepare:verify"
120
127
  ],
121
128
  "targets": "user-provided factory/router/implementations only unless added to destination allowlist",
122
- "copy": "Oracle can design and prepare protocol, NFT/gacha, and DEX launches. You still sign. Grants bind chain, spend, and destinations."
129
+ "copy": "Oracle can classify and prepare chain-family token, NFT collection, protocol, gacha, and DEX launches. Unsupported adapters fail closed. You still sign every side effect."
123
130
  },
124
131
  "profiles": [
125
132
  {
@@ -201,10 +208,12 @@
201
208
  "oracle-protocol-security",
202
209
  "oracle-nft-gacha-launch",
203
210
  "oracle-dex-launch",
204
- "oracle-receipts"
211
+ "oracle-receipts",
212
+ "oracle-multichain-token-launch",
213
+ "oracle-multichain-nft-launch"
205
214
  ],
206
215
  "label": "protocol builder",
207
- "description": "Scaffold, review, and prepare deploys for NFT, gacha, DEX, and protocol launches."
216
+ "description": "Classify by chain family, then scaffold, review, and prepare unsigned token, NFT collection, gacha, DEX, and protocol launches."
208
217
  }
209
218
  ],
210
219
  "default_tools": {
@@ -216,7 +225,14 @@
216
225
  "btc_health",
217
226
  "btc_fees",
218
227
  "btc_inscription_info",
219
- "solana_balance"
228
+ "solana_balance",
229
+ "portfolio_balance",
230
+ "portfolio_snapshot",
231
+ "portfolio_history",
232
+ "portfolio_value_graph",
233
+ "nft_inventory",
234
+ "nft_gallery",
235
+ "nft_pnl"
220
236
  ],
221
237
  "prepare": [
222
238
  "evm_prepare",
@@ -224,7 +240,8 @@
224
240
  "evm_protocol_quote",
225
241
  "jupiter_quote",
226
242
  "jupiter_prepare",
227
- "bitcoin_inscribe_prepare"
243
+ "bitcoin_inscribe_prepare",
244
+ "nft_prepare_list"
228
245
  ],
229
246
  "never_public_default": [
230
247
  "evm_sign",
@@ -268,6 +285,11 @@
268
285
  "solana-nft-marketplace",
269
286
  "solana-nft-mint",
270
287
  "hypercore-hype-staking",
271
- "hypercore-validator-delegation"
288
+ "hypercore-validator-delegation",
289
+ "multichain-token-launch",
290
+ "multichain-nft-collection-launch",
291
+ "multichain-balance",
292
+ "nft-inventory-gallery-pnl",
293
+ "portfolio-balance-history-graph"
272
294
  ]
273
295
  }
@@ -2,14 +2,17 @@
2
2
  // Oracle data MCP — read-only multichain market/discovery plane.
3
3
  // Loaded by an Oracle/Hermes profile. Never expose signing or broadcast here.
4
4
  //
5
- // Every tool is a READ. No key, no signing, no broadcast. Routes to the Oracle
6
- // data server read plane (/data/call), which is unauthenticated on loopback.
5
+ // Every tool is a READ or a prepared unsigned action. No key handling, signing,
6
+ // or broadcast. Routes to the Oracle data server read/prepare plane (/data/call),
7
+ // which is unauthenticated on loopback.
7
8
  // This is the "scan all public APIs" surface: DeFiLlama protocol TVL, DexScreener
8
9
  // token/search, LI.FI + Uniswap quotes, multi-chain RPC balances/blocks.
9
10
  //
10
11
  // Mirrors the Oracle exec MCP stdio JSON-RPC shape.
11
12
 
13
+ import fs from "node:fs";
12
14
  import process from "node:process";
15
+ import { fileURLToPath } from "node:url";
13
16
  import { env } from "../src/oracle-env.mjs";
14
17
 
15
18
  const DATA_URL = (env("ORACLE_DATA_URL", "MAD_DESK_URL", "http://127.0.0.1:8787")).replace(/\/$/, "");
@@ -96,6 +99,201 @@ const tools = [
96
99
  },
97
100
  },
98
101
  },
102
+ {
103
+ name: "portfolio_balance",
104
+ description:
105
+ "Read-only balance aggregation across every configured EVM chain plus Solana, Bitcoin, and Hyperliquid. Reports native assets, supported token/collectible reads, failed or missing providers, unverified assets, timestamps, and known priced value without pretending it is a complete total. Public addresses only; never signs or broadcasts.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: {
109
+ addresses: {
110
+ type: "object",
111
+ description: "Optional public wallet addresses by family. Omit configured families to use ORACLE_*_ADDRESS defaults.",
112
+ properties: {
113
+ evm: { type: "string" },
114
+ solana: { type: "string" },
115
+ bitcoin: { type: "string" },
116
+ hyperliquid: { type: "string" },
117
+ },
118
+ },
119
+ evmChainIds: {
120
+ type: "array",
121
+ items: { type: "number" },
122
+ description: "Optional EVM chain subset. Default is every configured EVM chain.",
123
+ },
124
+ includeTokens: { type: "boolean", description: "Include supported token reads. Default true." },
125
+ includeCollectibles: { type: "boolean", description: "Include supported collectible reads. Default true." },
126
+ includePrices: { type: "boolean", description: "Fetch current DeFiLlama native prices. Default true." },
127
+ },
128
+ },
129
+ },
130
+ {
131
+ name: "portfolio_snapshot",
132
+ description:
133
+ "Query the current multichain balance and NFT inventory, then append one compact profile-local observation for historical tracking. Stores public-address fingerprint, coverage, breakdown, and known priced value only; no keys, signatures, executable payloads, or raw NFT metadata. Returns the live balance, NFT result, and recorded snapshot.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ addresses: {
138
+ type: "object",
139
+ properties: {
140
+ evm: { type: "string" },
141
+ solana: { type: "string" },
142
+ bitcoin: { type: "string" },
143
+ hyperliquid: { type: "string" },
144
+ },
145
+ },
146
+ evmChainIds: { type: "array", items: { type: "number" } },
147
+ includeTokens: { type: "boolean" },
148
+ includeCollectibles: { type: "boolean" },
149
+ includePrices: { type: "boolean" },
150
+ includeNfts: { type: "boolean", description: "Include provider-estimated NFT value. Default true." },
151
+ },
152
+ },
153
+ },
154
+ {
155
+ name: "portfolio_history",
156
+ description:
157
+ "Read profile-local portfolio observations in a time range. Unknown or unavailable values remain null and are never converted to zero. Defaults to the configured public-address portfolio fingerprint.",
158
+ inputSchema: {
159
+ type: "object",
160
+ properties: {
161
+ addresses: {
162
+ type: "object",
163
+ properties: {
164
+ evm: { type: "string" },
165
+ solana: { type: "string" },
166
+ bitcoin: { type: "string" },
167
+ hyperliquid: { type: "string" },
168
+ },
169
+ },
170
+ portfolioId: { type: "string" },
171
+ allPortfolios: { type: "boolean", description: "Return observations for every public-address fingerprint in this profile." },
172
+ since: { type: "string", description: "Inclusive ISO 8601 lower bound." },
173
+ until: { type: "string", description: "Inclusive ISO 8601 upper bound." },
174
+ limit: { type: "number", description: "1-1000, default 100." },
175
+ order: { type: "string", enum: ["asc", "desc"] },
176
+ },
177
+ },
178
+ },
179
+ {
180
+ name: "portfolio_value_graph",
181
+ description:
182
+ "Render a static SVG line graph from profile-local portfolio snapshots. Plots known priced value only, labels incomplete coverage, includes provider-estimated NFT values when recorded, and omits unavailable observations instead of plotting fake zeroes.",
183
+ inputSchema: {
184
+ type: "object",
185
+ properties: {
186
+ addresses: {
187
+ type: "object",
188
+ properties: {
189
+ evm: { type: "string" },
190
+ solana: { type: "string" },
191
+ bitcoin: { type: "string" },
192
+ hyperliquid: { type: "string" },
193
+ },
194
+ },
195
+ portfolioId: { type: "string" },
196
+ allPortfolios: { type: "boolean" },
197
+ since: { type: "string" },
198
+ until: { type: "string" },
199
+ limit: { type: "number", description: "Historical observations to consider, max 1000." },
200
+ maxPoints: { type: "number", description: "Rendered point cap, 2-500, default 200." },
201
+ },
202
+ },
203
+ },
204
+ {
205
+ name: "nft_inventory",
206
+ description:
207
+ "Read all discoverable NFTs for configured EVM, Solana, and Bitcoin addresses. Returns normalized assets, image URLs, estimated values, per-chain partial failures, flagged/NSFW items separated from visible items, listing coverage, and explicit unsupported chains. Read-only.",
208
+ inputSchema: {
209
+ type: "object",
210
+ properties: {
211
+ addresses: {
212
+ type: "object",
213
+ properties: {
214
+ evm: { type: "string" },
215
+ solana: { type: "string" },
216
+ bitcoin: { type: "string" },
217
+ },
218
+ },
219
+ chains: { type: "array", items: { type: "string" }, description: "Optional chain slug subset." },
220
+ pageSize: { type: "number", description: "OpenSea items per page, max 200." },
221
+ maxPages: { type: "number", description: "Maximum pages per chain, max 25." },
222
+ bitcoinLimit: { type: "number" },
223
+ includePnl: { type: "boolean", description: "Include OpenSea-indexed account PnL when available. Default true." },
224
+ costBasis: { type: "object", description: "Optional user-supplied assetKey -> {costUsd,feesUsd,acquiredAt,source}. Missing basis stays unavailable, never zero." },
225
+ },
226
+ },
227
+ },
228
+ {
229
+ name: "nft_gallery",
230
+ description:
231
+ "Generate one static NFT contact-sheet image page from wallet inventory or supplied normalized items. External metadata is never rendered as HTML; only allowlisted JPEG/PNG/WebP bytes are embedded and unsafe or missing media becomes a placeholder.",
232
+ inputSchema: {
233
+ type: "object",
234
+ properties: {
235
+ addresses: {
236
+ type: "object",
237
+ properties: { evm: { type: "string" }, solana: { type: "string" }, bitcoin: { type: "string" } },
238
+ },
239
+ chains: { type: "array", items: { type: "string" } },
240
+ items: { type: "array", items: { type: "object" }, description: "Optional normalized inventory items. Omit to scan configured wallets." },
241
+ page: { type: "number", description: "1-based gallery page." },
242
+ pageSize: { type: "number", description: "1-12 items per page." },
243
+ },
244
+ },
245
+ },
246
+ {
247
+ name: "nft_pnl",
248
+ description:
249
+ "NFT PnL with explicit methodology. Returns OpenSea-indexed account-level PnL where available plus per-item unrealized estimates only when both user-supplied acquisition cost and provider estimated current value exist. Missing history is unavailable, never zero.",
250
+ inputSchema: {
251
+ type: "object",
252
+ properties: {
253
+ addresses: {
254
+ type: "object",
255
+ properties: { evm: { type: "string" }, solana: { type: "string" }, bitcoin: { type: "string" } },
256
+ },
257
+ chains: { type: "array", items: { type: "string" } },
258
+ costBasis: { type: "object" },
259
+ },
260
+ },
261
+ },
262
+ {
263
+ name: "nft_prepare_list",
264
+ description:
265
+ "Prepare an NFT listing for OpenSea EVM, Magic Eden Solana, or Satflow Bitcoin after explicit user review. Requires userConfirmed=true plus asset, marketplace, price, currency where applicable, and future expiry. Returns approval/signing actions, an unsigned Solana transaction, or an unsigned Bitcoin PSBT. Never signs, submits, or broadcasts.",
266
+ inputSchema: {
267
+ type: "object",
268
+ required: ["marketplace", "userConfirmed"],
269
+ properties: {
270
+ marketplace: { type: "string", enum: ["opensea", "magiceden", "magiceden-sol", "satflow"] },
271
+ userConfirmed: { type: "boolean" },
272
+ chain: { type: "string" },
273
+ seller: { type: "string" },
274
+ contract: { type: "string" },
275
+ tokenId: { type: "string" },
276
+ quantity: { type: "number" },
277
+ priceAmount: { type: "string" },
278
+ currency: { type: "string" },
279
+ endTime: { type: "string" },
280
+ useCreatorFee: { type: "boolean" },
281
+ taker: { type: "string" },
282
+ tokenMint: { type: "string" },
283
+ tokenATA: { type: "string" },
284
+ auctionHouse: { type: "string" },
285
+ priceSol: { type: "string" },
286
+ expiry: { description: "Future ISO 8601 timestamp or Unix seconds." },
287
+ inscriptionId: { type: "string" },
288
+ runesOutput: { type: "string" },
289
+ ordAddress: { type: "string" },
290
+ receiveAddress: { type: "string" },
291
+ priceSats: { type: "string" },
292
+ tapKey: { type: "string" },
293
+ collectionSlug: { type: "string" },
294
+ },
295
+ },
296
+ },
99
297
  {
100
298
  name: "rpc_balance",
101
299
  description:
@@ -427,6 +625,24 @@ const ROUTES = {
427
625
  fromAddress: a.fromAddress || DEFAULT_ADDRESS || undefined,
428
626
  },
429
627
  }),
628
+ portfolio_balance: (a) => ({
629
+ provider: "portfolio",
630
+ op: "balances",
631
+ args: {
632
+ addresses: a.addresses,
633
+ evmChainIds: a.evmChainIds,
634
+ includeTokens: a.includeTokens,
635
+ includeCollectibles: a.includeCollectibles,
636
+ includePrices: a.includePrices,
637
+ },
638
+ }),
639
+ portfolio_snapshot: (a) => ({ provider: "portfolio", op: "snapshot", args: a }),
640
+ portfolio_history: (a) => ({ provider: "portfolio", op: "history", args: a }),
641
+ portfolio_value_graph: (a) => ({ provider: "portfolio", op: "valueGraph", args: a }),
642
+ nft_inventory: (a) => ({ provider: "nft-portfolio", op: "inventory", args: a }),
643
+ nft_gallery: (a) => ({ provider: "nft-portfolio", op: "gallery", args: a }),
644
+ nft_pnl: (a) => ({ provider: "nft-portfolio", op: "pnl", args: a }),
645
+ nft_prepare_list: (a) => ({ provider: "nft-portfolio", op: "prepareList", args: a }),
430
646
  rpc_balance: (a) => {
431
647
  const addr = a.address || DEFAULT_ADDRESS;
432
648
  if (!addr) throw new Error("address required (set ORACLE_DEFAULT_ADDRESS to supply a default)");
@@ -567,7 +783,21 @@ async function handle(request) {
567
783
  if (method === "tools/call") {
568
784
  try {
569
785
  const result = await callTool(params?.name, params?.arguments || {});
570
- send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } });
786
+ if (result?.dataBase64 && /^image\/(?:svg\+xml|png|jpeg|webp)$/.test(String(result.mimeType || ""))) {
787
+ const { dataBase64, ...metadata } = result;
788
+ send({
789
+ jsonrpc: "2.0",
790
+ id,
791
+ result: {
792
+ content: [
793
+ { type: "image", data: dataBase64, mimeType: result.mimeType },
794
+ { type: "text", text: JSON.stringify(metadata, null, 2) },
795
+ ],
796
+ },
797
+ });
798
+ } else {
799
+ send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } });
800
+ }
571
801
  } catch (err) {
572
802
  send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: err.message }] } });
573
803
  }
@@ -596,7 +826,20 @@ function startStdioLoop() {
596
826
  });
597
827
  }
598
828
 
599
- if (import.meta.url === `file://${process.argv[1]}`) {
829
+ // npm bin wrappers are symlinks. Comparing import.meta.url to argv[1] as strings
830
+ // fails for `npx oracle-data-mcp` / node_modules/.bin/oracle-data-mcp and the
831
+ // process exits without ever starting the stdio loop (Hermes sees Connection closed).
832
+ function isMainModule() {
833
+ const entry = process.argv[1];
834
+ if (!entry) return false;
835
+ try {
836
+ return fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url));
837
+ } catch {
838
+ return false;
839
+ }
840
+ }
841
+
842
+ if (isMainModule()) {
600
843
  startStdioLoop();
601
844
  }
602
845
 
@@ -176,6 +176,71 @@ function record(kind, detail) {
176
176
  log(`${APPLY ? " ✔" : " ·"} ${kind}: ${detail}`);
177
177
  }
178
178
 
179
+ // Write Hermes mcp_servers.oracle-data directly into the profile config.
180
+ // Prefer this over `hermes mcp add`: current Hermes takes --command and --args
181
+ // as separate tokens, prompts interactively for tool enablement, and the old
182
+ // installer form `--command "node <path>"` silently no-ops under execFileSync.
183
+ function yamlScalar(value) {
184
+ if (
185
+ value === "" ||
186
+ /[:#\[\]{},&*!|>'"%@`\s]/.test(value) ||
187
+ /^(?:null|true|false|\d+)$/i.test(value)
188
+ ) {
189
+ return JSON.stringify(value);
190
+ }
191
+ return value;
192
+ }
193
+
194
+ function mcpServerBlock(serverName, scriptPath) {
195
+ return [
196
+ ` ${serverName}:`,
197
+ " command: node",
198
+ " args:",
199
+ ` - ${yamlScalar(scriptPath)}`,
200
+ " enabled: true",
201
+ ].join("\n");
202
+ }
203
+
204
+ function wireMcpIntoConfig(configPath, serverName, scriptPath) {
205
+ const entry = mcpServerBlock(serverName, scriptPath);
206
+ if (!fs.existsSync(configPath)) {
207
+ fs.writeFileSync(configPath, `mcp_servers:\n${entry}\n`, "utf8");
208
+ return "created";
209
+ }
210
+
211
+ let txt = fs.readFileSync(configPath, "utf8");
212
+ if (
213
+ txt.includes(scriptPath) &&
214
+ new RegExp(`^\\s*${serverName}:\\s*$`, "m").test(txt)
215
+ ) {
216
+ return "present";
217
+ }
218
+
219
+ // Replace an existing server block of the same name (simple indented map).
220
+ const serverRe = new RegExp(
221
+ `^([ \\t]*)${serverName}:\\s*\\n(?:\\1[ \\t]+.*\\n)*`,
222
+ "m",
223
+ );
224
+ if (serverRe.test(txt)) {
225
+ txt = txt.replace(serverRe, `${entry}\n`);
226
+ fs.writeFileSync(configPath, txt, "utf8");
227
+ return "updated";
228
+ }
229
+
230
+ if (/^mcp_servers:\s*$/m.test(txt) || /^mcp_servers:\s*\n/m.test(txt)) {
231
+ txt = txt.replace(/^(mcp_servers:\s*\n)/m, `$1${entry}\n`);
232
+ } else if (/^_config_version:.*$/m.test(txt)) {
233
+ txt = txt.replace(
234
+ /^(_config_version:.*\n)/m,
235
+ `$1mcp_servers:\n${entry}\n`,
236
+ );
237
+ } else {
238
+ txt = `mcp_servers:\n${entry}\n${txt}`;
239
+ }
240
+ fs.writeFileSync(configPath, txt, "utf8");
241
+ return "wired";
242
+ }
243
+
179
244
  // ---------------------------------------------------------------- main
180
245
 
181
246
  const schema = loadSchema();
@@ -363,34 +428,39 @@ for (const def of profiles) {
363
428
  }
364
429
  }
365
430
 
366
- // MCP: the read plane. This is the one step that genuinely needs the CLI, since
367
- // it edits the profile's own config. Without it, print the command instead.
368
- for (const m of def.mcp || []) {
369
- const cmd = path.join(ROOT, "bin", "oracle-data-mcp.mjs");
370
- if (!hermes) {
371
- record("mcp manual", `hermes -p ${def.id} mcp add ${m} --command "node ${cmd}"`);
372
- mcpManual.push({ lane: def.id, server: m, command: `hermes -p ${def.id} mcp add ${m} --command "node ${cmd}"` });
373
- continue;
374
- }
375
- record("wire mcp", `${m} (${def.id})`);
376
- if (APPLY && m === "oracle-data") {
377
- try {
378
- execFileSync(
379
- "hermes",
380
- ["-p", def.id, "mcp", "add", "oracle-data", "--command", `node ${cmd}`],
381
- { stdio: "pipe", timeout: 60_000 },
382
- );
383
- } catch {
384
- // Already registered, or this Hermes build wants different flags. Not fatal:
385
- // the profile is usable, it just needs the MCP wired by hand.
386
- record("mcp note", `could not auto-wire ${m} for ${def.id}; add it manually`);
387
- mcpManual.push({ lane: def.id, server: m, command: `hermes -p ${def.id} mcp add ${m} --command "node ${cmd}"` });
431
+ // MCP: write Hermes config directly. CLI is optional convenience only.
432
+ for (const m of def.mcp || []) {
433
+ const scriptPath = path.join(ROOT, "bin", "oracle-data-mcp.mjs");
434
+ const configPath = path.join(laneDir, "config.yaml");
435
+ const manual = `hermes -p ${def.id} mcp add ${m} --command node --args ${scriptPath}`;
436
+
437
+ if (m !== "oracle-data") {
438
+ record("mcp manual", manual);
439
+ mcpManual.push({ lane: def.id, server: m, command: manual });
440
+ continue;
441
+ }
442
+
443
+ record(
444
+ "wire mcp",
445
+ `${m} (${def.id}) -> ${path.relative(os.homedir(), configPath)}`,
446
+ );
447
+ if (APPLY) {
448
+ try {
449
+ fs.mkdirSync(laneDir, { recursive: true });
450
+ const how = wireMcpIntoConfig(configPath, m, scriptPath);
451
+ record(
452
+ "mcp config",
453
+ `${how}: ${path.relative(os.homedir(), configPath)}`,
454
+ );
455
+ } catch (err) {
456
+ record("mcp note", `could not write ${configPath}: ${err.message}`);
457
+ mcpManual.push({ lane: def.id, server: m, command: manual });
458
+ }
388
459
  }
389
460
  }
390
- }
391
461
 
392
- record("posture", `DISARMED (${(def.posture.grantActions || []).join(", ")})`);
393
- }
462
+ record("posture", `DISARMED (${(def.posture.grantActions || []).join(", ")})`);
463
+ }
394
464
 
395
465
  const summary = {
396
466
  ok: true,
@@ -414,10 +484,13 @@ if (JSON_OUT) {
414
484
  log("pass --force to overwrite (a timestamped .bak is written first).");
415
485
  }
416
486
  if (mcpManual.length) {
417
- log("\nMCP wiring needs the hermes CLI. Run these when it is available:");
487
+ log("\nMCP wiring needed a manual follow-up:");
418
488
  for (const m of mcpManual) log(` ${m.command}`);
419
489
  }
420
490
  if (!APPLY) log("re-run with --apply to make changes.");
421
- log("\nEvery lane is DISARMED. Set each lane's model in:");
491
+ log("\nEvery lane is DISARMED. Start the read plane, then chat a lane:");
492
+ log(" npx oracle-data # 127.0.0.1:8787 — MCP tools need this");
493
+ log(" hermes -p oracle chat");
494
+ log("\nSet each lane's model in:");
422
495
  log(` ${path.join(hermesRoot(), "profiles", "<lane>", "config.yaml")}`);
423
496
  }