chatgpt-webui-mcp 0.1.0

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/index.ts ADDED
@@ -0,0 +1,858 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from "node:crypto";
4
+ import http from "node:http";
5
+
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+
11
+ import { ChatgptWebuiClient } from "./chatgpt-webui-client.js";
12
+
13
+ const serverInfo = {
14
+ name: "chatgpt-webui-mcp",
15
+ version: "0.2.0",
16
+ } as const;
17
+
18
+ const askInputSchema = {
19
+ prompt: z.string().describe("Prompt to send."),
20
+ model: z
21
+ .string()
22
+ .optional()
23
+ .describe(
24
+ "Model slug override. Examples: gpt-5-2, gpt-5-2-pro, gpt-5-1-instant, research. Ignored when deep_research=true.",
25
+ ),
26
+ model_mode: z
27
+ .enum(["auto", "instant", "thinking", "pro"])
28
+ .optional()
29
+ .describe("Quick model mode selector that maps to GPT-5.2 variants."),
30
+ reasoning_effort: z
31
+ .enum(["none", "standard", "extended"])
32
+ .optional()
33
+ .describe("UI reasoning control. Mainly relevant for thinking-capable models."),
34
+ deep_research: z
35
+ .boolean()
36
+ .optional()
37
+ .describe("Enable Deep Research flow. When true, model selection switches to research mode."),
38
+ deep_research_site_mode: z
39
+ .enum(["search_web", "specific_sites"])
40
+ .optional()
41
+ .describe("Optional Deep Research sites mode override."),
42
+ create_image: z
43
+ .boolean()
44
+ .optional()
45
+ .describe("Enable image-generation mode in ChatGPT UI."),
46
+ wait_timeout_ms: z
47
+ .number()
48
+ .int()
49
+ .positive()
50
+ .optional()
51
+ .describe("Max wait for response completion in milliseconds (supports long GPT-5.2 Pro runs)."),
52
+ workspace: z
53
+ .string()
54
+ .optional()
55
+ .describe("Preferred workspace label if ChatGPT shows workspace selection (e.g. PRO, Personal)."),
56
+ conversation_id: z
57
+ .string()
58
+ .optional()
59
+ .describe("Optional conversation id to continue an existing chat."),
60
+ parent_message_id: z
61
+ .string()
62
+ .optional()
63
+ .describe("Optional parent message id for continued conversation state."),
64
+ };
65
+
66
+ type AskToolInput = {
67
+ prompt: string;
68
+ model?: string;
69
+ model_mode?: "auto" | "instant" | "thinking" | "pro";
70
+ reasoning_effort?: "none" | "standard" | "extended";
71
+ deep_research?: boolean;
72
+ deep_research_site_mode?: "search_web" | "specific_sites";
73
+ create_image?: boolean;
74
+ wait_timeout_ms?: number;
75
+ workspace?: string;
76
+ conversation_id?: string;
77
+ parent_message_id?: string;
78
+ };
79
+
80
+ type AskJobState = "queued" | "running" | "succeeded" | "failed";
81
+
82
+ type AskJob = {
83
+ id: string;
84
+ state: AskJobState;
85
+ createdAt: number;
86
+ startedAt: number | null;
87
+ finishedAt: number | null;
88
+ input: AskToolInput;
89
+ result: Awaited<ReturnType<ChatgptWebuiClient["ask"]>> | null;
90
+ error: string | null;
91
+ };
92
+
93
+ const askJobs = new Map<string, AskJob>();
94
+ const DEFAULT_ASK_ASYNC_JOB_TTL_MS = 24 * 60 * 60 * 1000;
95
+ const DEFAULT_ASK_ASYNC_JOB_MAX = 150;
96
+
97
+ function parsePositiveInt(raw: string | undefined, fallback: number): number {
98
+ if (!raw) {
99
+ return fallback;
100
+ }
101
+
102
+ const value = Number(raw);
103
+ if (!Number.isInteger(value) || value <= 0) {
104
+ return fallback;
105
+ }
106
+
107
+ return value;
108
+ }
109
+
110
+ const ASK_ASYNC_JOB_TTL_MS = parsePositiveInt(process.env.CHATGPT_ASYNC_JOB_TTL_MS, DEFAULT_ASK_ASYNC_JOB_TTL_MS);
111
+ const ASK_ASYNC_JOB_MAX = parsePositiveInt(process.env.CHATGPT_ASYNC_JOB_MAX, DEFAULT_ASK_ASYNC_JOB_MAX);
112
+
113
+ function sleep(ms: number): Promise<void> {
114
+ return new Promise((resolve) => setTimeout(resolve, ms));
115
+ }
116
+
117
+ function toAskClientInput(input: AskToolInput): Parameters<ChatgptWebuiClient["ask"]>[0] {
118
+ return {
119
+ prompt: input.prompt,
120
+ model: input.model,
121
+ modelMode: input.model_mode,
122
+ reasoningEffort: input.reasoning_effort,
123
+ deepResearch: input.deep_research,
124
+ deepResearchSiteMode: input.deep_research_site_mode,
125
+ createImage: input.create_image,
126
+ waitTimeoutMs: input.wait_timeout_ms,
127
+ workspace: input.workspace,
128
+ conversationId: input.conversation_id,
129
+ parentMessageId: input.parent_message_id,
130
+ };
131
+ }
132
+
133
+ function formatAskResult(result: Awaited<ReturnType<ChatgptWebuiClient["ask"]>>) {
134
+ return {
135
+ text: result.text,
136
+ conversation_id: result.conversationId,
137
+ parent_message_id: result.parentMessageId,
138
+ model: result.model,
139
+ image_urls: result.imageUrls ?? [],
140
+ };
141
+ }
142
+
143
+ function cleanupAskJobs(): void {
144
+ const now = Date.now();
145
+
146
+ for (const [jobId, job] of askJobs.entries()) {
147
+ if (job.finishedAt && now - job.finishedAt > ASK_ASYNC_JOB_TTL_MS) {
148
+ askJobs.delete(jobId);
149
+ }
150
+ }
151
+
152
+ if (askJobs.size <= ASK_ASYNC_JOB_MAX) {
153
+ return;
154
+ }
155
+
156
+ const evictable = Array.from(askJobs.values())
157
+ .filter((job) => job.state === "succeeded" || job.state === "failed")
158
+ .sort((a, b) => (a.finishedAt ?? a.createdAt) - (b.finishedAt ?? b.createdAt));
159
+
160
+ while (askJobs.size > ASK_ASYNC_JOB_MAX && evictable.length > 0) {
161
+ const next = evictable.shift();
162
+ if (!next) {
163
+ break;
164
+ }
165
+
166
+ askJobs.delete(next.id);
167
+ }
168
+ }
169
+
170
+ async function runAskJob(jobId: string): Promise<void> {
171
+ const job = askJobs.get(jobId);
172
+ if (!job || job.state !== "queued") {
173
+ return;
174
+ }
175
+
176
+ job.state = "running";
177
+ job.startedAt = Date.now();
178
+
179
+ try {
180
+ const result = await withClient(async (client) => client.ask(toAskClientInput(job.input)));
181
+ job.result = result;
182
+ job.state = "succeeded";
183
+ } catch (error) {
184
+ job.error = error instanceof Error ? error.message : String(error);
185
+ job.state = "failed";
186
+ } finally {
187
+ job.finishedAt = Date.now();
188
+ cleanupAskJobs();
189
+ }
190
+ }
191
+
192
+ function getAskJob(jobId: string): AskJob {
193
+ const job = askJobs.get(jobId);
194
+ if (!job) {
195
+ throw new Error(`ask_job_not_found: ${jobId}`);
196
+ }
197
+
198
+ return job;
199
+ }
200
+
201
+ async function waitForAskJob(job: AskJob, waitTimeoutMs: number, pollIntervalMs: number): Promise<AskJob> {
202
+ const startedAt = Date.now();
203
+ while (job.state === "queued" || job.state === "running") {
204
+ if (Date.now() - startedAt >= waitTimeoutMs) {
205
+ return job;
206
+ }
207
+
208
+ await sleep(pollIntervalMs);
209
+ }
210
+
211
+ return job;
212
+ }
213
+
214
+ function shouldRunInBackground(input: AskToolInput): boolean {
215
+ const model = String(input.model ?? "").toLowerCase();
216
+ if (input.deep_research || input.deep_research_site_mode) {
217
+ return true;
218
+ }
219
+
220
+ if (input.create_image) {
221
+ return true;
222
+ }
223
+
224
+ if (input.model_mode === "pro" || input.model_mode === "thinking") {
225
+ return true;
226
+ }
227
+
228
+ if (/\b(pro|thinking|research)\b/.test(model)) {
229
+ return true;
230
+ }
231
+
232
+ return (input.wait_timeout_ms ?? 0) > 300000;
233
+ }
234
+
235
+ function createAskJob(input: AskToolInput): AskJob {
236
+ cleanupAskJobs();
237
+
238
+ const jobId = crypto.randomUUID();
239
+ const job: AskJob = {
240
+ id: jobId,
241
+ state: "queued",
242
+ createdAt: Date.now(),
243
+ startedAt: null,
244
+ finishedAt: null,
245
+ input,
246
+ result: null,
247
+ error: null,
248
+ };
249
+
250
+ askJobs.set(jobId, job);
251
+ void runAskJob(jobId);
252
+ return job;
253
+ }
254
+
255
+ function normalizeCommand(value: string): string {
256
+ return value.replace(/\s+/g, " ").trim();
257
+ }
258
+
259
+ function parseModeFromCommand(command: string): "auto" | "wait" | "background" | null {
260
+ const lower = command.toLowerCase();
261
+ if (/\b(background|async|in\s+the\s+background|dont\s+wait)\b/i.test(lower)) {
262
+ return "background";
263
+ }
264
+ if (/\b(wait|blocking|sync|synchronously)\b/i.test(lower)) {
265
+ return "wait";
266
+ }
267
+ return null;
268
+ }
269
+
270
+ function parseAskInputFromCommand(commandRaw: string): {
271
+ ask: AskToolInput;
272
+ modeHint: "auto" | "wait" | "background" | null;
273
+ } {
274
+ const command = normalizeCommand(commandRaw);
275
+ const lower = command.toLowerCase();
276
+
277
+ const deepResearch = /\bdeep\s*research\b|\bdeepresearch\b/i.test(lower);
278
+ const deepResearchSiteMode: AskToolInput["deep_research_site_mode"] = /\bspecific\s+sites\b/i.test(lower)
279
+ ? "specific_sites"
280
+ : /\bsearch\s+the\s+web\b|\bsearch\s+web\b/i.test(lower)
281
+ ? "search_web"
282
+ : undefined;
283
+
284
+ const createImage = /\b(create|generate|make)\s+(an?\s+)?images?\b|\bimage\s+generation\b/i.test(lower);
285
+
286
+ const reasoningEffort: AskToolInput["reasoning_effort"] =
287
+ /\bno\s+thinking\b|\bdisable\s+thinking\b/i.test(lower)
288
+ ? "none"
289
+ : /\bextended\s+(thinking|reasoning)\b|\bext\s+thinking\b/i.test(lower)
290
+ ? "extended"
291
+ : /\bstandard\s+thinking\b|\bnormal\s+thinking\b/i.test(lower)
292
+ ? "standard"
293
+ : undefined;
294
+
295
+ const hasPro = /\bpro\b/i.test(lower);
296
+ const hasInstant = /\binstant\b/i.test(lower);
297
+ const hasThinking = /\bthinking\b/i.test(lower);
298
+ const hasAuto = /\bauto\b/i.test(lower);
299
+
300
+ let model: string | undefined;
301
+ let modelMode: AskToolInput["model_mode"] | undefined;
302
+
303
+ if (/\b5\.1\b/i.test(lower)) {
304
+ if (hasPro) model = "gpt-5-1-pro";
305
+ else if (hasInstant) model = "gpt-5-1-instant";
306
+ else if (hasThinking) model = "gpt-5-1-thinking";
307
+ else model = "gpt-5-1";
308
+ } else if (/\b5\.2\b/i.test(lower) || /\bgpt\s*-?5\.2\b/i.test(lower)) {
309
+ if (hasPro) modelMode = "pro";
310
+ else if (hasInstant) modelMode = "instant";
311
+ else if (hasThinking) modelMode = "thinking";
312
+ else if (hasAuto) modelMode = "auto";
313
+ } else {
314
+ if (hasPro) modelMode = "pro";
315
+ else if (hasInstant) modelMode = "instant";
316
+ else if (hasThinking) modelMode = "thinking";
317
+ else if (hasAuto) modelMode = "auto";
318
+ }
319
+
320
+ // prompt extraction
321
+ let prompt = command;
322
+ const colonIndex = command.indexOf(":");
323
+ if (colonIndex >= 0 && colonIndex < command.length - 1) {
324
+ prompt = command.slice(colonIndex + 1).trim();
325
+ } else {
326
+ const onMatch = command.match(/\b(?:on|about)\b\s+(.+)$/i);
327
+ if (onMatch?.[1]) {
328
+ prompt = onMatch[1].trim();
329
+ }
330
+ }
331
+
332
+ // trim common prefixes if user didn't use ':'
333
+ prompt = prompt
334
+ .replace(/^with\s+chatgpt\s+webui\b/i, "")
335
+ .replace(/^chatgpt\s+webui\b/i, "")
336
+ .replace(/^do\s+deep\s*research\b/i, "")
337
+ .replace(/^deep\s*research\b/i, "")
338
+ .trim();
339
+
340
+ const modeHint = parseModeFromCommand(command);
341
+
342
+ return {
343
+ ask: {
344
+ prompt,
345
+ model,
346
+ model_mode: modelMode,
347
+ reasoning_effort: reasoningEffort,
348
+ deep_research: deepResearch || undefined,
349
+ deep_research_site_mode: deepResearchSiteMode,
350
+ create_image: createImage || undefined,
351
+ },
352
+ modeHint,
353
+ };
354
+ }
355
+
356
+ function createClient(): ChatgptWebuiClient {
357
+ return new ChatgptWebuiClient();
358
+ }
359
+
360
+ async function withClient<T>(run: (client: ChatgptWebuiClient) => Promise<T>): Promise<T> {
361
+ const client = createClient();
362
+ try {
363
+ return await run(client);
364
+ } finally {
365
+ client.close();
366
+ }
367
+ }
368
+
369
+ function registerTools(server: McpServer): void {
370
+ const makeTextContent = (text: string) => [{ type: "text" as const, text }];
371
+
372
+ const promptHandler = async (
373
+ input: AskToolInput & {
374
+ mode?: "auto" | "wait" | "background";
375
+ wait_for_ms?: number;
376
+ poll_interval_ms?: number;
377
+ },
378
+ ) => {
379
+ const { mode = "auto", wait_for_ms, poll_interval_ms, ...askInput } = input;
380
+ const resolvedMode = mode === "auto" ? (shouldRunInBackground(askInput) ? "background" : "wait") : mode;
381
+
382
+ if (resolvedMode === "wait") {
383
+ const result = await withClient(async (client) => client.ask(toAskClientInput(askInput)));
384
+ const formatted = formatAskResult(result);
385
+ return {
386
+ content: makeTextContent(formatted.text),
387
+ structuredContent: {
388
+ mode: resolvedMode,
389
+ state: "succeeded",
390
+ ...formatted,
391
+ },
392
+ };
393
+ }
394
+
395
+ const job = createAskJob(askInput);
396
+ const pollIntervalMs = poll_interval_ms ?? 2000;
397
+
398
+ if (wait_for_ms && wait_for_ms > 0) {
399
+ const settled = await waitForAskJob(job, wait_for_ms, pollIntervalMs);
400
+ if (settled.state === "succeeded" && settled.result) {
401
+ const formatted = formatAskResult(settled.result);
402
+ return {
403
+ content: makeTextContent(formatted.text),
404
+ structuredContent: {
405
+ mode: resolvedMode,
406
+ run_id: settled.id,
407
+ state: settled.state,
408
+ ...formatted,
409
+ },
410
+ };
411
+ }
412
+
413
+ if (settled.state === "failed") {
414
+ return {
415
+ isError: true,
416
+ content: makeTextContent(settled.error ?? "ask_job_failed"),
417
+ structuredContent: {
418
+ mode: resolvedMode,
419
+ run_id: settled.id,
420
+ state: settled.state,
421
+ error: settled.error,
422
+ },
423
+ };
424
+ }
425
+ }
426
+
427
+ return {
428
+ content: makeTextContent(JSON.stringify({ run_id: job.id, state: job.state }, null, 2)),
429
+ structuredContent: {
430
+ mode: resolvedMode,
431
+ run_id: job.id,
432
+ state: job.state,
433
+ created_at: job.createdAt,
434
+ },
435
+ };
436
+ };
437
+
438
+ server.registerTool(
439
+ "chatgpt_webui_session",
440
+ {
441
+ description: "Validate ChatGPT session token and return session details.",
442
+ inputSchema: {},
443
+ },
444
+ async () => {
445
+ const session = await withClient(async (client) => client.getSession());
446
+ return {
447
+ content: [{ type: "text", text: JSON.stringify(session, null, 2) }],
448
+ structuredContent: session,
449
+ };
450
+ },
451
+ );
452
+
453
+ server.registerTool(
454
+ "chatgpt_webui_models",
455
+ {
456
+ description: "List available ChatGPT WebUI models for the current account.",
457
+ inputSchema: {},
458
+ },
459
+ async () => {
460
+ const models = await withClient(async (client) => client.getModels());
461
+ return {
462
+ content: [{ type: "text", text: JSON.stringify(models, null, 2) }],
463
+ structuredContent: { models },
464
+ };
465
+ },
466
+ );
467
+
468
+ server.registerTool(
469
+ "chatgpt_webui_ask",
470
+ {
471
+ description:
472
+ "Send a prompt to ChatGPT WebUI using session token auth and return assistant text.",
473
+ inputSchema: askInputSchema,
474
+ },
475
+ async (input: AskToolInput) => {
476
+ const result = await withClient(async (client) => client.ask(toAskClientInput(input)));
477
+ const formatted = formatAskResult(result);
478
+ return {
479
+ content: [{ type: "text", text: formatted.text }],
480
+ structuredContent: formatted,
481
+ };
482
+ },
483
+ );
484
+
485
+ server.registerTool(
486
+ "chatgpt_webui_ask_async_start",
487
+ {
488
+ description:
489
+ "Start a background ChatGPT ask job and return immediately with a job id. Use this for long-running tasks like Deep Research and Pro runs.",
490
+ inputSchema: askInputSchema,
491
+ },
492
+ async (input: AskToolInput) => {
493
+ const job = createAskJob(input);
494
+ return {
495
+ content: [{ type: "text", text: JSON.stringify({ job_id: job.id, state: job.state }, null, 2) }],
496
+ structuredContent: {
497
+ job_id: job.id,
498
+ state: job.state,
499
+ created_at: job.createdAt,
500
+ },
501
+ };
502
+ },
503
+ );
504
+
505
+ server.registerTool(
506
+ "chatgpt_webui_ask_async_status",
507
+ {
508
+ description: "Get status for a background ask job.",
509
+ inputSchema: {
510
+ job_id: z.string().describe("Job id returned by chatgpt_webui_ask_async_start."),
511
+ },
512
+ },
513
+ async ({ job_id }) => {
514
+ const job = getAskJob(job_id);
515
+ const payload = {
516
+ job_id: job.id,
517
+ state: job.state,
518
+ created_at: job.createdAt,
519
+ started_at: job.startedAt,
520
+ finished_at: job.finishedAt,
521
+ error: job.error,
522
+ result_preview: job.result?.text ? job.result.text.slice(0, 500) : null,
523
+ };
524
+ return {
525
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
526
+ structuredContent: payload,
527
+ };
528
+ },
529
+ );
530
+
531
+ server.registerTool(
532
+ "chatgpt_webui_ask_async_result",
533
+ {
534
+ description:
535
+ "Get result for a background ask job. Optionally wait for completion by providing wait_timeout_ms.",
536
+ inputSchema: {
537
+ job_id: z.string().describe("Job id returned by chatgpt_webui_ask_async_start."),
538
+ wait_timeout_ms: z
539
+ .number()
540
+ .int()
541
+ .positive()
542
+ .optional()
543
+ .describe("Optional max wait for completion before returning current state."),
544
+ poll_interval_ms: z
545
+ .number()
546
+ .int()
547
+ .positive()
548
+ .optional()
549
+ .describe("Polling interval while waiting. Default: 2000ms."),
550
+ },
551
+ },
552
+ async ({ job_id, wait_timeout_ms, poll_interval_ms }) => {
553
+ const pollIntervalMs = poll_interval_ms ?? 2000;
554
+ let job = getAskJob(job_id);
555
+
556
+ if (wait_timeout_ms) {
557
+ job = await waitForAskJob(job, wait_timeout_ms, pollIntervalMs);
558
+ }
559
+
560
+ if (job.state === "failed") {
561
+ const payload = {
562
+ job_id: job.id,
563
+ state: job.state,
564
+ error: job.error,
565
+ };
566
+
567
+ return {
568
+ isError: true,
569
+ content: [{ type: "text", text: job.error ?? "ask_job_failed" }],
570
+ structuredContent: payload,
571
+ };
572
+ }
573
+
574
+ if (job.state !== "succeeded" || !job.result) {
575
+ const payload = {
576
+ job_id: job.id,
577
+ state: job.state,
578
+ created_at: job.createdAt,
579
+ started_at: job.startedAt,
580
+ finished_at: job.finishedAt,
581
+ };
582
+
583
+ return {
584
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
585
+ structuredContent: payload,
586
+ };
587
+ }
588
+
589
+ const formatted = formatAskResult(job.result);
590
+ return {
591
+ content: [{ type: "text", text: formatted.text }],
592
+ structuredContent: {
593
+ job_id: job.id,
594
+ state: job.state,
595
+ ...formatted,
596
+ },
597
+ };
598
+ },
599
+ );
600
+
601
+ server.registerTool(
602
+ "chatgpt_webui_prompt",
603
+ {
604
+ description:
605
+ "Unified prompt entrypoint. mode=auto uses background runs for long tasks (Deep Research, Pro/Thinking, image generation) and direct wait for short tasks.",
606
+ inputSchema: {
607
+ ...askInputSchema,
608
+ mode: z
609
+ .enum(["auto", "wait", "background"])
610
+ .optional()
611
+ .describe("Execution mode. auto chooses best mode. wait blocks for result. background returns run_id."),
612
+ wait_for_ms: z
613
+ .number()
614
+ .int()
615
+ .positive()
616
+ .optional()
617
+ .describe("Optional extra wait window for background mode before returning running state."),
618
+ poll_interval_ms: z
619
+ .number()
620
+ .int()
621
+ .positive()
622
+ .optional()
623
+ .describe("Polling interval when wait_for_ms is set. Default 2000ms."),
624
+ },
625
+ },
626
+ async (input: AskToolInput & { mode?: "auto" | "wait" | "background"; wait_for_ms?: number; poll_interval_ms?: number }) => {
627
+ return await promptHandler(input);
628
+ },
629
+ );
630
+
631
+ server.registerTool(
632
+ "chatgpt_webui_run",
633
+ {
634
+ description:
635
+ "Unified run checker. Use run_id from chatgpt_webui_prompt background mode (or job_id from legacy async tools).",
636
+ inputSchema: {
637
+ run_id: z.string().optional().describe("Run id from chatgpt_webui_prompt."),
638
+ job_id: z.string().optional().describe("Legacy alias for run_id."),
639
+ wait_timeout_ms: z
640
+ .number()
641
+ .int()
642
+ .positive()
643
+ .optional()
644
+ .describe("Optional max wait for completion before returning running state."),
645
+ poll_interval_ms: z
646
+ .number()
647
+ .int()
648
+ .positive()
649
+ .optional()
650
+ .describe("Polling interval while waiting. Default: 2000ms."),
651
+ },
652
+ },
653
+ async ({ run_id, job_id, wait_timeout_ms, poll_interval_ms }) => {
654
+ const resolvedRunId = String(run_id ?? job_id ?? "").trim();
655
+ if (!resolvedRunId) {
656
+ return {
657
+ isError: true,
658
+ content: [{ type: "text", text: "missing_run_id" }],
659
+ };
660
+ }
661
+
662
+ const pollIntervalMs = poll_interval_ms ?? 2000;
663
+ let job: AskJob;
664
+ try {
665
+ job = getAskJob(resolvedRunId);
666
+ } catch (error) {
667
+ return {
668
+ isError: true,
669
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
670
+ };
671
+ }
672
+
673
+ if (wait_timeout_ms) {
674
+ job = await waitForAskJob(job, wait_timeout_ms, pollIntervalMs);
675
+ }
676
+
677
+ if (job.state === "failed") {
678
+ return {
679
+ isError: true,
680
+ content: [{ type: "text", text: job.error ?? "ask_job_failed" }],
681
+ structuredContent: {
682
+ run_id: job.id,
683
+ state: job.state,
684
+ error: job.error,
685
+ },
686
+ };
687
+ }
688
+
689
+ if (job.state !== "succeeded" || !job.result) {
690
+ return {
691
+ content: [{ type: "text", text: JSON.stringify({ run_id: job.id, state: job.state }, null, 2) }],
692
+ structuredContent: {
693
+ run_id: job.id,
694
+ state: job.state,
695
+ created_at: job.createdAt,
696
+ started_at: job.startedAt,
697
+ finished_at: job.finishedAt,
698
+ },
699
+ };
700
+ }
701
+
702
+ const formatted = formatAskResult(job.result);
703
+ return {
704
+ content: [{ type: "text", text: formatted.text }],
705
+ structuredContent: {
706
+ run_id: job.id,
707
+ state: job.state,
708
+ ...formatted,
709
+ },
710
+ };
711
+ },
712
+ );
713
+
714
+ server.registerTool(
715
+ "chatgpt_webui_command",
716
+ {
717
+ description:
718
+ "Natural-language command wrapper. Converts phrases like 'with chatgpt webui on gpt 5.2 pro extended thinking: ...' into a chatgpt_webui_prompt call.",
719
+ inputSchema: {
720
+ command: z.string().describe("Natural language command string."),
721
+ mode: z
722
+ .enum(["auto", "wait", "background"])
723
+ .optional()
724
+ .describe("Optional override. If omitted, inferred from command and defaults."),
725
+ wait_for_ms: z
726
+ .number()
727
+ .int()
728
+ .positive()
729
+ .optional()
730
+ .describe("Optional wait window for background mode before returning running state."),
731
+ poll_interval_ms: z
732
+ .number()
733
+ .int()
734
+ .positive()
735
+ .optional()
736
+ .describe("Polling interval when wait_for_ms is set. Default 2000ms."),
737
+ },
738
+ },
739
+ async ({ command, mode, wait_for_ms, poll_interval_ms }) => {
740
+ const parsed = parseAskInputFromCommand(command);
741
+ const modeFromText = parsed.modeHint;
742
+ const resolvedMode: "auto" | "wait" | "background" = mode ?? modeFromText ?? "auto";
743
+
744
+ return await promptHandler({
745
+ ...parsed.ask,
746
+ mode: resolvedMode,
747
+ wait_for_ms,
748
+ poll_interval_ms,
749
+ });
750
+ },
751
+ );
752
+ }
753
+
754
+ function createMcpServer(): McpServer {
755
+ const server = new McpServer(serverInfo);
756
+ registerTools(server);
757
+ return server;
758
+ }
759
+
760
+ async function main(): Promise<void> {
761
+ const transportType = String(process.env.MCP_TRANSPORT ?? "stdio").trim().toLowerCase();
762
+
763
+ if (transportType === "sse") {
764
+ const host = String(process.env.MCP_SSE_HOST ?? "127.0.0.1").trim();
765
+ const port = Number(process.env.MCP_SSE_PORT ?? 8791);
766
+
767
+ const transportsBySessionId = new Map<string, SSEServerTransport>();
768
+ const serversBySessionId = new Map<string, McpServer>();
769
+
770
+ const httpServer = http.createServer(async (req, res) => {
771
+ try {
772
+ const url = new URL(req.url ?? "/", `http://${host}:${port}`);
773
+
774
+ if (req.method === "GET" && url.pathname === "/sse") {
775
+ const transport = new SSEServerTransport("/messages", res);
776
+ const sessionId = transport.sessionId;
777
+ const server = createMcpServer();
778
+
779
+ let closed = false;
780
+ const closeSession = async (): Promise<void> => {
781
+ if (closed) {
782
+ return;
783
+ }
784
+
785
+ closed = true;
786
+ transportsBySessionId.delete(sessionId);
787
+ serversBySessionId.delete(sessionId);
788
+ transport.onclose = undefined;
789
+ transport.onerror = undefined;
790
+ try {
791
+ await server.close();
792
+ } catch {
793
+ // ignore close errors
794
+ }
795
+ };
796
+
797
+ transport.onclose = () => {
798
+ void closeSession();
799
+ };
800
+
801
+ transport.onerror = () => {
802
+ void closeSession();
803
+ };
804
+
805
+ transportsBySessionId.set(sessionId, transport);
806
+ serversBySessionId.set(sessionId, server);
807
+
808
+ await server.connect(transport);
809
+ return;
810
+ }
811
+
812
+ if (req.method === "POST" && url.pathname === "/messages") {
813
+ const sessionId = String(url.searchParams.get("sessionId") ?? "").trim();
814
+ const transport = sessionId ? transportsBySessionId.get(sessionId) ?? null : null;
815
+
816
+ if (!transport) {
817
+ res.writeHead(400, { "content-type": "application/json" });
818
+ res.end(JSON.stringify({ error: "sse_session_not_initialized", sessionId }));
819
+ return;
820
+ }
821
+
822
+ await transport.handlePostMessage(req, res);
823
+ return;
824
+ }
825
+
826
+ if (req.method === "GET" && url.pathname === "/health") {
827
+ res.writeHead(200, { "content-type": "application/json" });
828
+ res.end(JSON.stringify({ ok: true, transport: "sse" }));
829
+ return;
830
+ }
831
+
832
+ res.writeHead(404, { "content-type": "application/json" });
833
+ res.end(JSON.stringify({ error: "not_found" }));
834
+ } catch (error) {
835
+ res.writeHead(500, { "content-type": "application/json" });
836
+ res.end(JSON.stringify({ error: String(error) }));
837
+ }
838
+ });
839
+
840
+ await new Promise<void>((resolve, reject) => {
841
+ httpServer.once("error", reject);
842
+ httpServer.listen(port, host, () => resolve());
843
+ });
844
+
845
+ console.error(`chatgpt-webui-mcp server running on sse http://${host}:${port}/sse`);
846
+ return;
847
+ }
848
+
849
+ const server = createMcpServer();
850
+ const transport = new StdioServerTransport();
851
+ await server.connect(transport);
852
+ console.error("chatgpt-webui-mcp server running on stdio");
853
+ }
854
+
855
+ main().catch((error) => {
856
+ console.error("chatgpt-webui-mcp fatal:", error);
857
+ process.exit(1);
858
+ });