@gemus/mcp-proxy 0.1.2 → 0.1.5

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
@@ -1,4 +1,4 @@
1
- # @gemus/mcp-proxy (Issue #1751, P1 — WIP)
1
+ # @gemus/mcp-proxy (Issues #1751, #1756, #1917)
2
2
 
3
3
  Local stdio↔HTTP MCP proxy that gives **Codex desktop (direct-connect / Mode B)** users canvas
4
4
  image write-back. Bundled into the Codex plugin (`.codex-plugin/`), declared as a **command-based**
@@ -6,11 +6,13 @@ MCP server so codex spawns it locally; the proxy owns upstream auth to the remot
6
6
  (Codex does no OAuth for stdio servers). Full design: `docs/plans/1751-codex-desktop-companion.md`
7
7
  and `docs/architecture/agent architecture/codex-desktop-companion.md`.
8
8
 
9
- ## Status: minimal skeleton, behavioral checkpoint PASSED
9
+ ## Status: implemented
10
10
 
11
- `src/proxy.mjs` is the validated passthrough+tap skeleton. The backfill delivery layer (Stop-hook
12
- endpoint read rollout via `transcript_path` `ExecuteClaimTracker` → key-only `deliverImageToNode`)
13
- is the next build increment (plan Steps 2–4).
11
+ `src/proxy.mjs` is the production passthrough, execute-tap, and deterministic image-backfill
12
+ companion. A trusted Stop hook triggers `generated_images`/rollout discovery; `ExecuteClaimTracker`
13
+ then delivers an unambiguous image to its planned node or conservatively creates an orphan
14
+ `image-upload`. MCP initialization advertises the shared Codex capability contract used by the
15
+ server blueprint policy.
14
16
 
15
17
  ## What was validated end-to-end (2026-07-06, codex-cli 0.142.2 → local dev `/api/mcp`)
16
18
 
@@ -23,7 +25,8 @@ is the next build increment (plan Steps 2–4).
23
25
  `workflowId` from `arguments`, and **turn/thread identity from `_meta.x-codex-turn-metadata`
24
26
  (`thread_id`/`turn_id`)** — so per-turn scoping needs no Stop hook. `execute` is the same path.
25
27
  - **(c) turn-end + retrieval** — `Stop` hook fires post-flush carrying `transcript_path` → exact
26
- rollout; imagegen writes `generated_images/<session>/ig_<call_id>.png` + rollout `saved_path`+base64.
28
+ rollout; imagegen writes `generated_images/<session>/<sanitized-call-id>.png` (historically `ig_*`,
29
+ currently `call_*`) + rollout `saved_path`+base64.
27
30
  - **Session cap** — `KEY_ONLY_SESSION_CAP=3`/user is real; the proxy MUST `DELETE` its upstream
28
31
  session on close (implemented in `shutdown()`) to avoid leaking slots.
29
32
 
@@ -53,4 +56,4 @@ GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp PROXY_LOG=/tmp/proxy.l
53
56
  ```
54
57
 
55
58
  Env: `GEMUS_KEY` (required), `GEMUS_URL` (default `https://gemus.ai/api/mcp`), `PROXY_LOG` (optional
56
- debug log), `PROXY_BACKFILL_IDLE_MS` (default 30000 — turn-idle fallback before salvage-orphan).
59
+ debug log), `PROXY_BACKFILL_IDLE_MS` (default 180000 — turn-idle fallback before salvage-orphan).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemus/mcp-proxy",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Local stdio<->HTTP MCP proxy for Codex desktop: transparent passthrough + execute tap + imagegen backfill (Issue #1751, P1).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
- "@gemus/codex-backfill-core": "0.1.1"
28
+ "@gemus/codex-backfill-core": "0.1.3"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=18"
@@ -74,7 +74,7 @@ beforeEach(() => {
74
74
  mocks.readRollout.mockReset().mockReturnValue('')
75
75
  mocks.findRolloutFile.mockReset().mockReturnValue('/fake/rollout.jsonl')
76
76
  // Default: generated_images unavailable → backfill falls back to the rollout source (existing tests).
77
- mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [] })
77
+ mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [], failedCallIds: [] })
78
78
  })
