gogcli-mcp 2.29.1 → 3.0.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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +17105 -18892
  4. package/dist/lib.js +15157 -9562
  5. package/manifest.json +1 -1
  6. package/mint.yaml +2 -2
  7. package/package.json +5 -5
  8. package/server.json +2 -2
  9. package/src/blob-upload.ts +179 -0
  10. package/src/blob-urls.ts +280 -0
  11. package/src/connector-auth.ts +22 -6
  12. package/src/connector-login.ts +87 -0
  13. package/src/connector-runtime.ts +33 -10
  14. package/src/gmail-dispatch-guard.ts +106 -0
  15. package/src/gmail-results.ts +1 -1
  16. package/src/lib.ts +29 -0
  17. package/src/pagination.ts +1 -1
  18. package/src/runner.ts +19 -2
  19. package/src/tools/api.ts +7 -7
  20. package/src/tools/appscript.ts +15 -15
  21. package/src/tools/auth.ts +11 -11
  22. package/src/tools/calendar.ts +13 -13
  23. package/src/tools/chat.ts +25 -25
  24. package/src/tools/classroom.ts +49 -49
  25. package/src/tools/contacts.ts +9 -9
  26. package/src/tools/docs.ts +13 -13
  27. package/src/tools/drive.ts +22 -22
  28. package/src/tools/gmail.ts +136 -23
  29. package/src/tools/sheets.ts +15 -15
  30. package/src/tools/slides.ts +13 -13
  31. package/src/tools/tasks.ts +13 -13
  32. package/src/tools/utils.ts +2 -3
  33. package/src/worker.ts +52 -64
  34. package/tests/blob-upload.test.ts +204 -0
  35. package/tests/blob-urls.test.ts +319 -0
  36. package/tests/connector-login.test.ts +151 -0
  37. package/tests/connector-runtime.test.ts +21 -1
  38. package/tests/gmail-dispatch-guard.test.ts +132 -0
  39. package/tests/runner.test.ts +62 -1
  40. package/tests/sdk-single-copy.test.ts +18 -36
  41. package/tests/tools/appscript.test.ts +1 -1
  42. package/tests/tools/chat.test.ts +1 -1
  43. package/tests/tools/gmail.test.ts +242 -12
  44. package/tests/tools/sheets.test.ts +1 -1
  45. package/tests/zod-single-copy.test.ts +2 -2
  46. package/tsconfig.json +1 -3
  47. package/vitest.config.ts +3 -5
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
@@ -6,13 +6,13 @@ export function registerSlidesTools(server: McpServer): void {
6
6
  server.registerTool('gog_slides_export', {
7
7
  description: 'Export a Google Slides presentation to a local file (pdf or pptx).',
8
8
  annotations: { readOnlyHint: true },
9
- inputSchema: {
9
+ inputSchema: z.object({
10
10
  presentationId: z.string().describe('Presentation ID'),
11
11
  out: z.string().optional().describe('Output file path'),
12
12
  format: z.enum(['pdf', 'pptx']).optional().describe('Export format (default: pptx)'),
13
13
  overwrite: z.boolean().optional().describe('Overwrite the output file if it already exists (gog refuses otherwise)'),
14
14
  account: accountParam,
15
- },
15
+ }),
16
16
  }, async ({ presentationId, out, format, overwrite, account }) => {
17
17
  const args = ['slides', 'export', presentationId];
18
18
  if (out) args.push(`--out=${out}`);
@@ -24,22 +24,22 @@ export function registerSlidesTools(server: McpServer): void {
24
24
  server.registerTool('gog_slides_info', {
25
25
  description: 'Get metadata for a Google Slides presentation (title, ID, slide count, etc.).',
26
26
  annotations: { readOnlyHint: true },
27
- inputSchema: {
27
+ inputSchema: z.object({
28
28
  presentationId: z.string().describe('Presentation ID'),
29
29
  account: accountParam,
30
- },
30
+ }),
31
31
  }, async ({ presentationId, account }) => {
32
32
  return runOrDiagnose(['slides', 'info', presentationId], { account });
33
33
  });
34
34
 
35
35
  server.registerTool('gog_slides_create', {
36
36
  description: 'Create a new Google Slides presentation, optionally in a folder or copying from a template.',
37
- inputSchema: {
37
+ inputSchema: z.object({
38
38
  title: z.string().describe('Presentation title'),
39
39
  parent: z.string().optional().describe('Destination folder ID'),
40
40
  template: z.string().optional().describe('Template presentation ID to copy from'),
41
41
  account: accountParam,
42
- },
42
+ }),
43
43
  }, async ({ title, parent, template, account }) => {
44
44
  const args = ['slides', 'create', title];
45
45
  if (parent) args.push(`--parent=${parent}`);
@@ -49,12 +49,12 @@ export function registerSlidesTools(server: McpServer): void {
49
49
 
50
50
  server.registerTool('gog_slides_copy', {
51
51
  description: 'Copy a Google Slides presentation to a new presentation with the given title.',
52
- inputSchema: {
52
+ inputSchema: z.object({
53
53
  presentationId: z.string().describe('Presentation ID to copy'),
54
54
  title: z.string().describe('Title for the new copy'),
55
55
  parent: z.string().optional().describe('Destination folder ID'),
56
56
  account: accountParam,
57
- },
57
+ }),
58
58
  }, async ({ presentationId, title, parent, account }) => {
59
59
  const args = ['slides', 'copy', presentationId, title];
60
60
  if (parent) args.push(`--parent=${parent}`);
@@ -64,10 +64,10 @@ export function registerSlidesTools(server: McpServer): void {
64
64
  server.registerTool('gog_slides_list_slides', {
65
65
  description: 'List slides in a Google Slides presentation.',
66
66
  annotations: { readOnlyHint: true },
67
- inputSchema: {
67
+ inputSchema: z.object({
68
68
  presentationId: z.string().describe('Presentation ID'),
69
69
  account: accountParam,
70
- },
70
+ }),
71
71
  }, async ({ presentationId, account }) => {
72
72
  return runOrDiagnose(['slides', 'list-slides', presentationId], { account });
73
73
  });
@@ -75,12 +75,12 @@ export function registerSlidesTools(server: McpServer): void {
75
75
  server.registerTool('gog_slides_read_slide', {
76
76
  description: 'Read the content of a slide (text, shapes, speaker notes). Set detail=true to also include normalized element geometry, styled text runs, paragraphs, table-cell content, and image source URLs.',
77
77
  annotations: { readOnlyHint: true },
78
- inputSchema: {
78
+ inputSchema: z.object({
79
79
  presentationId: z.string().describe('Presentation ID'),
80
80
  slideId: z.string().describe('Slide ID to read'),
81
81
  detail: z.boolean().optional().describe('Include normalized element geometry, styled text runs, paragraphs, table-cell content, and image source URLs'),
82
82
  account: accountParam,
83
- },
83
+ }),
84
84
  }, async ({ presentationId, slideId, detail, account }) => {
85
85
  const args = ['slides', 'read-slide', presentationId, slideId];
86
86
  if (detail) args.push('--detail');
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
@@ -6,9 +6,9 @@ export function registerTasksTools(server: McpServer): void {
6
6
  server.registerTool('gog_tasks_lists', {
7
7
  description: 'List all Google Task lists.',
8
8
  annotations: { readOnlyHint: true },
9
- inputSchema: {
9
+ inputSchema: z.object({
10
10
  account: accountParam,
11
- },
11
+ }),
12
12
  }, async ({ account }) => {
13
13
  return runOrDiagnose(['tasks', 'lists', 'list'], { account });
14
14
  });
@@ -16,10 +16,10 @@ export function registerTasksTools(server: McpServer): void {
16
16
  server.registerTool('gog_tasks_list', {
17
17
  description: 'List tasks in a Google Task list.',
18
18
  annotations: { readOnlyHint: true },
19
- inputSchema: {
19
+ inputSchema: z.object({
20
20
  tasklistId: z.string().describe('Task list ID (use gog_tasks_lists to find IDs)'),
21
21
  account: accountParam,
22
- },
22
+ }),
23
23
  }, async ({ tasklistId, account }) => {
24
24
  return runOrDiagnose(['tasks', 'list', tasklistId], { account });
25
25
  });
@@ -27,11 +27,11 @@ export function registerTasksTools(server: McpServer): void {
27
27
  server.registerTool('gog_tasks_get', {
28
28
  description: 'Get a specific task by ID.',
29
29
  annotations: { readOnlyHint: true },
30
- inputSchema: {
30
+ inputSchema: z.object({
31
31
  tasklistId: z.string().describe('Task list ID'),
32
32
  taskId: z.string().describe('Task ID'),
33
33
  account: accountParam,
34
- },
34
+ }),
35
35
  }, async ({ tasklistId, taskId, account }) => {
36
36
  return runOrDiagnose(['tasks', 'get', tasklistId, taskId], { account });
37
37
  });
@@ -39,13 +39,13 @@ export function registerTasksTools(server: McpServer): void {
39
39
  server.registerTool('gog_tasks_add', {
40
40
  description: 'Add a task to a Google Task list.',
41
41
  annotations: { destructiveHint: false },
42
- inputSchema: {
42
+ inputSchema: z.object({
43
43
  tasklistId: z.string().describe('Task list ID'),
44
44
  title: z.string().describe('Task title'),
45
45
  notes: z.string().optional().describe('Task notes/description'),
46
46
  due: z.string().optional().describe('Due date (YYYY-MM-DD or RFC3339)'),
47
47
  account: accountParam,
48
- },
48
+ }),
49
49
  }, async ({ tasklistId, title, notes, due, account }) => {
50
50
  const args = ['tasks', 'add', tasklistId, `--title=${title}`];
51
51
  if (notes) args.push(`--notes=${notes}`);
@@ -56,11 +56,11 @@ export function registerTasksTools(server: McpServer): void {
56
56
  server.registerTool('gog_tasks_done', {
57
57
  description: 'Mark a task as completed.',
58
58
  annotations: { destructiveHint: true },
59
- inputSchema: {
59
+ inputSchema: z.object({
60
60
  tasklistId: z.string().describe('Task list ID'),
61
61
  taskId: z.string().describe('Task ID'),
62
62
  account: accountParam,
63
- },
63
+ }),
64
64
  }, async ({ tasklistId, taskId, account }) => {
65
65
  return runOrDiagnose(['tasks', 'done', tasklistId, taskId], { account });
66
66
  });
@@ -68,11 +68,11 @@ export function registerTasksTools(server: McpServer): void {
68
68
  server.registerTool('gog_tasks_delete', {
69
69
  description: 'Delete a task.',
70
70
  annotations: { destructiveHint: true },
71
- inputSchema: {
71
+ inputSchema: z.object({
72
72
  tasklistId: z.string().describe('Task list ID'),
73
73
  taskId: z.string().describe('Task ID'),
74
74
  account: accountParam,
75
- },
75
+ }),
76
76
  }, async ({ tasklistId, taskId, account }) => {
77
77
  // gog gates this delete behind a confirmation; the runner injects
78
78
  // --no-input, so without --force it refuses at runtime.
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
4
3
  import { errorResult, rawTextResult, minifiedResult, stripMediaUrls } from '@chrischall/mcp-utils';
5
4
  import { run, isRunnerTransportError } from '../runner.js';
6
5
  import type { GogArg, RunnerFailureKind } from '../runner.js';
@@ -149,7 +148,7 @@ export function registerRunTool(
149
148
  server.registerTool(`gog_${service}_run`, {
150
149
  description,
151
150
  annotations: { destructiveHint: true },
152
- inputSchema,
151
+ inputSchema: z.object(inputSchema),
153
152
  }, async (rawArgs) => {
154
153
  const { subcommand, args, account } = rawArgs as { subcommand: string; args: string[]; account?: string };
155
154
  return runOrDiagnose([service, subcommand, ...args], { account });
package/src/worker.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { McpAgent } from 'agents/mcp';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { createMcpHandler, getMcpAuthContext } from 'agents/mcp/server';
3
3
  import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
4
- import { handleAuthorize } from '@chrischall/mcp-connector';
5
4
  import type { ToolRegistrar } from '@chrischall/mcp-utils';
6
5
  import {
7
6
  BASE_TOOL_REGISTRARS,
@@ -15,8 +14,9 @@ import { registerExtraSheetsTools } from '../../gogcli-mcp-sheets/src/tools/shee
15
14
  import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-extra.js';
16
15
  import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
17
16
  import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
18
- import { makeFlyExecutor, wrapServer } from './connector-runtime.js';
17
+ import { createFlyExecutorResolver, wrapServer } from './connector-runtime.js';
19
18
  import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth.js';
19
+ import { handleAuthorize } from './connector-login.js';
20
20
 
21
21
  // The Cloudflare remote-connector entrypoint for gogcli-mcp.
22
22
  //
@@ -24,10 +24,9 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
24
24
  // every assembled `gog` arg-array by forwarding it to a Fly.io backend (a Worker
25
25
  // cannot spawn processes). The bridge is the `runExecutor` AsyncLocalStorage seam
26
26
  // in `runner.ts`: `wrapServer` scopes each tool handler in `runExecutor.run(...)`
27
- // so the handler's `run()` forwards to the per-session Fly executor.
27
+ // so the handler's `run()` forwards to the authenticated caller's Fly executor.
28
28
  //
29
- // One Worker serves several MCP endpoints under one OAuth login, each a distinct
30
- // tool set backed by its own Durable Object:
29
+ // One stateless Worker serves several MCP endpoints under one OAuth login:
31
30
  // /mcp all-services base (BASE_TOOL_REGISTRARS)
32
31
  // /mcp/sheets auth + Sheets base + Sheets extras
33
32
  // /mcp/gmail auth + Gmail base + Gmail extras
@@ -38,52 +37,48 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
37
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
38
  // connectors in claude.ai (each authorizes with the same connector key).
40
39
 
41
- const VERSION = '2.29.1'; // x-release-please-version
40
+ const VERSION = '3.0.0'; // x-release-please-version
42
41
 
43
- // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
- // each handler wrapped in the ALS scope carrying the per-session Fly executor.
45
- // (Kept in worker.ts, not connector-runtime.ts, because it imports the Worker-only
46
- // `agents` runtime; the node-testable helpers stay in connector-runtime.ts.)
47
- function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
48
- class GogAgent extends McpAgent<unknown, unknown, GogProps> {
49
- // `instructions` is the connector's only channel to the model that is not a
50
- // tool description, and it carries the one thing the client UI gets wrong:
51
- // "connected"/"refreshed" is a statement about the connector key, not about
52
- // Google. See CONNECTOR_INSTRUCTIONS for why that has to be said out loud.
53
- server = new McpServer(
54
- { name: 'gogcli-mcp', version: VERSION },
55
- { instructions: CONNECTOR_INSTRUCTIONS },
56
- );
57
- async init() {
58
- // NO third argument, deliberately: the hosted connector supplies no
59
- // per-caller access token, so `gog` runs as the Fly volume's own identity
60
- // and refreshes from its own keyring. That is what makes the eviction +
61
- // replay machinery in connector-runtime.ts INERT here — with no token
62
- // source there is no module-level cache that can go stale, so a Google
63
- // 401 on this path stops at the `no access token was supplied` guard and
64
- // logs `replay.declined`. That record is the expected outcome for a
65
- // hosted connector, not a bug; the transport-failure classification and
66
- // the auth log itself do apply here.
67
- //
68
- // Inert is not the same as unobserved. Because `gog` is spawned fresh per
69
- // /run and re-reads the keyring each time, a Google 401 here means the
70
- // STORED credential was refused — which no retry can repair, so no retry
71
- // is built. Instead that same guard first takes one live reading of the
72
- // Google layer (`GET /health/google` on the runner) and records it as
73
- // `refusal.google-ok` / `-unhealthy` / `-unmeasured`. It is throttled,
74
- // deadline-bounded, cannot throw, and leaves the caller's error
75
- // byte-identical; its whole job is to answer, in the log, the question
76
- // that could not be answered after the incident: at the moment Google
77
- // refused, was the refresh token on the volume alive or dead?
78
- // (docs/DEPLOY-CONNECTOR.md, "Reading the auth log" and "Why a hosted
79
- // Google 401 is measured rather than retried", says this for whoever is
80
- // reading logs rather than code.)
81
- const executor = makeFlyExecutor((this.env as { FLY_ENDPOINT: string }).FLY_ENDPOINT, this.props.key);
82
- const wrapped = wrapServer(this.server, executor);
83
- for (const register of registrars) register(wrapped);
84
- }
85
- }
86
- return GogAgent as unknown as typeof McpAgent;
42
+ type WorkerEnv = { FLY_ENDPOINT: string };
43
+
44
+ // One resolver per Worker isolate. Reusing an executor for the same backend
45
+ // credential preserves makeFlyExecutor's refusal-probe throttle across
46
+ // stateless requests; different credentials retain independent throttles.
47
+ const resolveFlyExecutor = createFlyExecutorResolver();
48
+
49
+ // Build a fresh SDK v2 server for every HTTP request. That is the transport
50
+ // model required by MCP 2026-07-28 multi round-trip requests: input_required
51
+ // returns to the client, and the later request reconstructs the server from
52
+ // the tool arguments plus protocol-managed inputResponses.
53
+ function makeHandler(route: string, registrars: ToolRegistrar[]) {
54
+ return {
55
+ fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext) {
56
+ const handler = createMcpHandler(() => {
57
+ // Resolve the OAuth props lazily while the tool runs. OAuthProvider
58
+ // establishes this request context before calling the MCP handler.
59
+ const executor = ((args, options) => {
60
+ const props = getMcpAuthContext()?.props as GogProps | undefined;
61
+ if (!props?.key) throw new Error('Missing authenticated gogcli connector key');
62
+ return resolveFlyExecutor(env.FLY_ENDPOINT, props.key)(args, options);
63
+ }) satisfies ReturnType<typeof resolveFlyExecutor>;
64
+
65
+ // `instructions` is the connector's only channel to the model that is
66
+ // not a tool description. It explains that connector authentication
67
+ // does not prove the Google credential itself is healthy.
68
+ const server = new McpServer(
69
+ { name: 'gogcli-mcp', version: VERSION },
70
+ { instructions: CONNECTOR_INSTRUCTIONS },
71
+ );
72
+ const wrapped = wrapServer(server, executor);
73
+ for (const register of registrars) register(wrapped);
74
+ return server;
75
+ }, {
76
+ route,
77
+ allowedHostnames: ['connector.gogcli.nullnet.app'],
78
+ });
79
+ return handler(request, env, ctx);
80
+ },
81
+ };
87
82
  }
88
83
 
89
84
  // auth + <service> base + <service> extras — the exact set each sub-package's
@@ -94,12 +89,6 @@ function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
94
89
  const svc = (service: string, base: ToolRegistrar, extra: ToolRegistrar): ToolRegistrar[] =>
95
90
  [authToolsFor(service), base, extra];
96
91
 
97
- export class GogcliMcpAgent extends makeAgent(BASE_TOOL_REGISTRARS) {}
98
- export class GogcliSheetsAgent extends makeAgent(svc('sheets', registerSheetsTools, registerExtraSheetsTools)) {}
99
- export class GogcliGmailAgent extends makeAgent(svc('gmail', registerGmailTools, registerExtraGmailTools)) {}
100
- export class GogcliDriveAgent extends makeAgent(svc('drive,driveactivity,drivelabels', registerDriveTools, registerExtraDriveTools)) {}
101
- export class GogcliDocsAgent extends makeAgent(svc('docs', registerDocsTools, registerExtraDocsTools)) {}
102
-
103
92
  const defaultHandler = {
104
93
  fetch(request: Request, env: unknown): Response | Promise<Response> {
105
94
  const url = new URL(request.url);
@@ -113,12 +102,11 @@ const defaultHandler = {
113
102
  // (otherwise `/mcp` greedily swallows `/mcp/sheets`).
114
103
  const handler = new OAuthProvider({
115
104
  apiHandlers: {
116
- '/mcp/sheets': GogcliSheetsAgent.serve('/mcp/sheets', { binding: 'SHEETS_MCP' }) as never,
117
- '/mcp/gmail': GogcliGmailAgent.serve('/mcp/gmail', { binding: 'GMAIL_MCP' }) as never,
118
- '/mcp/drive': GogcliDriveAgent.serve('/mcp/drive', { binding: 'DRIVE_MCP' }) as never,
119
- '/mcp/docs': GogcliDocsAgent.serve('/mcp/docs', { binding: 'DOCS_MCP' }) as never,
120
- '/mcp': GogcliMcpAgent.serve('/mcp') as never,
121
- '/sse': GogcliMcpAgent.serveSSE('/sse') as never,
105
+ '/mcp/sheets': makeHandler('/mcp/sheets', svc('sheets', registerSheetsTools, registerExtraSheetsTools)) as never,
106
+ '/mcp/gmail': makeHandler('/mcp/gmail', svc('gmail', registerGmailTools, registerExtraGmailTools)) as never,
107
+ '/mcp/drive': makeHandler('/mcp/drive', svc('drive,driveactivity,drivelabels', registerDriveTools, registerExtraDriveTools)) as never,
108
+ '/mcp/docs': makeHandler('/mcp/docs', svc('docs', registerDocsTools, registerExtraDocsTools)) as never,
109
+ '/mcp': makeHandler('/mcp', BASE_TOOL_REGISTRARS) as never,
122
110
  },
123
111
  defaultHandler: defaultHandler as never,
124
112
  authorizeEndpoint: '/authorize',
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { uploadToBlobStore, RUNNER_UPLOAD_TIMEOUT_MS } from '../src/blob-upload.js';
3
+
4
+ // A minted PUT URL, with the two shapes the scrubber has to recognise: the
5
+ // whole string, and the bare signature it carries.
6
+ const SIG = 'AbCdEf-_1234';
7
+ const PUT_URL = `https://host.example/b/reg_1/gmail/m1/Guest%20Copy.pdf?exp=1757000000000&sig=${SIG}`;
8
+
9
+ const ENV = { GOG_RUNNER_URL: 'https://runner.example', GOG_RUNNER_KEY: 'runner-key' };
10
+
11
+ const REQUEST = { path: '/tmp/gog-attachments/m1/Guest_Copy.pdf', url: PUT_URL, contentType: 'application/pdf' };
12
+
13
+ function answers(status: number, body: unknown, ok = status >= 200 && status < 300) {
14
+ return vi.fn(async () => ({
15
+ ok,
16
+ status,
17
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
18
+ }));
19
+ }
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals();
23
+ vi.unstubAllEnvs();
24
+ });
25
+
26
+ describe('uploadToBlobStore', () => {
27
+ it('POSTs the path, the URL and the content type to the runner, bearing its key', async () => {
28
+ const fetchMock = answers(200, { ok: true, status: 200, bytes: 99723 });
29
+ vi.stubGlobal('fetch', fetchMock);
30
+
31
+ const outcome = await uploadToBlobStore(REQUEST, { env: { ...ENV, GOG_RUNNER_URL: 'https://runner.example//' } });
32
+
33
+ expect(outcome).toEqual({ bytes: 99723, status: 200 });
34
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
35
+ // Trailing slashes trimmed — `<endpoint>//upload` is a different route.
36
+ expect(url).toBe('https://runner.example/upload');
37
+ expect(init.method).toBe('POST');
38
+ expect(init.headers).toMatchObject({
39
+ Authorization: 'Bearer runner-key',
40
+ 'Content-Type': 'application/json',
41
+ });
42
+ expect(JSON.parse(init.body as string)).toEqual({
43
+ path: REQUEST.path,
44
+ url: PUT_URL,
45
+ contentType: 'application/pdf',
46
+ });
47
+ // A deadline of our own: the runner's 120 s is an INACTIVITY timer and
48
+ // cannot bound a transfer that keeps dribbling, so nothing else protects
49
+ // the MCP caller from a request that never ends.
50
+ expect(init.signal).toBeInstanceOf(AbortSignal);
51
+ });
52
+
53
+ it('refuses, without dialling, when no gog runner is configured', async () => {
54
+ const fetchMock = answers(200, { ok: true });
55
+ vi.stubGlobal('fetch', fetchMock);
56
+
57
+ await expect(uploadToBlobStore(REQUEST, { env: { GOG_RUNNER_URL: ENV.GOG_RUNNER_URL } }))
58
+ .rejects.toThrow(/GOG_RUNNER_URL.*GOG_RUNNER_KEY/s);
59
+ await expect(uploadToBlobStore(REQUEST, { env: { GOG_RUNNER_KEY: ENV.GOG_RUNNER_KEY } }))
60
+ .rejects.toThrow(/GOG_RUNNER_URL.*GOG_RUNNER_KEY/s);
61
+ expect(fetchMock).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('reports the blob store\'s own verdict when the runner relays a refusal', async () => {
65
+ vi.stubGlobal('fetch', answers(422, {
66
+ error: 'the blob store refused the upload with 403: forbidden',
67
+ status: 403,
68
+ retryable: false,
69
+ }));
70
+
71
+ await expect(uploadToBlobStore(REQUEST, { env: ENV }))
72
+ .rejects.toThrow(/the blob store refused the upload with 403: forbidden/);
73
+ });
74
+
75
+ it('names the runner\'s status when it answers with no error text', async () => {
76
+ vi.stubGlobal('fetch', answers(502, { retryable: true }));
77
+
78
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/502/);
79
+ });
80
+
81
+ it('survives an answer that is not JSON at all (a proxy\'s HTML error page)', async () => {
82
+ vi.stubGlobal('fetch', answers(504, '<html>gateway timeout</html>'));
83
+
84
+ // The body is quoted, not parsed — and the throw is about the upload, never
85
+ // a SyntaxError from somewhere inside this module.
86
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/504/);
87
+ });
88
+
89
+ // A signed URL is a credential with up to 24 h of anybody-who-holds-it access
90
+ // to the object. The runner scrubs its own words, but the far side's text is
91
+ // a third party's and may quote the request URL straight back — and an error
92
+ // thrown here is the one value on this path that is expected to be logged.
93
+ it('never lets the signed URL, or its bare signature, into the error it throws', async () => {
94
+ vi.stubGlobal('fetch', answers(422, { error: `refused: PUT ${PUT_URL} (sig ${SIG})`, status: 403 }));
95
+
96
+ const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
97
+ expect(err).toBeInstanceOf(Error);
98
+ expect((err as Error).message).not.toContain(PUT_URL);
99
+ expect((err as Error).message).not.toContain(SIG);
100
+ expect((err as Error).message).toContain('<signed url>');
101
+ });
102
+
103
+ it('scrubs a transport failure too — a rejected fetch may quote the URL', async () => {
104
+ vi.stubGlobal('fetch', vi.fn(async () => {
105
+ throw new Error(`connect ECONNREFUSED while sending ${PUT_URL}`);
106
+ }));
107
+
108
+ const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
109
+ expect((err as Error).message).not.toContain(PUT_URL);
110
+ expect((err as Error).message).not.toContain(SIG);
111
+ expect((err as Error).message).toContain('ECONNREFUSED');
112
+ });
113
+
114
+ it('reports a transport failure that threw something other than an Error', async () => {
115
+ vi.stubGlobal('fetch', vi.fn(async () => { throw 'socket hang up'; }));
116
+
117
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/socket hang up/);
118
+ });
119
+
120
+ // The response ARRIVES and then its body fails to read — a severed or
121
+ // truncated answer. That is a third shape of transport failure, and the
122
+ // module's contract is about every one of them: nothing it throws carries the
123
+ // URL. undici's own body errors ('terminated', 'Premature close') happen to
124
+ // name nothing, so this is the guarantee being a property of the function
125
+ // rather than a fact about today's runtime.
126
+ it('scrubs a body that fails to read, as it scrubs a failed dial', async () => {
127
+ vi.stubGlobal('fetch', vi.fn(async () => ({
128
+ ok: true,
129
+ status: 200,
130
+ text: async () => { throw new Error(`terminated while reading ${PUT_URL}`); },
131
+ })));
132
+
133
+ const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
134
+ expect(err).toBeInstanceOf(Error);
135
+ expect((err as Error).message).not.toContain(PUT_URL);
136
+ expect((err as Error).message).not.toContain(SIG);
137
+ expect((err as Error).message).toContain('terminated');
138
+ });
139
+
140
+ it('reports a body read that rejected with something other than an Error', async () => {
141
+ vi.stubGlobal('fetch', vi.fn(async () => ({
142
+ ok: true,
143
+ status: 200,
144
+ text: async () => { throw 'premature close'; },
145
+ })));
146
+
147
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/premature close/);
148
+ });
149
+
150
+ // The whole-string replacement is what does the work for a URL with no `sig`
151
+ // at all — and a URL that is not parseable must still be redacted rather than
152
+ // throwing on the way to a redaction.
153
+ it('scrubs a URL that carries no signature and does not parse', async () => {
154
+ const odd = 'not a url at all';
155
+ vi.stubGlobal('fetch', answers(422, { error: `refused: ${odd}`, status: 400 }));
156
+
157
+ const err = await uploadToBlobStore({ ...REQUEST, url: odd }, { env: ENV }).catch((e: Error) => e);
158
+ expect((err as Error).message).not.toContain(odd);
159
+ expect((err as Error).message).toContain('<signed url>');
160
+ });
161
+
162
+ // `split('')` cuts a message into single characters and interleaves the
163
+ // replacement between every one of them, so an empty needle does not redact a
164
+ // message, it destroys it. Today's one call site always mints a URL first —
165
+ // this is a property of the function, which is why it is asserted on it.
166
+ it('an empty URL leaves the message intact rather than shredding it', async () => {
167
+ vi.stubGlobal('fetch', answers(422, { error: 'the blob store refused the upload', status: 403 }));
168
+
169
+ const err = await uploadToBlobStore({ ...REQUEST, url: '' }, { env: ENV }).catch((e: Error) => e);
170
+ expect((err as Error).message).toContain('the blob store refused the upload');
171
+ expect((err as Error).message).not.toContain('<signed url>');
172
+ });
173
+
174
+ it('reads the ambient environment, and its own deadline, when given neither', async () => {
175
+ const fetchMock = answers(200, { ok: true, status: 200, bytes: 12 });
176
+ vi.stubGlobal('fetch', fetchMock);
177
+ vi.stubEnv('GOG_RUNNER_URL', ENV.GOG_RUNNER_URL);
178
+ vi.stubEnv('GOG_RUNNER_KEY', ENV.GOG_RUNNER_KEY);
179
+
180
+ await expect(uploadToBlobStore(REQUEST)).resolves.toEqual({ bytes: 12, status: 200 });
181
+ expect((fetchMock.mock.calls[0] as [string, RequestInit])[0]).toBe('https://runner.example/upload');
182
+ });
183
+
184
+ it('exposes its deadline as a constant rather than a literal at the call site', () => {
185
+ expect(RUNNER_UPLOAD_TIMEOUT_MS).toBeGreaterThan(30_000);
186
+ });
187
+
188
+ // The rule this repo already applies at the other hop (`DEADLINE_GRACE_MS` in
189
+ // connector-runtime.ts: 30 s backend budget + 5 s): the CALLER's deadline sits
190
+ // ABOVE the backend's own, so the backend loses the race only when it
191
+ // genuinely cannot answer. The doc block here cited that rule while the
192
+ // numbers inverted it — 90 s against the runner's 120 s — so a socket that
193
+ // went quiet was aborted on this side 30 s before the runner's own timer could
194
+ // name it, turning "the upload timed out after 120000ms" into exactly the
195
+ // opaque client abort the rule exists to prevent.
196
+ //
197
+ // Restated rather than imported: `fly-gog-runner/server.mjs` must not be
198
+ // pulled into the Worker bundle (the same reason the attachment ceilings are
199
+ // restated in attachments.ts), so the two move by hand and this is the guard.
200
+ it('sits above the runner\'s own upload timeout, so the runner answers first', () => {
201
+ const UPLOAD_TIMEOUT_MS_ON_THE_BOX = 120_000; // server.mjs UPLOAD_TIMEOUT_MS
202
+ expect(RUNNER_UPLOAD_TIMEOUT_MS).toBeGreaterThan(UPLOAD_TIMEOUT_MS_ON_THE_BOX);
203
+ });
204
+ });