machine-bridge-mcp 0.2.1 → 0.2.4

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/src/local/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import path, { resolve } from "node:path";
4
4
  import process from "node:process";
5
5
  import readline from "node:readline/promises";
6
6
  import { LocalDaemon } from "./daemon.mjs";
7
- import { startLocalApiServer, DEFAULT_API_HOST, DEFAULT_API_PORT, DEFAULT_API_MODEL, DEFAULT_UPSTREAM_URL } from "./api-server.mjs";
7
+ import { startLocalApiServer, DEFAULT_API_HOST, DEFAULT_API_PORT, DEFAULT_API_MODEL } from "./api-server.mjs";
8
8
  import { createLogger, redactSecret } from "./log.mjs";
9
9
  import { runWrangler } from "./shell.mjs";
10
10
  import {
@@ -257,6 +257,7 @@ async function apiCommand(args) {
257
257
  keepProcessAlive({ apiServer, logger });
258
258
  }
259
259
 
260
+
260
261
  async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" })) {
261
262
  const apiOptions = apiOptionsFromArgs(state, args);
262
263
  return startLocalApiServer({ ...apiOptions, logger });
@@ -278,7 +279,7 @@ async function startOptionalApiServer(state, args, parentLogger) {
278
279
  parentLogger.warn(error.message);
279
280
  return null;
280
281
  }
281
- parentLogger.warn(`Local API provider skipped: ${error.message}`);
282
+ parentLogger.warn(`Local API skipped: ${error.message}`);
282
283
  return null;
283
284
  }
284
285
  }
@@ -304,28 +305,25 @@ async function probeLocalApiHealth(baseUrl, expectedApiKey = "") {
304
305
  }
305
306
 
306
307
  function apiOptionsFromArgs(state, args = {}) {
307
- const upstreamKey = valueFromArgsEnv(args.apiUpstreamKey, "MBM_API_UPSTREAM_KEY", "OPENAI_API_KEY");
308
308
  const explicitPort = explicitArg(args.apiPort) ?? explicitArg(args.port);
309
309
  const envPort = valueFromArgsEnv(undefined, "MBM_API_PORT", "PORT");
310
310
  return {
311
311
  host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || state.localApi?.host || DEFAULT_API_HOST,
312
312
  port: explicitPort ?? envPort ?? state.localApi?.port ?? DEFAULT_API_PORT,
313
313
  apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY") || state.localApi?.apiKey || ensureLocalApiKey(state, { rotateApiKey: Boolean(args.rotateApiKey) }),
314
- upstreamUrl: valueFromArgsEnv(args.apiUpstreamUrl, "MBM_API_UPSTREAM_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE") || state.localApi?.upstreamUrl || DEFAULT_UPSTREAM_URL,
315
- upstreamKey: upstreamKey || "",
316
- model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL", "OPENAI_MODEL") || state.localApi?.model || DEFAULT_API_MODEL,
314
+ model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL") || state.localApi?.model || DEFAULT_API_MODEL,
315
+ workerUrl: state.worker?.url || "",
316
+ daemonSecret: state.worker?.daemonSecret || "",
317
317
  };
318
318
  }
319
319
 