79
79
 
80
80
  describe('normalizeToolName (tap → tracker)', () => {
@@ -92,14 +92,25 @@ describe('normalizeToolName (tap → tracker)', () => {
92
92
  })
93
93
 
94
94
  describe('injectSteering (Mode-B initialize instructions)', () => {
95
- it('appends companion steering to the initialize response instructions', () => {
95
+ it('appends a search-neutral skill bootstrap to initialize instructions', () => {
96
96
  const msg = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', serverInfo: { name: 'gemus' } } }
97
97
  expect(injectSteering(msg)).toBe(true)
98
- expect(msg.result.instructions).toContain('auto-backfilled')
99
- expect(msg.result.instructions).toContain('NEVER') // don't self-forward
98
+ expect(msg.result.instructions).toContain('gemus-getting-started')
99
+ expect(msg.result.instructions).toContain('gemus-codex-imagen')
100
100
  expect(msg.result.instructions).toBe(COMPANION_STEERING)
101
101
  })
102
102
 
103
+ it('does not pollute deferred search documents with registered tool names', () => {
104
+ const toolNames = [
105
+ 'canvas_read', 'view_image', 'node_list', 'workflow_list', 'canvas_edit', 'ppt_edit',
106
+ 'open_canvas', 'blueprint', 'execute', 'batch_execute', 'recall_history',
107
+ ]
108
+ for (const toolName of toolNames) {
109
+ const exactName = new RegExp(`(?<![A-Za-z0-9_])${toolName}(?![A-Za-z0-9_])`, 'i')
110
+ expect(COMPANION_STEERING, `companion steering references ${toolName}`).not.toMatch(exactName)
111
+ }
112
+ })
113
+
103
114
  it('preserves any existing server instructions (appends, not replaces)', () => {
104
115
  const msg = { id: 1, result: { protocolVersion: '1', instructions: 'server says hi' } }
105
116
  injectSteering(msg)
@@ -304,7 +315,7 @@ describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
304
315
  // generated_images available with one fresh image → file source wins, rollout untouched.
305
316
  mocks.readGeneratedImageFiles.mockReturnValue({
306
317
  available: true,
307
- images: [{ callId: 'ig_a', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
318
+ images: [{ callId: 'call_current', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
308
319
  })
309
320
 
310
321
  const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
@@ -312,7 +323,10 @@ describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
312
323
  expect(mocks.readGeneratedImageFiles).toHaveBeenCalled()
313
324
  expect(mocks.readRollout).not.toHaveBeenCalled() // file source is authoritative when available
314
325
  expect(r.delivered).toBe(1)
315
- expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ nodeId: 'nodeA', image: { callId: 'ig_a' } })
326
+ expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({
327
+ nodeId: 'nodeA',
328
+ image: { callId: 'call_current' },
329
+ })
316
330
  })
317
331
 
318
332
  it('available but empty → no delivery, and does NOT fall back to rollout (mtime guard preserved)', async () => {
@@ -339,4 +353,42 @@ describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
339
353
  expect(mocks.readRollout).toHaveBeenCalledWith('/x')
340
354
  expect(r.delivered).toBe(1)
341
355
  })
356
+
357
+ it('exec-* file-source image (opaque call-id) is delivered exactly once (#1968)', async () => {
358
+ const bf = makeBackfiller()
359
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
360
+ mocks.readGeneratedImageFiles.mockReturnValue({
361
+ available: true,
362
+ images: [{ callId: 'exec-3eb2a29c', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
363
+ failedCallIds: [],
364
+ })
365
+
366
+ const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
367
+
368
+ expect(mocks.readRollout).not.toHaveBeenCalled()
369
+ expect(r.delivered).toBe(1)
370
+ expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
371
+ expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({
372
+ nodeId: 'nodeA',
373
+ image: { callId: 'exec-3eb2a29c' },
374
+ })
375
+ })
376
+
377
+ it('unreadable fresh candidate is recovered from rollout by EXACT call-id only — no history bypass (#1968)', async () => {
378
+ const bf = makeBackfiller()
379
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
380
+ // File source: nothing readable, but one fresh candidate failed to read (torn write).
381
+ mocks.readGeneratedImageFiles.mockReturnValue({ available: true, images: [], failedCallIds: ['exec-torn'] })
382
+ // Rollout holds the failed id AND an unrelated historical image that must NOT be surfaced.
383
+ mocks.readRollout.mockReturnValue(rollout([['exec-torn', 'iVBORw0KGgoAAAA9'], ['ig_history', 'iVBORw0KGgoAAAA8']]))
384
+
385
+ const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
386
+
387
+ expect(mocks.readRollout).toHaveBeenCalledWith('/x')
388
+ expect(r.delivered).toBe(1)
389
+ expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
390
+ expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ image: { callId: 'exec-torn' } })
391
+ // The historical image was in the rollout but is NOT in failedCallIds → never delivered or orphaned.
392
+ expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
393
+ })
342
394
  })
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildProxyUpstreamHeaders } from '../mcpHeaders.mjs'
3
+
4
+ describe('buildProxyUpstreamHeaders', () => {
5
+ it('advertises the canonical Codex MCP capabilities alongside bearer authentication', () => {
6
+ expect(buildProxyUpstreamHeaders('mak_test123')).toEqual({
7
+ Authorization: 'Bearer mak_test123',
8
+ 'X-Gemus-Client-Capabilities': 'agent-authored-prompts,codex-imagen',
9
+ })
10
+ })
11
+ })
package/src/backfill.mjs CHANGED
@@ -34,27 +34,14 @@ export function normalizeToolName(name) {
34
34
  return WIRE_TO_NORMALIZED[name] ?? name
35
35
  }
36
36
 
37
- // Companion steering (Mode B has no bridge to inject CLIENT_DELIVERED_GUIDANCE into the system prompt).
38
- // This proxy IS the backfiller, so it tells codex via the MCP initialize `instructions` field — not to
39
- // self-forward imagegen output. Mirrors src/lib/agent/core/prompt/workflowBehavior.ts (condensed). Without
40
- // it codex hunts the file / uploads it to a public host to self-deliver (#1751 real-machine observation).
37
+ // Keep initialize instructions search-neutral. Codex appends this namespace text to every deferred
38
+ // tool's BM25 document, so operational rules and exact tool names live in installed plugin skills.
41
39
  export const COMPANION_STEERING = [
42
- '# Gemus companion — codex-imagen is auto-backfilled',
40
+ '# Gemus companion',
43
41
  '',
44
- 'A local Gemus companion backfills your own image generations onto the planned canvas nodes at turn',
45
- 'end. For a `codex-imagen` (gen-image) node: (1) create the node with config.model="codex-imagen",',
46
- '(2) call `execute` with dryRun:true and read the returned resolvedPrompt, (3) generate the image',
47
- 'LOCALLY with your built-in imagegen, then (4) STOP.',
48
- '',
49
- 'NEVER locate, read, host, upload, or submit the imagegen file yourself — you cannot access it and it',
50
- 'is delivered for you. NEVER pass `content` for a codex-imagen node, and NEVER spin up a local HTTP',
51
- 'server or upload the image to a public host to "deliver" it. A codex-imagen node showing no output /',
52
- 'clientDeliveredStatus:"awaiting_backfill" DURING your turn is ON TRACK, not a failure — do not re-read',
53
- 'it in a loop, do not switch its model to a platform provider (gemini/etc), do not self-forward. Just',
54
- 'end your turn; the image arrives on the node after it.',
55
- '',
56
- '(Text is the exception: codex-text has no backfill — self-forward with execute dryRun:false +',
57
- 'content:{text:"..."}.)',
42
+ 'Before using Gemus, load and follow the installed `gemus-getting-started` skill.',
43
+ 'For local image generation and turn-end backfill, also load `gemus-codex-imagen`.',
44
+ 'These skills are the authoritative operational guidance for this direct MCP connection.',
58
45
  ].join('\n')
59
46
 
60
47
  /**
@@ -150,17 +137,33 @@ export function createBackfiller({ server, apiKey, codexHome, call, log = () =>
150
137
  async function backfillTurnInner({ turnKey, sessionId, rolloutPath, salvageOnly }) {
151
138
  const t = turns.get(turnKey)
152
139
  const sid = sessionId || t?.sessionId
153
- // #1756 Layer 2: 优先读 generated_images 落盘文件(去 rollout-scrape 脆化)。三态 fallback——
154
- // 仅目录整体不可用(旧 codex 不落盘)才回退 rollout;目录可用即锁定文件源(即便过滤后空),
155
- // 否则会绕过 mtime 守卫把历史图从 rollout 重新捞出重复交付。
140
+ // #1756 Layer 2: 优先读 generated_images 落盘文件(去 rollout-scrape 脆化)。仅目录整体不可用
141
+ // (旧 codex 不落盘)才整源回退 rollout;目录可用即锁定文件源,否则会绕过 mtime 守卫把历史图从
142
+ // rollout 重新捞出重复交付。#1968:文件源里「已确认 fresh 但读失败」的候选(torn/locked write)
143
+ // 按精确 call-id 定向从 rollout 恢复——只捞这几个 id,不换整源、不越 mtime 守卫。
156
144
  const fileResult = sid
157
145
  ? core.readGeneratedImageFiles(codexHome, sid, procStartMs)
158
- : { available: false, images: [] }
146
+ : { available: false, images: [], failedCallIds: [] }
159
147
  let all
160
148
  let source
161
149
  if (fileResult.available) {
162
150
  all = fileResult.images
163
151
  source = 'generated_images'
152
+ const failedCallIds = fileResult.failedCallIds || []
153
+ if (failedCallIds.length) {
154
+ const path = rolloutPath || core.findRolloutFile(codexHome, sid)
155
+ const want = new Set(failedCallIds)
156
+ const recovered = core.extractImageOutputs(core.readRollout(path)).filter((img) => want.has(img.callId))
157
+ if (recovered.length) {
158
+ all = all.concat(recovered)
159
+ source = `${source}+rollout`
160
+ }
161
+ log('backfill: unreadable generated_images candidates', {
162
+ turnKey,
163
+ failed: failedCallIds.length,
164
+ recovered: recovered.length,
165
+ })
166
+ }
164
167
  } else {
165
168
  const path = rolloutPath || core.findRolloutFile(codexHome, sid)
166
169
  all = core.extractImageOutputs(core.readRollout(path))
@@ -0,0 +1,12 @@
1
+ import {
2
+ CLIENT_CAPABILITIES,
3
+ CLIENT_CAPABILITIES_HEADER,
4
+ serializeClientCapabilities,
5
+ } from '@gemus/codex-backfill-core'
6
+
7
+ export function buildProxyUpstreamHeaders(apiKey) {
8
+ return {
9
+ Authorization: `Bearer ${apiKey}`,
10
+ [CLIENT_CAPABILITIES_HEADER]: serializeClientCapabilities(CLIENT_CAPABILITIES),
11
+ }
12
+ }
package/src/proxy.mjs CHANGED
@@ -25,6 +25,7 @@ import fs from 'node:fs'
25
25
  import { randomBytes } from 'node:crypto'
26
26
  import { createBackfiller, injectSteering } from './backfill.mjs'
27
27
  import { unwrapMcpContent } from '@gemus/codex-backfill-core'
28
+ import { buildProxyUpstreamHeaders } from './mcpHeaders.mjs'
28
29
 
29
30
  const KEY = process.env.GEMUS_KEY || ''
30
31
  const GEMUS_URL = process.env.GEMUS_URL || 'https://gemus.ai/api/mcp'
@@ -244,7 +245,7 @@ if (typeof hookServer.unref === 'function') hookServer.unref()
244
245
 
245
246
  // ── transparent JSON-RPC passthrough (raw send/onmessage) ──────────────────────────────────────
246
247
  const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
247
- requestInit: { headers: { Authorization: `Bearer ${KEY}` } },
248
+ requestInit: { headers: buildProxyUpstreamHeaders(KEY) },
248
249
  })
249
250
  const stdio = new StdioServerTransport()
250
251