@agfpd/iapeer 0.4.124 → 0.4.125
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/docs/08-peer-management.md +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +12 -1
- package/src/verify/mcpParity.ts +82 -0
|
@@ -53,7 +53,7 @@ iapeer verify # report divergences
|
|
|
53
53
|
iapeer verify --fix # repair
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
`verify` checks
|
|
56
|
+
`verify` checks three things: profiles' conformance to the standard, divergences between the registry and the local profiles, and **MCP parity across a peer's agent runtimes** — a server declared for one runtime of the peer and missing for another is an error naming the peer, the server and both sides (`✗ p: MCP server "x" is in [codex] but MISSING in [claude]`). Peer-level sources: claude `.mcp.json`, codex `<cwd>/.codex/config.toml`, opencode host-default + per-peer `opencode.json` + fragments; the core `iapeer` server is excluded (wired on every runtime by construction). `--fix` does not copy MCP servers between runtimes — their configs differ in form; the report says what to add where. With `--fix` the command self-heals the registry from the profiles (and migrates profiles from the old runtime-field format to the current one along the way). It's the standard way to put the registry in order if it's gone out of sync.
|
|
57
57
|
|
|
58
58
|
**The daemon reconciles continuously (0.4.104).** The local `peer-profile.json` is the source of truth and the registry only its projection — but until 0.4.104 a direct edit of the source (the owner's legitimate move) propagated only when some reindexing verb happened to run afterwards; the router reads the INDEX per-request, so a stale projection mis-routed deliveries (measured live 17.07.2026: a peer's local profile said `default_runtime: claude` for days while the index kept `codex`, and deliveries went to a mute codex session). The daemon now runs a profile-sync sweep (`daemon/profilesync.ts`, 60 s, mtime/set-gated — steady state is stats only, zero writes): any change to a registered peer's local profile triggers the same locked `reindexFromLocals` the verbs use, and a boot sweep heals drift accumulated while the daemon was down. Heals are logged to `lifecycle.log` as `profile-reindex` events. `verify` remains the read-only report and the manual lever; the daemon closes the propagation gap.
|
|
59
59
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -2336,6 +2336,17 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2336
2336
|
legacyRuntimeProfiles.push({ personality: peer.personality, cwd: peer.cwd })
|
|
2337
2337
|
}
|
|
2338
2338
|
}
|
|
2339
|
+
// MCP parity across the peer's agent runtimes: a server on one endpoint and missing on another
|
|
2340
|
+
// leaves the SAME peer silently weaker there (incident 17.10 — domain MCPs only in codex config).
|
|
2341
|
+
const { mcpParity } = await import('../verify/mcpParity.ts')
|
|
2342
|
+
let mcpDrift = 0
|
|
2343
|
+
for (const peer of index.peers) {
|
|
2344
|
+
for (const issue of mcpParity(peer.personality, peer.cwd, peer.runtimes, { env })) {
|
|
2345
|
+
errors++
|
|
2346
|
+
mcpDrift++
|
|
2347
|
+
lines.push(`✗ ${peer.personality}: MCP server "${issue.server}" is in [${issue.presentIn.join(', ')}] but MISSING in [${issue.missingIn.join(', ')}]`)
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2339
2350
|
const reconcile = reconcileIndex({ env })
|
|
2340
2351
|
for (const r of reconcile) {
|
|
2341
2352
|
if (r.drift === null) {
|
|
@@ -2346,7 +2357,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2346
2357
|
}
|
|
2347
2358
|
}
|
|
2348
2359
|
if (flags.json === true) {
|
|
2349
|
-
out(JSON.stringify({ peers: index.peers.length, errors, drift: driftCount, reconcile }, null, 2) + '\n')
|
|
2360
|
+
out(JSON.stringify({ peers: index.peers.length, errors, drift: driftCount, mcpDrift, reconcile }, null, 2) + '\n')
|
|
2350
2361
|
} else {
|
|
2351
2362
|
for (const l of lines) out(l + '\n')
|
|
2352
2363
|
out(`\n${index.peers.length} peers · ${errors} error(s) · ${driftCount} index↔local drift(s)\n`)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// MCP parity across a peer's agent runtimes (verify). One personality is one agent with several
|
|
2
|
+
// endpoints; a tool server the peer has on one runtime but not on another makes the SAME peer silently
|
|
3
|
+
// weaker there (incident 17.10: after a claude-side redeploy impact-finder / mrmechanic /
|
|
4
|
+
// company-checker had their domain MCP servers only in .codex/config.toml — the claude session ran
|
|
5
|
+
// without its tools and nobody noticed). This reads each runtime's PEER-LEVEL MCP declarations and
|
|
6
|
+
// reports every server present on some runtime and missing on another.
|
|
7
|
+
//
|
|
8
|
+
// Sources (peer level only — host-wide runtime config is shared by all peers and not a peer fact):
|
|
9
|
+
// claude — <cwd>/.mcp.json mcpServers.<name>
|
|
10
|
+
// codex — <cwd>/.codex/config.toml [mcp_servers.<name>]
|
|
11
|
+
// opencode — host-default + per-peer opencode.json `mcp` and the registered fragments (docs/20)
|
|
12
|
+
// The core's own `iapeer` server is excluded: it is wired by the core on every runtime by
|
|
13
|
+
// construction (host-wide for codex), so comparing it would only produce false alarms.
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { opencodeHostConfigPath, type StorageOptions } from '../storage/index.ts'
|
|
17
|
+
import { OPENCODE_MEMORY_FRAGMENT, opencodePaths } from '../opencode/config.ts'
|
|
18
|
+
import { listCapabilities } from '../capability/index.ts'
|
|
19
|
+
|
|
20
|
+
const CORE_SERVER = 'iapeer'
|
|
21
|
+
|
|
22
|
+
function jsonKeys(path: string, pick: (o: Record<string, unknown>) => unknown): string[] {
|
|
23
|
+
try {
|
|
24
|
+
const o = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
25
|
+
const v = pick(o)
|
|
26
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v as object) : []
|
|
27
|
+
} catch {
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `[mcp_servers.<name>]` / `[mcp_servers."<name>"]` table headers (sub-tables like `.env` ignored). */
|
|
33
|
+
export function codexMcpNames(toml: string): string[] {
|
|
34
|
+
const out = new Set<string>()
|
|
35
|
+
for (const m of toml.matchAll(/^\s*\[\s*mcp_servers\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\s*\]/gm)) out.add((m[1] ?? m[2])!)
|
|
36
|
+
return [...out]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function peerMcpServers(runtime: string, personality: string, cwd: string, options: StorageOptions = {}): string[] | null {
|
|
40
|
+
let names: string[]
|
|
41
|
+
if (runtime === 'claude') {
|
|
42
|
+
names = jsonKeys(join(cwd, '.mcp.json'), o => o.mcpServers)
|
|
43
|
+
} else if (runtime === 'codex') {
|
|
44
|
+
const p = join(cwd, '.codex', 'config.toml')
|
|
45
|
+
names = existsSync(p) ? codexMcpNames(readFileSync(p, 'utf8')) : []
|
|
46
|
+
} else if (runtime === 'opencode') {
|
|
47
|
+
const paths = opencodePaths(personality, options)
|
|
48
|
+
const stems = [OPENCODE_MEMORY_FRAGMENT, ...listCapabilities(options).declarations.map(d => d.capability)]
|
|
49
|
+
names = [
|
|
50
|
+
...jsonKeys(opencodeHostConfigPath(options), o => o.mcp),
|
|
51
|
+
...jsonKeys(paths.config, o => o.mcp),
|
|
52
|
+
...stems.flatMap(s => jsonKeys(join(paths.dir, `${s}.json`), o => o.mcp)),
|
|
53
|
+
]
|
|
54
|
+
} else {
|
|
55
|
+
return null // not an agent runtime with an MCP client
|
|
56
|
+
}
|
|
57
|
+
return [...new Set(names)].filter(n => n !== CORE_SERVER).sort()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface McpParityIssue {
|
|
61
|
+
server: string
|
|
62
|
+
presentIn: string[]
|
|
63
|
+
missingIn: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every server some agent runtime of the peer declares and another lacks. */
|
|
67
|
+
export function mcpParity(personality: string, cwd: string, runtimes: readonly string[], options: StorageOptions = {}): McpParityIssue[] {
|
|
68
|
+
const sets = new Map<string, Set<string>>()
|
|
69
|
+
for (const rt of runtimes) {
|
|
70
|
+
const names = peerMcpServers(rt, personality, cwd, options)
|
|
71
|
+
if (names) sets.set(rt, new Set(names))
|
|
72
|
+
}
|
|
73
|
+
if (sets.size < 2) return []
|
|
74
|
+
const all = new Set([...sets.values()].flatMap(s => [...s]))
|
|
75
|
+
const issues: McpParityIssue[] = []
|
|
76
|
+
for (const server of [...all].sort()) {
|
|
77
|
+
const presentIn = [...sets].filter(([, s]) => s.has(server)).map(([rt]) => rt)
|
|
78
|
+
const missingIn = [...sets].filter(([, s]) => !s.has(server)).map(([rt]) => rt)
|
|
79
|
+
if (missingIn.length) issues.push({ server, presentIn, missingIn })
|
|
80
|
+
}
|
|
81
|
+
return issues
|
|
82
|
+
}
|