gogcli-mcp 2.18.4 → 2.19.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +84 -2
- package/dist/lib.js +85 -3
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/index.ts +6 -0
- package/src/lib.ts +1 -0
- package/src/remote-runner.ts +49 -0
- package/src/worker.ts +1 -1
- package/tests/remote-runner.test.ts +90 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.19.1"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "gogcli",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
18
|
-
"version": "2.
|
|
18
|
+
"version": "2.19.1",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/dist/index.js
CHANGED
|
@@ -3644,7 +3644,12 @@ var require_fast_uri = __commonJS({
|
|
|
3644
3644
|
}
|
|
3645
3645
|
function resolve(baseURI, relativeURI, options) {
|
|
3646
3646
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
3647
|
-
const
|
|
3647
|
+
const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
|
|
3648
|
+
const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
|
|
3649
|
+
if (baseMalformed || relativeMalformed) {
|
|
3650
|
+
throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
|
|
3651
|
+
}
|
|
3652
|
+
const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
|
|
3648
3653
|
schemelessOptions.skipEscape = true;
|
|
3649
3654
|
return serialize(resolved, schemelessOptions);
|
|
3650
3655
|
}
|
|
@@ -3770,6 +3775,7 @@ var require_fast_uri = __commonJS({
|
|
|
3770
3775
|
}
|
|
3771
3776
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
3772
3777
|
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
3778
|
+
var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
|
|
3773
3779
|
function getParseError(parsed, matches) {
|
|
3774
3780
|
if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
3775
3781
|
return 'URI path must start with "/" when authority is present.';
|
|
@@ -3804,6 +3810,20 @@ var require_fast_uri = __commonJS({
|
|
|
3804
3810
|
parsed.error = "URI authority must not contain a literal backslash.";
|
|
3805
3811
|
malformedAuthorityOrPort = true;
|
|
3806
3812
|
}
|
|
3813
|
+
const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION);
|
|
3814
|
+
if (introducerMatch !== null) {
|
|
3815
|
+
const region = introducerMatch[1];
|
|
3816
|
+
const normalizedRegion = region.replace(/[\t\n\r]/g, "");
|
|
3817
|
+
if (normalizedRegion.length >= 2) {
|
|
3818
|
+
if (normalizedRegion.slice(0, 2) !== "//") {
|
|
3819
|
+
parsed.error = parsed.error || "URI authority must not contain a literal backslash.";
|
|
3820
|
+
malformedAuthorityOrPort = true;
|
|
3821
|
+
} else if (region.length !== normalizedRegion.length) {
|
|
3822
|
+
parsed.error = parsed.error || "URI authority introducer must not contain whitespace.";
|
|
3823
|
+
malformedAuthorityOrPort = true;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3807
3827
|
const matches = uri.match(URI_PARSE);
|
|
3808
3828
|
if (matches) {
|
|
3809
3829
|
parsed.scheme = matches[1];
|
|
@@ -33052,7 +33072,7 @@ function registerTasksTools(server) {
|
|
|
33052
33072
|
}
|
|
33053
33073
|
|
|
33054
33074
|
// src/server.ts
|
|
33055
|
-
var VERSION = true ? "2.
|
|
33075
|
+
var VERSION = true ? "2.19.1" : "0.0.0";
|
|
33056
33076
|
var BASE_TOOL_REGISTRARS = [
|
|
33057
33077
|
registerApiTools,
|
|
33058
33078
|
registerAuthTools,
|
|
@@ -33067,7 +33087,69 @@ var BASE_TOOL_REGISTRARS = [
|
|
|
33067
33087
|
registerTasksTools
|
|
33068
33088
|
];
|
|
33069
33089
|
|
|
33090
|
+
// src/connector-runtime.ts
|
|
33091
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
33092
|
+
var DEADLINE_GRACE_MS = 5e3;
|
|
33093
|
+
var RUNNER_GOG_FAILED = 422;
|
|
33094
|
+
var RUNNER_DRAINING = 503;
|
|
33095
|
+
function makeFlyExecutor(endpoint, key) {
|
|
33096
|
+
return async (args, opts) => {
|
|
33097
|
+
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
33098
|
+
let res;
|
|
33099
|
+
try {
|
|
33100
|
+
res = await fetch(endpoint + "/run", {
|
|
33101
|
+
method: "POST",
|
|
33102
|
+
headers: {
|
|
33103
|
+
Authorization: "Bearer " + key,
|
|
33104
|
+
"Content-Type": "application/json"
|
|
33105
|
+
},
|
|
33106
|
+
body: JSON.stringify({ args }),
|
|
33107
|
+
signal: AbortSignal.timeout(deadlineMs)
|
|
33108
|
+
});
|
|
33109
|
+
} catch (err) {
|
|
33110
|
+
const name = err instanceof Error ? err.name : "";
|
|
33111
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
33112
|
+
throw new Error(
|
|
33113
|
+
`gog-runner did not respond within ${deadlineMs}ms (${endpoint}) \u2014 the Fly backend may be cold or wedged`
|
|
33114
|
+
);
|
|
33115
|
+
}
|
|
33116
|
+
throw err;
|
|
33117
|
+
}
|
|
33118
|
+
if (!res.ok) {
|
|
33119
|
+
const body = await res.json().catch(() => null);
|
|
33120
|
+
const detail = body && typeof body.error === "string" ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim() ? `${body.error}
|
|
33121
|
+
${body.stderr}` : body.error : "";
|
|
33122
|
+
if (res.status === RUNNER_GOG_FAILED) {
|
|
33123
|
+
throw new Error(detail || "gog failed on the runner (no detail supplied)");
|
|
33124
|
+
}
|
|
33125
|
+
if (res.status === RUNNER_DRAINING || body?.retryable === true) {
|
|
33126
|
+
throw new Error(
|
|
33127
|
+
`gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ""}`
|
|
33128
|
+
);
|
|
33129
|
+
}
|
|
33130
|
+
if (detail) {
|
|
33131
|
+
throw new Error(detail);
|
|
33132
|
+
}
|
|
33133
|
+
throw new Error(
|
|
33134
|
+
`gog-runner HTTP ${res.status}: the response did not come from the runner, so the request never reached gog. The backend Machine was most likely starting or shutting down \u2014 this is transient, retry the same call.`
|
|
33135
|
+
);
|
|
33136
|
+
}
|
|
33137
|
+
const { stdout } = await res.json();
|
|
33138
|
+
return stdout;
|
|
33139
|
+
};
|
|
33140
|
+
}
|
|
33141
|
+
|
|
33142
|
+
// src/remote-runner.ts
|
|
33143
|
+
function useRemoteGogRunner(env = process.env) {
|
|
33144
|
+
const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
|
|
33145
|
+
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
33146
|
+
if (!endpoint || !key) return false;
|
|
33147
|
+
runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ""), key) });
|
|
33148
|
+
return true;
|
|
33149
|
+
}
|
|
33150
|
+
|
|
33070
33151
|
// src/index.ts
|
|
33152
|
+
useRemoteGogRunner();
|
|
33071
33153
|
await runMcp({
|
|
33072
33154
|
name: "gogcli",
|
|
33073
33155
|
version: VERSION,
|
package/dist/lib.js
CHANGED
|
@@ -3643,7 +3643,12 @@ var require_fast_uri = __commonJS({
|
|
|
3643
3643
|
}
|
|
3644
3644
|
function resolve(baseURI, relativeURI, options) {
|
|
3645
3645
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
3646
|
-
const
|
|
3646
|
+
const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
|
|
3647
|
+
const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
|
|
3648
|
+
if (baseMalformed || relativeMalformed) {
|
|
3649
|
+
throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
|
|
3650
|
+
}
|
|
3651
|
+
const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
|
|
3647
3652
|
schemelessOptions.skipEscape = true;
|
|
3648
3653
|
return serialize(resolved, schemelessOptions);
|
|
3649
3654
|
}
|
|
@@ -3769,6 +3774,7 @@ var require_fast_uri = __commonJS({
|
|
|
3769
3774
|
}
|
|
3770
3775
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
3771
3776
|
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
3777
|
+
var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
|
|
3772
3778
|
function getParseError(parsed, matches) {
|
|
3773
3779
|
if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
3774
3780
|
return 'URI path must start with "/" when authority is present.';
|
|
@@ -3803,6 +3809,20 @@ var require_fast_uri = __commonJS({
|
|
|
3803
3809
|
parsed.error = "URI authority must not contain a literal backslash.";
|
|
3804
3810
|
malformedAuthorityOrPort = true;
|
|
3805
3811
|
}
|
|
3812
|
+
const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION);
|
|
3813
|
+
if (introducerMatch !== null) {
|
|
3814
|
+
const region = introducerMatch[1];
|
|
3815
|
+
const normalizedRegion = region.replace(/[\t\n\r]/g, "");
|
|
3816
|
+
if (normalizedRegion.length >= 2) {
|
|
3817
|
+
if (normalizedRegion.slice(0, 2) !== "//") {
|
|
3818
|
+
parsed.error = parsed.error || "URI authority must not contain a literal backslash.";
|
|
3819
|
+
malformedAuthorityOrPort = true;
|
|
3820
|
+
} else if (region.length !== normalizedRegion.length) {
|
|
3821
|
+
parsed.error = parsed.error || "URI authority introducer must not contain whitespace.";
|
|
3822
|
+
malformedAuthorityOrPort = true;
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3806
3826
|
const matches = uri.match(URI_PARSE);
|
|
3807
3827
|
if (matches) {
|
|
3808
3828
|
parsed.scheme = matches[1];
|
|
@@ -24958,7 +24978,7 @@ function registerTasksTools(server) {
|
|
|
24958
24978
|
}
|
|
24959
24979
|
|
|
24960
24980
|
// src/server.ts
|
|
24961
|
-
var VERSION = true ? "2.
|
|
24981
|
+
var VERSION = true ? "2.19.1" : "0.0.0";
|
|
24962
24982
|
var BASE_TOOL_REGISTRARS = [
|
|
24963
24983
|
registerApiTools,
|
|
24964
24984
|
registerAuthTools,
|
|
@@ -24972,6 +24992,67 @@ var BASE_TOOL_REGISTRARS = [
|
|
|
24972
24992
|
registerSlidesTools,
|
|
24973
24993
|
registerTasksTools
|
|
24974
24994
|
];
|
|
24995
|
+
|
|
24996
|
+
// src/connector-runtime.ts
|
|
24997
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
24998
|
+
var DEADLINE_GRACE_MS = 5e3;
|
|
24999
|
+
var RUNNER_GOG_FAILED = 422;
|
|
25000
|
+
var RUNNER_DRAINING = 503;
|
|
25001
|
+
function makeFlyExecutor(endpoint, key) {
|
|
25002
|
+
return async (args, opts) => {
|
|
25003
|
+
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
25004
|
+
let res;
|
|
25005
|
+
try {
|
|
25006
|
+
res = await fetch(endpoint + "/run", {
|
|
25007
|
+
method: "POST",
|
|
25008
|
+
headers: {
|
|
25009
|
+
Authorization: "Bearer " + key,
|
|
25010
|
+
"Content-Type": "application/json"
|
|
25011
|
+
},
|
|
25012
|
+
body: JSON.stringify({ args }),
|
|
25013
|
+
signal: AbortSignal.timeout(deadlineMs)
|
|
25014
|
+
});
|
|
25015
|
+
} catch (err) {
|
|
25016
|
+
const name = err instanceof Error ? err.name : "";
|
|
25017
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
25018
|
+
throw new Error(
|
|
25019
|
+
`gog-runner did not respond within ${deadlineMs}ms (${endpoint}) \u2014 the Fly backend may be cold or wedged`
|
|
25020
|
+
);
|
|
25021
|
+
}
|
|
25022
|
+
throw err;
|
|
25023
|
+
}
|
|
25024
|
+
if (!res.ok) {
|
|
25025
|
+
const body = await res.json().catch(() => null);
|
|
25026
|
+
const detail = body && typeof body.error === "string" ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim() ? `${body.error}
|
|
25027
|
+
${body.stderr}` : body.error : "";
|
|
25028
|
+
if (res.status === RUNNER_GOG_FAILED) {
|
|
25029
|
+
throw new Error(detail || "gog failed on the runner (no detail supplied)");
|
|
25030
|
+
}
|
|
25031
|
+
if (res.status === RUNNER_DRAINING || body?.retryable === true) {
|
|
25032
|
+
throw new Error(
|
|
25033
|
+
`gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ""}`
|
|
25034
|
+
);
|
|
25035
|
+
}
|
|
25036
|
+
if (detail) {
|
|
25037
|
+
throw new Error(detail);
|
|
25038
|
+
}
|
|
25039
|
+
throw new Error(
|
|
25040
|
+
`gog-runner HTTP ${res.status}: the response did not come from the runner, so the request never reached gog. The backend Machine was most likely starting or shutting down \u2014 this is transient, retry the same call.`
|
|
25041
|
+
);
|
|
25042
|
+
}
|
|
25043
|
+
const { stdout } = await res.json();
|
|
25044
|
+
return stdout;
|
|
25045
|
+
};
|
|
25046
|
+
}
|
|
25047
|
+
|
|
25048
|
+
// src/remote-runner.ts
|
|
25049
|
+
function useRemoteGogRunner(env = process.env) {
|
|
25050
|
+
const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
|
|
25051
|
+
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
25052
|
+
if (!endpoint || !key) return false;
|
|
25053
|
+
runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ""), key) });
|
|
25054
|
+
return true;
|
|
25055
|
+
}
|
|
24975
25056
|
export {
|
|
24976
25057
|
BASE_TOOL_REGISTRARS,
|
|
24977
25058
|
MIN_GOG_VERSION,
|
|
@@ -25001,5 +25082,6 @@ export {
|
|
|
25001
25082
|
run,
|
|
25002
25083
|
runBinary,
|
|
25003
25084
|
runExecutor,
|
|
25004
|
-
runOrDiagnose
|
|
25085
|
+
runOrDiagnose,
|
|
25086
|
+
useRemoteGogRunner
|
|
25005
25087
|
};
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp",
|
|
5
5
|
"display_name": "gogcli",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.19.1",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.1",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp",
|
|
5
5
|
"description": "MCP server wrapping gogcli for Google service access",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"zod": "^4.4.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@types/node": "^26.1.
|
|
49
|
+
"@types/node": "^26.1.2",
|
|
50
50
|
"@vitest/coverage-v8": "^4.1.8",
|
|
51
51
|
"esbuild": "^0.28.1",
|
|
52
52
|
"typescript": "^7.0.2",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.19.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.19.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
3
|
import { BASE_TOOL_REGISTRARS, VERSION } from './server.js';
|
|
4
|
+
import { useRemoteGogRunner } from './remote-runner.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// Execute `gog` on the Fly backend when the host points us at one; without
|
|
8
|
+
// it, nothing changes and we spawn the local binary as before.
|
|
9
|
+
useRemoteGogRunner();
|
|
4
10
|
|
|
5
11
|
await runMcp({
|
|
6
12
|
name: 'gogcli',
|
package/src/lib.ts
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
registerTasksTools,
|
|
16
16
|
} from './server.js';
|
|
17
17
|
export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
|
|
18
|
+
export { useRemoteGogRunner } from './remote-runner.js';
|
|
18
19
|
export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
|
|
19
20
|
export {
|
|
20
21
|
PAYLOAD_INLINE_MAX,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
+
import { runExecutor } from './runner.js';
|
|
3
|
+
import { makeFlyExecutor } from './connector-runtime.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Let a stdio server run `gog` on the Fly backend instead of spawning it.
|
|
7
|
+
*
|
|
8
|
+
* The default stdio path shells out to the `gog` binary (`runner.ts`,
|
|
9
|
+
* `spawn(GOG_PATH ?? 'gog')`), which is right on a laptop and impossible
|
|
10
|
+
* anywhere the binary is not installed — notably mcp-host, whose runner image
|
|
11
|
+
* is deliberately Node + git + tar and nothing else. Baking a Go binary into a
|
|
12
|
+
* generic runner for one MCP's sake, or curling an unpinned release tarball
|
|
13
|
+
* inside an install, are both worse than using the seam that already exists:
|
|
14
|
+
* `makeFlyExecutor` has forwarded arg-arrays to `<runner>/run` for the
|
|
15
|
+
* Cloudflare connector since that connector shipped, and it touches nothing
|
|
16
|
+
* Worker-only.
|
|
17
|
+
*
|
|
18
|
+
* So this is wiring, not new machinery. Set both variables and the process
|
|
19
|
+
* executes remotely; leave either unset and nothing changes, which is what
|
|
20
|
+
* keeps every existing local install on the binary it already has.
|
|
21
|
+
*
|
|
22
|
+
* ## Why `enterWith` and not `run`
|
|
23
|
+
*
|
|
24
|
+
* `runExecutor` is an AsyncLocalStorage. The Worker wraps each REQUEST in
|
|
25
|
+
* `runExecutor.run(...)` because a Worker isolate serves many of them and the
|
|
26
|
+
* executor differs per user. A stdio process is one user for its whole life,
|
|
27
|
+
* and its tool calls arrive later as I/O callbacks — which would NOT inherit a
|
|
28
|
+
* store established by a `run()` that had already returned. `enterWith` sets it
|
|
29
|
+
* for the remainder of this execution and everything descending from it, which
|
|
30
|
+
* is the whole process. Using `run()` here would look correct and then fall
|
|
31
|
+
* back to spawning on the first actual tool call.
|
|
32
|
+
*
|
|
33
|
+
* Call before the server starts, so no tool can be serviced ahead of it.
|
|
34
|
+
*/
|
|
35
|
+
export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
36
|
+
// The shared reader, not a local trim: it already treats blanks, unexpanded
|
|
37
|
+
// `${...}` placeholders AND the literal strings "undefined"/"null" as unset.
|
|
38
|
+
// Those last two are what a hand-rolled check misses, and they arrive whenever
|
|
39
|
+
// a host stringifies a missing value into an env block.
|
|
40
|
+
const endpoint = readEnvVar('GOG_RUNNER_URL', { env });
|
|
41
|
+
const key = readEnvVar('GOG_RUNNER_KEY', { env });
|
|
42
|
+
// Both or neither. A URL with no key would send unauthenticated requests the
|
|
43
|
+
// runner rejects, and a key with no URL is a credential configured for
|
|
44
|
+
// nothing — either alone is a misconfiguration, and silently spawning
|
|
45
|
+
// instead would hide it until someone wondered why the binary was needed.
|
|
46
|
+
if (!endpoint || !key) return false;
|
|
47
|
+
runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ''), key) });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
|
|
|
38
38
|
// connector with all ~360 tools at once. Add whichever paths you want as separate
|
|
39
39
|
// connectors in claude.ai (each authorizes with the same connector key).
|
|
40
40
|
|
|
41
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.19.1'; // x-release-please-version
|
|
42
42
|
|
|
43
43
|
// Build an McpAgent subclass whose init() registers `registrars` onto its server,
|
|
44
44
|
// each handler wrapped in the ALS scope carrying the per-session Fly executor.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
5
|
+
import { useRemoteGogRunner } from '../src/remote-runner.js';
|
|
6
|
+
import { runExecutor } from '../src/runner.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The whole point of this seam is that a host without the `gog` binary can
|
|
10
|
+
* still serve. Two ways it silently fails: a half-configured env that falls
|
|
11
|
+
* back to spawning, and using `run()` instead of `enterWith()` so the store is
|
|
12
|
+
* gone by the time a tool call arrives.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
16
|
+
|
|
17
|
+
describe('useRemoteGogRunner', () => {
|
|
18
|
+
it('does nothing unless BOTH variables are set, so local installs are untouched', () => {
|
|
19
|
+
expect(useRemoteGogRunner({})).toBe(false);
|
|
20
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test' })).toBe(false);
|
|
21
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_KEY: 'k' })).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('treats blanks, placeholders and stringified nothings as unset', () => {
|
|
25
|
+
// MCP hosts pass env blocks through verbatim, so all three of these arrive
|
|
26
|
+
// in practice: a blank, a literal `${...}` that never expanded, and the
|
|
27
|
+
// string "undefined" from a host that stringified a missing value. The
|
|
28
|
+
// shared readEnvVar knows all three; a hand-rolled trim knew only the first
|
|
29
|
+
// two, which is why this uses the shared one.
|
|
30
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: ' ', GOG_RUNNER_KEY: 'k' })).toBe(false);
|
|
31
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: '${GOG_RUNNER_KEY}' })).toBe(false);
|
|
32
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'undefined' })).toBe(false);
|
|
33
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'null', GOG_RUNNER_KEY: 'k' })).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('installs an executor that survives into a LATER async callback', async () => {
|
|
37
|
+
// The real failure mode this guards: with `run()` the store would be gone
|
|
38
|
+
// by the time a tool call arrives as an I/O callback, and the server would
|
|
39
|
+
// quietly go back to spawning a binary that is not installed.
|
|
40
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
|
|
41
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
42
|
+
|
|
43
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test/', GOG_RUNNER_KEY: 'secret' })).toBe(true);
|
|
44
|
+
|
|
45
|
+
// Cross a macrotask boundary, the way a stdio tool call does.
|
|
46
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
47
|
+
const store = runExecutor.getStore();
|
|
48
|
+
expect(store?.executor).toBeTypeOf('function');
|
|
49
|
+
|
|
50
|
+
await store!.executor(['--version'], {});
|
|
51
|
+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
52
|
+
// Trailing slash trimmed, so the endpoint is never `…//run`.
|
|
53
|
+
expect(url).toBe('https://r.test/run');
|
|
54
|
+
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer secret');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The seam is opt-in PER BIN — `useRemoteGogRunner()` is a call in each
|
|
60
|
+
* package's `index.ts`, and a package that omits it spawns the binary no
|
|
61
|
+
* matter what the host sets. That shipped: 2.19.0 wired base, sheets, docs,
|
|
62
|
+
* drive and gmail, and left calendar, classroom, contacts and slides behind,
|
|
63
|
+
* so those four answered every tool call on mcp-host with "gog executable not
|
|
64
|
+
* found" — a host with no binary and no way to ask for the backend.
|
|
65
|
+
*
|
|
66
|
+
* Nothing caught it because each package's suite covers its TOOLS, and
|
|
67
|
+
* `src/index.ts` is excluded from the coverage gate in every package (it is
|
|
68
|
+
* the bin: it boots a server and cannot be imported under test). So this
|
|
69
|
+
* asserts on the source text instead, across the whole workspace — the one
|
|
70
|
+
* check that scales to the next sub-package, which will otherwise be added by
|
|
71
|
+
* copying an index.ts that predates the seam.
|
|
72
|
+
*/
|
|
73
|
+
describe('every package bin', () => {
|
|
74
|
+
const packagesDir = fileURLToPath(new URL('../../', import.meta.url));
|
|
75
|
+
const bins = readdirSync(packagesDir)
|
|
76
|
+
.filter((name) => name.startsWith('gogcli-mcp'))
|
|
77
|
+
.map((name) => [name, join(packagesDir, name, 'src', 'index.ts')] as const);
|
|
78
|
+
|
|
79
|
+
it('finds every package (guards the glob itself against silently matching nothing)', () => {
|
|
80
|
+
expect(bins.length).toBeGreaterThanOrEqual(9);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each(bins)('%s installs the remote executor before starting the server', (_name, path) => {
|
|
84
|
+
const source = readFileSync(path, 'utf8');
|
|
85
|
+
expect(source).toContain('useRemoteGogRunner()');
|
|
86
|
+
// Order matters as much as presence: `runMcp` starts serving, so a call
|
|
87
|
+
// placed after it could be beaten by a tool call and fall back to spawning.
|
|
88
|
+
expect(source.indexOf('useRemoteGogRunner()')).toBeLessThan(source.indexOf('runMcp({'));
|
|
89
|
+
});
|
|
90
|
+
});
|