320
320
  function configureLocalApiState(state, args = {}) {
321
321
  state.localApi ||= {};
322
- ensureLocalApiKey(state, { apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY"), rotateApiKey: Boolean(args.rotateApiKey) });
322
+ ensureLocalApiKey(state, { apiKey: explicitArg(args.apiKey), rotateApiKey: Boolean(args.rotateApiKey) });
323
323
  const port = explicitArg(args.apiPort) ?? explicitArg(args.port);
324
324
  if (port !== undefined && String(port) !== "0") state.localApi.port = String(port);
325
325
  const host = explicitArg(args.apiHost);
326
326
  if (host !== undefined) state.localApi.host = String(host);
327
- const upstreamUrl = explicitArg(args.apiUpstreamUrl);
328
- if (upstreamUrl !== undefined) state.localApi.upstreamUrl = String(upstreamUrl);
329
327
  const model = explicitArg(args.apiModel);
330
328
  if (model !== undefined) state.localApi.model = String(model);
331
329
  state.localApi.updatedAt = new Date().toISOString();
@@ -506,7 +504,8 @@ async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
506
504
 
507
505
  function printStartJson(state, apiServer, { noPrintCredentials = false } = {}) {
508
506
  const mcpPassword = noPrintCredentials ? previewSecret(state.worker.oauthPassword) : state.worker.oauthPassword;
509
- const apiKey = state.localApi?.apiKey ? (noPrintCredentials ? previewSecret(state.localApi.apiKey) : state.localApi.apiKey) : null;
507
+ const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
508
+ const apiKey = runtimeApiKey ? (noPrintCredentials ? previewSecret(runtimeApiKey) : runtimeApiKey) : null;
510
509
  createLogger({ component: "ready" }).json({
511
510
  mcp: {
512
511
  server_url: state.worker.mcpServerUrl,
@@ -518,6 +517,9 @@ function printStartJson(state, apiServer, { noPrintCredentials = false } = {}) {
518
517
  base_url: apiServer.baseUrl,
519
518
  api_key: apiKey,
520
519
  already_running: Boolean(apiServer.alreadyRunning),
520
+ backend: "chatgpt-mcp",
521
+ model: state.localApi?.model || DEFAULT_API_MODEL,
522
+ mcp_bridge_configured: Boolean(state.worker?.url && state.worker?.daemonSecret),
521
523
  client_type: "OpenAI-compatible",
522
524
  } : null,
523
525
  workspace: state.workspace.path,
@@ -527,10 +529,14 @@ function printStartJson(state, apiServer, { noPrintCredentials = false } = {}) {
527
529
  }
528
530
 
529
531
  function printApiJson(apiServer, state, { noPrintCredentials = false } = {}) {
532
+ const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
530
533
  createLogger({ component: "ready" }).json({
531
534
  local_api: {
532
535
  base_url: apiServer.baseUrl,
533
- api_key: noPrintCredentials ? previewSecret(state.localApi.apiKey) : state.localApi.apiKey,
536
+ api_key: noPrintCredentials ? previewSecret(runtimeApiKey) : runtimeApiKey,
537
+ backend: "chatgpt-mcp",
538
+ model: apiServer?.model || state.localApi?.model || DEFAULT_API_MODEL,
539
+ mcp_bridge_configured: Boolean(state.worker?.url && state.worker?.daemonSecret),
534
540
  client_type: "OpenAI-compatible",
535
541
  },
536
542
  workspace: state.workspace.path,
@@ -570,12 +576,18 @@ function printMcpConnection(state, { json = false, noPrintCredentials = false, i
570
576
 
571
577
  function printApiConnection(apiServer, state, { noPrintCredentials = false, quiet = false } = {}) {
572
578
  const logger = createLogger({ component: "ready", quiet });
573
- logger.success("Local model API provider is ready");
579
+ const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
580
+ logger.success("Local ChatGPT MCP-backed OpenAI-compatible API is running");
574
581
  logger.plain(` API Base URL: ${apiServer.baseUrl}`);
575
- logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(state.localApi.apiKey)} (redacted)` : state.localApi.apiKey}`);
582
+ logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(runtimeApiKey)} (redacted)` : runtimeApiKey}`);
576
583
  logger.plain(" Client type: OpenAI-compatible");
584
+ logger.plain(` Model: ${apiServer?.model || state.localApi?.model || DEFAULT_API_MODEL}`);
585
+ logger.plain(" Backend: ChatGPT MCP sampling via the connected ChatGPT app");
586
+ if (state.worker?.mcpServerUrl) logger.plain(` MCP Server URL: ${state.worker.mcpServerUrl}`);
587
+ else logger.plain(" MCP bridge: not deployed yet; run `machine-mcp` before using generation routes.");
577
588
  }
578
589
 
590
+
579
591
  function keepProcessAlive({ daemon = null, lock = null, apiServer = null, logger = createLogger({ component: "cli" }) } = {}) {
580
592
  let stopping = false;
581
593
  const stop = async () => {
@@ -778,7 +790,7 @@ Usage:
778
790
 
779
791
  Commands:
780
792
  start Deploy/update Worker, install autostart, start daemon and local API
781
- api Start local OpenAI-compatible API provider only
793
+ api Start local ChatGPT MCP-backed OpenAI-compatible API only
782
794
  workspace show Show remembered workspace
783
795
  workspace set Re-select workspace; prompts with current/default path
784
796
  service status Show autostart status
@@ -805,16 +817,14 @@ Start options:
805
817
  --full-env Pass full parent environment to exec_command (default: minimal env)
806
818
  --state-dir DIR Override state root
807
819
  --json Print MCP and local API connection details as JSON
808
- --no-api Do not start the local OpenAI-compatible API provider
820
+ --no-api Do not start the local OpenAI-compatible API
809
821
  --api Deprecated no-op; local API starts by default
810
822
  --api-port PORT Local API port (default: 8765)
811
823
  --port PORT Alias for --api-port on the api command
812
824
  --api-host HOST Local API host (default: 127.0.0.1)
813
825
  --api-key KEY Set or replace local API key in state
814
826
  --rotate-api-key Rotate local API key
815
- --api-upstream-url URL Upstream OpenAI-compatible base URL (default: https://api.openai.com/v1)
816
- --api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
817
- --api-model MODEL Model id advertised by /v1/models
827
+ --api-model MODEL Local model id advertised by /v1/models (default: chatgpt-mcp)
818
828
 
819
829
  Uninstall options:
820
830
  --keep-worker Do not delete deployed Worker(s) during uninstall
@@ -823,18 +833,19 @@ Uninstall options:
823
833
  }
824
834
 
825
835
  function apiUsage() {
826
- console.log(`machine-bridge-mcp local API provider
836
+ console.log(`machine-bridge-mcp local ChatGPT MCP-backed API
827
837
 
828
838
  Usage:
829
839
  machine-mcp
830
840
  machine-mcp --api-port 8766
831
- machine-mcp api # API provider only
841
+ machine-mcp api # local API only
832
842
  machine-mcp api --api-port 8766
833
843
 
834
844
  Client settings:
835
845
  API Base URL: http://127.0.0.1:<port>/v1
836
846
  API key: printed on startup unless --no-print-credentials is set
837
- Client type: OpenAI-compatible
847
+ Model: chatgpt-mcp by default
848
+ Backend: connected ChatGPT app through the Remote MCP bridge
838
849
 
839
850
  Options:
840
851
  --workspace PATH Use and remember this workspace path
@@ -843,19 +854,21 @@ Options:
843
854
  --port PORT Alias for --api-port
844
855
  --api-key KEY Set or replace local API key in state
845
856
  --rotate-api-key Rotate local API key
846
- --api-upstream-url URL Upstream OpenAI-compatible base URL
847
- --api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
848
- --api-model MODEL Model id advertised by /v1/models
857
+ --api-model MODEL Local model id advertised by /v1/models
849
858
  --no-print-credentials Redact the local API key in console output
850
- --no-api Only valid for start; disables the default local API provider
859
+ --no-api Only valid for start; disables the default local API
851
860
  --state-dir DIR Override state root
852
861
 
862
+ Important:
863
+ Generation routes require a ChatGPT app connected to the printed MCP Server URL.
864
+ The local API sends sampling/createMessage requests through that MCP connection.
865
+
853
866
  Environment:
854
- MBM_API_HOST, MBM_API_PORT, MBM_API_KEY, MBM_API_UPSTREAM_URL, MBM_API_UPSTREAM_KEY, MBM_API_MODEL
855
- OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_API_BASE, OPENAI_MODEL
867
+ MBM_API_HOST, MBM_API_PORT, MBM_API_KEY, MBM_API_MODEL
856
868
  `);
857
869
  }
858
870
 
871
+
859
872
  function version() {
860
873
  const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
861
874
  console.log(`${pkg.name} ${pkg.version}`);
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
+ import http from "node:http";
2
3
  import { mkdtemp, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import { daemonSelfTest } from "./daemon.mjs";
6
- import { normalizeBaseUrl, startLocalApiServer } from "./api-server.mjs";
7
+ import { startLocalApiServer } from "./api-server.mjs";
7
8
  import { createLogger, redactSecret } from "./log.mjs";
8
- import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
9
+ import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, saveState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
9
10
 
10
11
  await daemonSelfTest();
11
12
  await stateSelfTest();
@@ -41,6 +42,15 @@ async function stateSelfTest() {
41
42
  if (redacted.localApi.apiKey === state.localApi.apiKey) throw new Error("local API key was not redacted");
42
43
  if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
43
44
  if (!redactSecret(state.localApi.apiKey).includes("...")) throw new Error("redactSecret did not preview long secret");
45
+
46
+ state.localApi.upstreamUrl = "https://api.example.test/v1";
47
+ state.localApi.upstreamKey = "old-upstream-key";
48
+ state.localApi.upstreamModel = "old-upstream-model";
49
+ saveState(state);
50
+ const migrated = loadState(workspace, { stateDir: stateRoot });
51
+ if ("upstreamUrl" in migrated.localApi || "upstreamKey" in migrated.localApi || "upstreamModel" in migrated.localApi) {
52
+ throw new Error("legacy upstream local API state was not migrated away");
53
+ }
44
54
  } finally {
45
55
  await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
46
56
  await rm(workspace, { recursive: true, force: true }).catch(() => {});
@@ -49,48 +59,182 @@ async function stateSelfTest() {
49
59
 
50
60
 
51
61
  async function apiSelfTest() {
52
- try {
53
- normalizeBaseUrl("https://user:pass@example.com/v1");
54
- throw new Error("upstream URL credentials were accepted");
55
- } catch (error) {
56
- if (!/must not contain credentials/.test(error.message)) throw error;
57
- }
58
- try {
59
- normalizeBaseUrl("https://example.com/v1?api_key=secret");
60
- throw new Error("upstream URL query string was accepted");
61
- } catch (error) {
62
- if (!/without query strings/.test(error.message)) throw error;
63
- }
64
-
65
62
  const logger = createLogger({ quiet: true, component: "api-test" });
66
63
  const api = await startLocalApiServer({
67
64
  host: "127.0.0.1",
68
65
  port: 0,
69
66
  apiKey: "local-test-key",
70
- upstreamKey: "",
71
- model: "test-model",
67
+ model: "chatgpt-mcp",
72
68
  logger,
73
69
  });
74
70
  const base = `http://${api.host}:${api.port}`;
75
71
  try {
72
+ if (api.apiKey !== "local-test-key") throw new Error("local API server did not expose runtime API key for CLI printing");
76
73
  const health = await fetch(`${base}/health`);
77
74
  if (health.status !== 200) throw new Error(`health returned ${health.status}`);
78
75
  const healthPayload = await health.json();
79
76
  const expectedHash = createHash("sha256").update("local-test-key").digest("hex");
80
77
  if (healthPayload.api_key_sha256 !== expectedHash) throw new Error("health did not expose expected API key hash");
78
+ if (healthPayload.backend !== "chatgpt-mcp") throw new Error("health did not report MCP-backed backend");
79
+ if (healthPayload.mcp_bridge_configured !== false) throw new Error("health should report missing MCP bridge");
81
80
  const unauth = await fetch(`${base}/v1/models`);
82
81
  if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
83
82
  const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
84
83
  if (models.status !== 200) throw new Error(`authorized models returned ${models.status}`);
85
84
  const payload = await models.json();
86
- if (payload?.data?.[0]?.id !== "test-model") throw new Error("model payload did not include configured model");
85
+ if (payload?.data?.length !== 1 || payload.data[0].id !== "chatgpt-mcp") throw new Error("model payload should expose local MCP model");
87
86
  const chat = await fetch(`${base}/v1/chat/completions`, {
88
87
  method: "POST",
89
88
  headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
90
- body: JSON.stringify({ model: "test-model", messages: [] }),
89
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
91
90
  });
92
- if (chat.status !== 503) throw new Error(`missing upstream key should return 503, got ${chat.status}`);
91
+ if (chat.status !== 503) throw new Error(`missing MCP bridge should return 503, got ${chat.status}`);
92
+ const chatPayload = await chat.json();
93
+ if (!/Remote MCP bridge/.test(chatPayload?.error?.message || "")) throw new Error("missing bridge error did not clarify MCP bridge requirement");
94
+
95
+ const unsupported = await fetch(`${base}/v1/responses`, {
96
+ method: "POST",
97
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
98
+ body: JSON.stringify({ input: "hello" }),
99
+ });
100
+ if (unsupported.status !== 501) throw new Error(`unsupported Responses endpoint returned ${unsupported.status}`);
101
+ const unsupportedPayload = await unsupported.json();
102
+ if (unsupportedPayload?.error?.code !== "unsupported_endpoint") throw new Error("unsupported endpoint did not return explicit error code");
103
+
104
+ } finally {
105
+ await api.close();
106
+ }
107
+
108
+ await mcpSamplingSelfTest(logger);
109
+ await mcpSamplingErrorSelfTest(logger);
110
+ }
111
+
112
+ async function mcpSamplingSelfTest(logger) {
113
+ let captured = null;
114
+ const expectedErrorLogger = { ...logger, error() {} };
115
+ const worker = http.createServer((req, res) => {
116
+ const chunks = [];
117
+ req.on("data", chunk => chunks.push(chunk));
118
+ req.on("end", () => {
119
+ captured = { bridgeToken: req.headers["x-bridge-token"], url: req.url, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
120
+ res.setHeader("content-type", "application/json");
121
+ res.end(JSON.stringify({ ok: true, result: { role: "assistant", content: { type: "text", text: "hello from ChatGPT MCP" }, model: "chatgpt-client-model", stopReason: "endTurn" } }));
122
+ });
123
+ });
124
+ await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
125
+ const workerPort = worker.address().port;
126
+ const api = await startLocalApiServer({
127
+ host: "127.0.0.1",
128
+ port: 0,
129
+ apiKey: "local-test-key",
130
+ workerUrl: `http://127.0.0.1:${workerPort}`,
131
+ daemonSecret: "daemon-test-secret",
132
+ model: "chatgpt-mcp",
133
+ logger: expectedErrorLogger,
134
+ });
135
+ try {
136
+ const models = await fetch(`http://${api.host}:${api.port}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
137
+ if (models.status !== 200) throw new Error(`configured models returned ${models.status}`);
138
+ const modelsPayload = await models.json();
139
+ if (modelsPayload?.data?.length !== 1 || modelsPayload.data[0].id !== "chatgpt-mcp") throw new Error("model payload did not expose local MCP model");
140
+
141
+ const image = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
142
+ method: "POST",
143
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
144
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.test/image.png" } }] }] }),
145
+ });
146
+ if (image.status !== 400) throw new Error(`unsupported non-text content returned ${image.status}`);
147
+ const imagePayload = await image.json();
148
+ if (imagePayload?.error?.code !== "unsupported_content") throw new Error("unsupported non-text content did not return explicit error code");
149
+
150
+ const response = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
151
+ method: "POST",
152
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
153
+ body: JSON.stringify({
154
+ model: "gpt-5-hint",
155
+ messages: [
156
+ { role: "system", content: "Be concise." },
157
+ { role: "developer", content: "Use plain text." },
158
+ { role: "user", content: [{ type: "text", text: "Say hello" }] },
159
+ ],
160
+ max_completion_tokens: 77,
161
+ }),
162
+ });
163
+ if (response.status !== 200) throw new Error(`MCP sampling rewrite returned ${response.status}`);
164
+ if (captured?.url !== "/api/mcp/sampling") throw new Error("sampling request did not target Worker sampling endpoint");
165
+ if (captured?.bridgeToken !== "daemon-test-secret") throw new Error("bridge token was not set");
166
+ if (captured?.body?.messages?.[0]?.content?.text !== "Say hello") throw new Error("chat message was not converted to MCP sampling message");
167
+ if (captured?.body?.systemPrompt !== "Be concise.\n\nUse plain text.") throw new Error("system/developer prompt was not forwarded");
168
+ if (captured?.body?.maxTokens !== 77) throw new Error("max_completion_tokens was not converted to maxTokens");
169
+ if (captured?.body?.modelPreferences?.hints?.[0]?.name !== "gpt-5-hint") throw new Error("model hint was not forwarded");
170
+ const payload = await response.json();
171
+ if (payload?.choices?.[0]?.message?.content !== "hello from ChatGPT MCP") throw new Error("MCP sampling result was not wrapped as chat completion");
172
+ if (payload?.model !== "chatgpt-client-model") throw new Error("MCP sampling result model was not preserved");
173
+ } finally {
174
+ await api.close();
175
+ await new Promise(resolve => worker.close(resolve));
176
+ }
177
+ }
178
+
179
+ async function mcpSamplingErrorSelfTest(logger) {
180
+ await withMockWorkerError(
181
+ logger,
182
+ 409,
183
+ { error: "mcp_client_stream_missing", message: "No MCP client has an open server-to-client stream for sampling/createMessage." },
184
+ async ({ base }) => {
185
+ const response = await fetch(`${base}/v1/chat/completions`, {
186
+ method: "POST",
187
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
188
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
189
+ });
190
+ if (response.status !== 409) throw new Error(`missing MCP stream should return 409, got ${response.status}`);
191
+ const payload = await response.json();
192
+ if (payload?.error?.code !== "mcp_client_stream_missing") throw new Error("missing MCP stream error code was not preserved");
193
+ if (!/MCP client|server-to-client stream/.test(payload?.error?.message || "")) throw new Error("missing MCP stream error message was not explicit");
194
+ }
195
+ );
196
+
197
+ await withMockWorkerError(
198
+ logger,
199
+ 501,
200
+ { error: "mcp_sampling_not_supported", message: "A connected MCP client did not advertise the MCP sampling capability." },
201
+ async ({ base }) => {
202
+ const response = await fetch(`${base}/v1/chat/completions`, {
203
+ method: "POST",
204
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
205
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
206
+ });
207
+ if (response.status !== 501) throw new Error(`missing sampling capability should return 501, got ${response.status}`);
208
+ const payload = await response.json();
209
+ if (payload?.error?.code !== "mcp_sampling_not_supported") throw new Error("missing sampling capability error code was not preserved");
210
+ if (!/sampling capability/.test(payload?.error?.message || "")) throw new Error("missing sampling capability message was not explicit");
211
+ }
212
+ );
213
+ }
214
+
215
+ async function withMockWorkerError(logger, status, payload, callback) {
216
+ const expectedErrorLogger = { ...logger, error() {} };
217
+ const worker = http.createServer((req, res) => {
218
+ req.resume();
219
+ res.statusCode = status;
220
+ res.setHeader("content-type", "application/json");
221
+ res.end(JSON.stringify(payload));
222
+ });
223
+ await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
224
+ const workerPort = worker.address().port;
225
+ const api = await startLocalApiServer({
226
+ host: "127.0.0.1",
227
+ port: 0,
228
+ apiKey: "local-test-key",
229
+ workerUrl: `http://127.0.0.1:${workerPort}`,
230
+ daemonSecret: "daemon-test-secret",
231
+ model: "chatgpt-mcp",
232
+ logger: expectedErrorLogger,
233
+ });
234
+ try {
235
+ await callback({ base: `http://${api.host}:${api.port}` });
93
236
  } finally {
94
237
  await api.close();
238
+ await new Promise(resolve => worker.close(resolve));
95
239
  }
96
240
  }
@@ -100,9 +100,17 @@ export function loadState(workspace, options = {}) {
100
100
  state.paths = { stateRoot, profileDir, statePath };
101
101
  state.worker ||= {};
102
102
  state.policy ||= {};
103
+ migrateLocalApiState(state);
103
104
  return state;
104
105
  }
105
106
 
107
+ function migrateLocalApiState(state) {
108
+ if (!state.localApi || typeof state.localApi !== "object" || Array.isArray(state.localApi)) return;
109
+ delete state.localApi.upstreamUrl;
110
+ delete state.localApi.upstreamKey;
111
+ delete state.localApi.upstreamModel;
112
+ }
113
+
106
114
  export function saveState(state) {
107
115
  const statePath = state?.paths?.statePath;
108
116
  if (!statePath) throw new Error("state path is missing");