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/README.md +27 -33
- package/package.json +1 -1
- package/src/local/api-server.mjs +199 -91
- package/src/local/cli.mjs +40 -27
- package/src/local/self-test.mjs +164 -20
- package/src/local/state.mjs +8 -0
- package/src/worker/index.ts +259 -16
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
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
|
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,
|
|
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}`);
|
package/src/local/self-test.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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]
|
|
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: "
|
|
89
|
+
body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
|
|
91
90
|
});
|
|
92
|
-
if (chat.status !== 503) throw new Error(`missing
|
|
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
|
}
|
package/src/local/state.mjs
CHANGED
|
@@ -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");
|