gogcli-mcp-docs 4.9.1 → 4.10.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -0
- package/SKILL.md +8 -0
- package/dist/index.js +680 -24
- package/manifest.json +5 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/index.ts +2 -2
- package/src/tools/docs-extra.ts +30 -14
- package/tests/tool-annotations.test.ts +4 -4
- package/tests/tools/docs-blobkeys.test.ts +83 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
|
|
10
|
-
"version": "4.
|
|
10
|
+
"version": "4.10.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "gogcli (Docs)",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
|
|
18
|
-
"version": "4.
|
|
18
|
+
"version": "4.10.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -79,6 +79,14 @@ These bugs live in gogcli's upstream markdown → Docs converter; the wrapper ju
|
|
|
79
79
|
| Inline `**bold**` / `*italic*` / `` `code` `` inside table cells renders as literal characters | Insert plain cell text, then apply formatting via `gog_docs_format` | [openclaw/gogcli#608](https://github.com/openclaw/gogcli/issues/608) |
|
|
80
80
|
| Tables with an empty header row leak the last data row as literal pipe text | Always supply a non-empty header row | [openclaw/gogcli#609](https://github.com/openclaw/gogcli/issues/609) |
|
|
81
81
|
|
|
82
|
+
## Files on a hosted deployment
|
|
83
|
+
|
|
84
|
+
When this server is hosted (remote), it cannot read paths on your machine. Call
|
|
85
|
+
`gog_upload_slots` with `files: [{filename}]`, PUT each file to its `uploadUrl`
|
|
86
|
+
with the slot's `curl` command, then pass the returned `blobKeys` to `gog_docs_insert_image` and `gog_docs_replace_image` (instead of `file`).
|
|
87
|
+
Files up to 25 MiB; each is deleted from the upload store after the call succeeds
|
|
88
|
+
and kept if it fails. On a local install, pass a path as before.
|
|
89
|
+
|
|
82
90
|
## License
|
|
83
91
|
|
|
84
92
|
MIT
|
package/SKILL.md
CHANGED
|
@@ -62,3 +62,11 @@ Three bugs in gogcli's upstream markdown→Docs converter to be aware of:
|
|
|
62
62
|
- **3+ tables in one call** reorders the trailing punctuation of the paragraph before the 3rd table — split into multiple calls with ≤2 tables each ([openclaw/gogcli#607](https://github.com/openclaw/gogcli/issues/607)).
|
|
63
63
|
- **Inline `**bold**` / `*italic*` / `` `code` `` inside table cells** renders as literal characters — insert plain cell text, then apply formatting via `gog_docs_format` ([openclaw/gogcli#608](https://github.com/openclaw/gogcli/issues/608)).
|
|
64
64
|
- **Empty-header tables** leak the last data row as literal pipe text — always supply a non-empty header row ([openclaw/gogcli#609](https://github.com/openclaw/gogcli/issues/609)).
|
|
65
|
+
|
|
66
|
+
## Files on a hosted deployment
|
|
67
|
+
|
|
68
|
+
When this server is hosted (remote), it cannot read paths on your machine. Call
|
|
69
|
+
`gog_upload_slots` with `files: [{filename}]`, PUT each file to its `uploadUrl`
|
|
70
|
+
with the slot's `curl` command, then pass the returned `blobKeys` to `gog_docs_insert_image` and `gog_docs_replace_image` (instead of `file`).
|
|
71
|
+
Files up to 25 MiB; each is deleted from the upload store after the call succeeds
|
|
72
|
+
and kept if it fails. On a local install, pass a path as before.
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,14 @@ function withCallSignal(signal, fn, request) {
|
|
|
14
14
|
function currentCallSignal() {
|
|
15
15
|
return storage.getStore()?.signal;
|
|
16
16
|
}
|
|
17
|
+
function withAmbientCancellation(own2) {
|
|
18
|
+
const ambient = currentCallSignal();
|
|
19
|
+
if (!ambient)
|
|
20
|
+
return own2;
|
|
21
|
+
if (!own2)
|
|
22
|
+
return ambient;
|
|
23
|
+
return AbortSignal.any([own2, ambient]);
|
|
24
|
+
}
|
|
17
25
|
function killOnCancel(child, signal = "SIGTERM") {
|
|
18
26
|
const ambient = currentCallSignal();
|
|
19
27
|
if (!ambient)
|
|
@@ -36309,9 +36317,158 @@ import { constants as fsConstants2, realpathSync as realpathSync2 } from "node:f
|
|
|
36309
36317
|
var O_NOFOLLOW2 = fsConstants2.O_NOFOLLOW ?? 0;
|
|
36310
36318
|
var O_NONBLOCK = fsConstants2.O_NONBLOCK ?? 0;
|
|
36311
36319
|
|
|
36320
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/internal/headers.js
|
|
36321
|
+
function responseHeader(res, name) {
|
|
36322
|
+
return getHeader(res?.headers, name);
|
|
36323
|
+
}
|
|
36324
|
+
function getHeader(headers, name) {
|
|
36325
|
+
if (!headers || typeof headers.get !== "function")
|
|
36326
|
+
return null;
|
|
36327
|
+
const value = headers.get.call(headers, name);
|
|
36328
|
+
return typeof value === "string" ? value : null;
|
|
36329
|
+
}
|
|
36330
|
+
|
|
36312
36331
|
// ../../node_modules/@chrischall/mcp-utils/dist/scrape/index.js
|
|
36313
36332
|
var NEXT_DATA_MAX_CHARS = 16 * 1024 * 1024;
|
|
36314
36333
|
|
|
36334
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/http/body-limit.js
|
|
36335
|
+
var ResponseTooLargeError = class extends McpToolError {
|
|
36336
|
+
maxBytes;
|
|
36337
|
+
constructor(service, maxBytes) {
|
|
36338
|
+
super(`Response from ${service} is larger than the ${maxBytes}-byte limit.`, {
|
|
36339
|
+
hint: "Raise maxBytes (or the client's maxResponseBytes) if a response this large is expected.",
|
|
36340
|
+
kind: "too_large"
|
|
36341
|
+
});
|
|
36342
|
+
this.name = "ResponseTooLargeError";
|
|
36343
|
+
this.maxBytes = maxBytes;
|
|
36344
|
+
}
|
|
36345
|
+
};
|
|
36346
|
+
async function readBytesCapped(res, maxBytes, service, bounded) {
|
|
36347
|
+
if (maxBytes !== void 0) {
|
|
36348
|
+
const declared = Number(responseHeader(res, "content-length") ?? NaN);
|
|
36349
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
36350
|
+
cancelStream(res.body);
|
|
36351
|
+
throw new ResponseTooLargeError(service, maxBytes);
|
|
36352
|
+
}
|
|
36353
|
+
}
|
|
36354
|
+
if (!res.body)
|
|
36355
|
+
return new Uint8Array(0);
|
|
36356
|
+
const reader = res.body.getReader();
|
|
36357
|
+
const chunks = [];
|
|
36358
|
+
let total = 0;
|
|
36359
|
+
try {
|
|
36360
|
+
for (; ; ) {
|
|
36361
|
+
const { done, value } = await bounded(reader.read());
|
|
36362
|
+
if (done)
|
|
36363
|
+
break;
|
|
36364
|
+
total += value.byteLength;
|
|
36365
|
+
if (maxBytes !== void 0 && total > maxBytes)
|
|
36366
|
+
throw new ResponseTooLargeError(service, maxBytes);
|
|
36367
|
+
chunks.push(value);
|
|
36368
|
+
}
|
|
36369
|
+
} catch (err) {
|
|
36370
|
+
reader.cancel().catch(() => {
|
|
36371
|
+
});
|
|
36372
|
+
throw err;
|
|
36373
|
+
}
|
|
36374
|
+
const out = new Uint8Array(total);
|
|
36375
|
+
let offset = 0;
|
|
36376
|
+
for (const c of chunks) {
|
|
36377
|
+
out.set(c, offset);
|
|
36378
|
+
offset += c.byteLength;
|
|
36379
|
+
}
|
|
36380
|
+
return out;
|
|
36381
|
+
}
|
|
36382
|
+
function cancelStream(body) {
|
|
36383
|
+
try {
|
|
36384
|
+
body?.cancel().catch(() => {
|
|
36385
|
+
});
|
|
36386
|
+
} catch {
|
|
36387
|
+
}
|
|
36388
|
+
}
|
|
36389
|
+
|
|
36390
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/http/fetch-bounded.js
|
|
36391
|
+
async function fetchBounded(url2, init, opts = {}) {
|
|
36392
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
36393
|
+
const service = opts.service ?? hostOf(url2);
|
|
36394
|
+
const read = opts.read ?? "text";
|
|
36395
|
+
const timeoutMs = opts.timeoutMs === false ? 0 : opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
36396
|
+
const caller = withAmbientCancellation(init?.signal ?? void 0);
|
|
36397
|
+
if (caller?.aborted)
|
|
36398
|
+
throw reasonOf(caller);
|
|
36399
|
+
const timer = timeoutMs > 0 ? new AbortController() : void 0;
|
|
36400
|
+
const signal = combine(timer?.signal, caller);
|
|
36401
|
+
let handle;
|
|
36402
|
+
let detach = () => {
|
|
36403
|
+
};
|
|
36404
|
+
const stop = signal ? new Promise((_resolve, reject) => {
|
|
36405
|
+
const onAbort = () => reject(timer?.signal.aborted ? new RequestTimeoutError(service, timeoutMs) : reasonOf(caller));
|
|
36406
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
36407
|
+
detach = () => signal.removeEventListener("abort", onAbort);
|
|
36408
|
+
}) : void 0;
|
|
36409
|
+
stop?.catch(() => {
|
|
36410
|
+
});
|
|
36411
|
+
const bounded = (p) => stop ? Promise.race([p, stop]) : p;
|
|
36412
|
+
if (timer)
|
|
36413
|
+
handle = setTimeout(() => timer.abort(), timeoutMs);
|
|
36414
|
+
try {
|
|
36415
|
+
let res;
|
|
36416
|
+
try {
|
|
36417
|
+
res = await bounded(doFetch(url2, { ...init, ...signal ? { signal } : {} }));
|
|
36418
|
+
} catch (err) {
|
|
36419
|
+
if (timer?.signal.aborted)
|
|
36420
|
+
throw new RequestTimeoutError(service, timeoutMs);
|
|
36421
|
+
if (caller?.aborted)
|
|
36422
|
+
throw reasonOf(caller);
|
|
36423
|
+
throw err;
|
|
36424
|
+
}
|
|
36425
|
+
if (read === "none") {
|
|
36426
|
+
cancelStream(res.body);
|
|
36427
|
+
return done(res, void 0);
|
|
36428
|
+
}
|
|
36429
|
+
const bytes = await readBytesCapped(res, opts.maxBytes, service, bounded);
|
|
36430
|
+
if (read === "bytes")
|
|
36431
|
+
return done(res, bytes);
|
|
36432
|
+
const text = new TextDecoder().decode(bytes);
|
|
36433
|
+
if (read === "text")
|
|
36434
|
+
return done(res, text);
|
|
36435
|
+
if (text.length === 0)
|
|
36436
|
+
return done(res, void 0);
|
|
36437
|
+
try {
|
|
36438
|
+
return done(res, JSON.parse(text));
|
|
36439
|
+
} catch (cause) {
|
|
36440
|
+
const type = res.headers.get("content-type") ?? "no content-type";
|
|
36441
|
+
throw new McpToolError(`${service} returned a non-JSON response (HTTP ${res.status}, ${type}).`, {
|
|
36442
|
+
hint: "The upstream may have served an error or sign-in page instead of data.",
|
|
36443
|
+
cause,
|
|
36444
|
+
status: res.status
|
|
36445
|
+
});
|
|
36446
|
+
}
|
|
36447
|
+
} finally {
|
|
36448
|
+
clearTimeout(handle);
|
|
36449
|
+
detach();
|
|
36450
|
+
}
|
|
36451
|
+
function done(res, body) {
|
|
36452
|
+
return { res, status: res.status, ok: res.ok, headers: res.headers, body };
|
|
36453
|
+
}
|
|
36454
|
+
}
|
|
36455
|
+
function combine(a, b) {
|
|
36456
|
+
if (a && b)
|
|
36457
|
+
return AbortSignal.any([a, b]);
|
|
36458
|
+
return a ?? b;
|
|
36459
|
+
}
|
|
36460
|
+
function reasonOf(signal) {
|
|
36461
|
+
const reason = signal.reason;
|
|
36462
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
36463
|
+
}
|
|
36464
|
+
function hostOf(url2) {
|
|
36465
|
+
try {
|
|
36466
|
+
return new URL(url2).host;
|
|
36467
|
+
} catch {
|
|
36468
|
+
return String(url2);
|
|
36469
|
+
}
|
|
36470
|
+
}
|
|
36471
|
+
|
|
36315
36472
|
// ../../node_modules/@chrischall/mcp-utils/dist/netguard/address.js
|
|
36316
36473
|
function parseIPv4(text) {
|
|
36317
36474
|
if (!/^(?:0|[1-9]\d{0,2})(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(text))
|
|
@@ -36395,6 +36552,18 @@ var NAT64 = V6("64:ff9b::");
|
|
|
36395
36552
|
var SIX_TO_FOUR = V6("2002::");
|
|
36396
36553
|
|
|
36397
36554
|
// ../../node_modules/@chrischall/mcp-utils/dist/http/index.js
|
|
36555
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
36556
|
+
var RequestTimeoutError = class extends Error {
|
|
36557
|
+
timeoutMs;
|
|
36558
|
+
/** Always `'timeout'` unless a subclass says otherwise. */
|
|
36559
|
+
kind = "timeout";
|
|
36560
|
+
constructor(service, timeoutMs) {
|
|
36561
|
+
super(`Request to ${service} timed out after ${timeoutMs}ms.`);
|
|
36562
|
+
this.name = "RequestTimeoutError";
|
|
36563
|
+
this.timeoutMs = timeoutMs;
|
|
36564
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
36565
|
+
}
|
|
36566
|
+
};
|
|
36398
36567
|
var EDGE_BLOCK_SCAN_MAX = 8 * 1024;
|
|
36399
36568
|
|
|
36400
36569
|
// ../../node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
@@ -37508,6 +37677,445 @@ var attachInlineParam = external_exports.array(inlineAttachmentSchema).optional(
|
|
|
37508
37677
|
`Attachments supplied as BYTES rather than as server-side paths \u2014 use this whenever you hold a file and the gog server does not, which is always the case on a hosted deployment (e.g. mcp-host). Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). Can be combined with \`attach\`: the two name disjoint files (paths read on the server vs. bytes sent with the call), and both end up as ordinary attachments on the message.`
|
|
37509
37678
|
);
|
|
37510
37679
|
|
|
37680
|
+
// ../gogcli-mcp/src/upload-slots.ts
|
|
37681
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
37682
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
37683
|
+
|
|
37684
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/export/blob.js
|
|
37685
|
+
import { createHmac as createHmac3 } from "node:crypto";
|
|
37686
|
+
var BLOB_DEFAULT_TTL_MS = 60 * 60 * 1e3;
|
|
37687
|
+
var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
37688
|
+
var BLOB_MAX_OBJECT_BYTES = 100 * 1024 * 1024;
|
|
37689
|
+
var MAX_KEY_LENGTH = 1024;
|
|
37690
|
+
var SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
37691
|
+
function configError(reason) {
|
|
37692
|
+
return new McpToolError(`MCP_BLOB_BASE_URL is not usable: ${reason}.`, {
|
|
37693
|
+
hint: "It should look like https://<host>/b/<registrationId> \u2014 check the deployment's environment.",
|
|
37694
|
+
kind: "unknown"
|
|
37695
|
+
});
|
|
37696
|
+
}
|
|
37697
|
+
function parseBase(baseUrl) {
|
|
37698
|
+
let parsed;
|
|
37699
|
+
try {
|
|
37700
|
+
parsed = new URL(baseUrl);
|
|
37701
|
+
} catch {
|
|
37702
|
+
throw configError("not a valid URL");
|
|
37703
|
+
}
|
|
37704
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
37705
|
+
throw configError("scheme must be http(s)");
|
|
37706
|
+
if (parsed.username || parsed.password)
|
|
37707
|
+
throw configError("must not contain credentials");
|
|
37708
|
+
if (parsed.search || parsed.hash)
|
|
37709
|
+
throw configError("must not contain a query or fragment");
|
|
37710
|
+
const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
|
|
37711
|
+
const prefix = path.startsWith("b/") ? path.slice(2) : path;
|
|
37712
|
+
if (!prefix || prefix === "b")
|
|
37713
|
+
throw configError("has no registration path");
|
|
37714
|
+
return { base: `${parsed.origin}/${path}`, prefix };
|
|
37715
|
+
}
|
|
37716
|
+
function badInput(message) {
|
|
37717
|
+
return new McpToolError(message, { kind: "unknown" });
|
|
37718
|
+
}
|
|
37719
|
+
function encodeKey(key) {
|
|
37720
|
+
if (typeof key !== "string" || key.length === 0)
|
|
37721
|
+
throw badInput("Blob key must be a non-empty string.");
|
|
37722
|
+
if (key.length > MAX_KEY_LENGTH)
|
|
37723
|
+
throw badInput(`Blob key is longer than ${MAX_KEY_LENGTH} characters.`);
|
|
37724
|
+
const segments = key.split("/");
|
|
37725
|
+
for (const seg of segments) {
|
|
37726
|
+
if (seg === "" || seg === "." || seg === ".." || !SAFE_SEGMENT.test(seg)) {
|
|
37727
|
+
throw badInput('Blob key must be relative, slash-separated segments of [A-Za-z0-9._~-] with no empty, "." or ".." segment.');
|
|
37728
|
+
}
|
|
37729
|
+
}
|
|
37730
|
+
return segments.map(encodeURIComponent).join("/");
|
|
37731
|
+
}
|
|
37732
|
+
function checkContentType(contentType) {
|
|
37733
|
+
if (typeof contentType !== "string" || contentType.length === 0 || /[\x00-\x1f\x7f]/.test(contentType)) {
|
|
37734
|
+
throw badInput("Blob content type must be a non-empty string without control characters.");
|
|
37735
|
+
}
|
|
37736
|
+
}
|
|
37737
|
+
function expiry(opts) {
|
|
37738
|
+
const ttl = opts?.ttlMs ?? BLOB_DEFAULT_TTL_MS;
|
|
37739
|
+
if (!Number.isFinite(ttl) || ttl <= 0 || ttl > BLOB_MAX_TTL_MS) {
|
|
37740
|
+
throw badInput(`Blob link TTL must be between 1 ms and ${BLOB_MAX_TTL_MS} ms (24h); the gateway refuses longer.`);
|
|
37741
|
+
}
|
|
37742
|
+
return Math.floor((opts?.now ?? Date.now()) + ttl);
|
|
37743
|
+
}
|
|
37744
|
+
function toArrayBuffer(view) {
|
|
37745
|
+
const buf = view.buffer;
|
|
37746
|
+
if (buf instanceof ArrayBuffer && view.byteOffset === 0 && view.byteLength === buf.byteLength)
|
|
37747
|
+
return buf;
|
|
37748
|
+
return view.slice().buffer;
|
|
37749
|
+
}
|
|
37750
|
+
function createBlobStore(opts) {
|
|
37751
|
+
const { base, prefix } = parseBase(opts.baseUrl);
|
|
37752
|
+
const signingKey = opts.signingKey;
|
|
37753
|
+
const sign2 = (payload) => createHmac3("sha256", signingKey).update(payload).digest("base64url");
|
|
37754
|
+
const link = (encodedKey, exp, sig) => `${base}/${encodedKey}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
|
|
37755
|
+
const signGetUrl = (key, o) => {
|
|
37756
|
+
const encoded = encodeKey(key);
|
|
37757
|
+
const exp = expiry(o);
|
|
37758
|
+
return { url: link(encoded, exp, sign2(`${prefix}/${key}
|
|
37759
|
+
${exp}`)), expiresAtMs: exp };
|
|
37760
|
+
};
|
|
37761
|
+
const signPutUrl = (key, contentType, o) => {
|
|
37762
|
+
const encoded = encodeKey(key);
|
|
37763
|
+
checkContentType(contentType);
|
|
37764
|
+
const exp = expiry(o);
|
|
37765
|
+
return { url: link(encoded, exp, sign2(`put\0${prefix}/${key}\0${contentType}\0${exp}`)), expiresAtMs: exp };
|
|
37766
|
+
};
|
|
37767
|
+
const put = async (key, body, contentType, o = {}) => {
|
|
37768
|
+
const { url: url2 } = signPutUrl(key, contentType, o);
|
|
37769
|
+
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
37770
|
+
const doFetch = o.fetchImpl ?? fetch;
|
|
37771
|
+
let res;
|
|
37772
|
+
try {
|
|
37773
|
+
res = await doFetch(url2, {
|
|
37774
|
+
method: "PUT",
|
|
37775
|
+
headers: { "content-type": contentType },
|
|
37776
|
+
body: toArrayBuffer(bytes),
|
|
37777
|
+
...o.signal ? { signal: o.signal } : {}
|
|
37778
|
+
});
|
|
37779
|
+
} catch (cause) {
|
|
37780
|
+
if (o.signal?.aborted)
|
|
37781
|
+
throw cause;
|
|
37782
|
+
throw new McpToolError(`Blob store upload of ${key} did not complete (network error).`, {
|
|
37783
|
+
kind: "transport"
|
|
37784
|
+
});
|
|
37785
|
+
}
|
|
37786
|
+
if (!res.ok) {
|
|
37787
|
+
throw new McpToolError(`Blob store upload of ${key} failed (HTTP ${res.status}).`, {
|
|
37788
|
+
kind: "http",
|
|
37789
|
+
status: res.status
|
|
37790
|
+
});
|
|
37791
|
+
}
|
|
37792
|
+
return { key, bytes: bytes.byteLength };
|
|
37793
|
+
};
|
|
37794
|
+
const signDeleteUrl = (key, o) => {
|
|
37795
|
+
const encoded = encodeKey(key);
|
|
37796
|
+
const exp = expiry(o);
|
|
37797
|
+
return { url: link(encoded, exp, sign2(`del\0${prefix}/${key}\0${exp}`)), expiresAtMs: exp };
|
|
37798
|
+
};
|
|
37799
|
+
const exchange = async (what, url2, method, read, o) => {
|
|
37800
|
+
try {
|
|
37801
|
+
return await fetchBounded(url2, { method, ...o.signal ? { signal: o.signal } : {} }, {
|
|
37802
|
+
read,
|
|
37803
|
+
service: "the blob store",
|
|
37804
|
+
...read === "bytes" ? { maxBytes: o.maxBytes ?? BLOB_MAX_OBJECT_BYTES } : {},
|
|
37805
|
+
...o.timeoutMs !== void 0 ? { timeoutMs: o.timeoutMs } : {},
|
|
37806
|
+
...o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}
|
|
37807
|
+
});
|
|
37808
|
+
} catch (cause) {
|
|
37809
|
+
if (o.signal?.aborted || cause instanceof McpToolError || cause instanceof RequestTimeoutError)
|
|
37810
|
+
throw cause;
|
|
37811
|
+
throw new McpToolError(`Blob store ${what} did not complete (network error).`, { kind: "transport" });
|
|
37812
|
+
}
|
|
37813
|
+
};
|
|
37814
|
+
const get = async (key, o = {}) => {
|
|
37815
|
+
const { url: url2 } = signGetUrl(key, o);
|
|
37816
|
+
const res = await exchange(`read of ${key}`, url2, "GET", "bytes", o);
|
|
37817
|
+
if (!res.ok) {
|
|
37818
|
+
const why = res.status === 404 ? " \u2014 not found (never uploaded, expired, or already consumed)" : "";
|
|
37819
|
+
throw new McpToolError(`Blob store read of ${key} failed (HTTP ${res.status})${why}.`, {
|
|
37820
|
+
kind: "http",
|
|
37821
|
+
status: res.status
|
|
37822
|
+
});
|
|
37823
|
+
}
|
|
37824
|
+
const contentType = res.headers.get("content-type") ?? void 0;
|
|
37825
|
+
return { key, bytes: res.body, ...contentType !== void 0 ? { contentType } : {} };
|
|
37826
|
+
};
|
|
37827
|
+
const del = async (key, o = {}) => {
|
|
37828
|
+
const { url: url2 } = signDeleteUrl(key, o);
|
|
37829
|
+
const res = await exchange(`delete of ${key}`, url2, "DELETE", "none", o);
|
|
37830
|
+
if (!res.ok) {
|
|
37831
|
+
throw new McpToolError(`Blob store delete of ${key} failed (HTTP ${res.status}).`, {
|
|
37832
|
+
kind: "http",
|
|
37833
|
+
status: res.status
|
|
37834
|
+
});
|
|
37835
|
+
}
|
|
37836
|
+
};
|
|
37837
|
+
const consume = async (key, use, o = {}) => {
|
|
37838
|
+
const object2 = await get(key, o);
|
|
37839
|
+
const result = await use(object2);
|
|
37840
|
+
try {
|
|
37841
|
+
await del(key, o);
|
|
37842
|
+
return { result, deleted: true };
|
|
37843
|
+
} catch (e) {
|
|
37844
|
+
return { result, deleted: false, deleteError: e instanceof Error ? e.message : String(e) };
|
|
37845
|
+
}
|
|
37846
|
+
};
|
|
37847
|
+
return { baseUrl: base, prefix, signGetUrl, signPutUrl, put, signDeleteUrl, get, delete: del, consume };
|
|
37848
|
+
}
|
|
37849
|
+
function blobStoreFromEnv(env = process.env) {
|
|
37850
|
+
const baseUrl = readEnvVar("MCP_BLOB_BASE_URL", { env });
|
|
37851
|
+
const signingKey = readEnvVar("MCP_BLOB_SIGNING_KEY", { env });
|
|
37852
|
+
if (!baseUrl || !signingKey)
|
|
37853
|
+
return void 0;
|
|
37854
|
+
return createBlobStore({ baseUrl, signingKey });
|
|
37855
|
+
}
|
|
37856
|
+
|
|
37857
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/export/zip.js
|
|
37858
|
+
var CRC_TABLE = (() => {
|
|
37859
|
+
const table = new Uint32Array(256);
|
|
37860
|
+
for (let n = 0; n < 256; n++) {
|
|
37861
|
+
let c = n;
|
|
37862
|
+
for (let k = 0; k < 8; k++)
|
|
37863
|
+
c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
37864
|
+
table[n] = c >>> 0;
|
|
37865
|
+
}
|
|
37866
|
+
return table;
|
|
37867
|
+
})();
|
|
37868
|
+
|
|
37869
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/export/sink.js
|
|
37870
|
+
var EXPORT_BLOB_MAX_BYTES = 256 * 1024 * 1024;
|
|
37871
|
+
|
|
37872
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/export/upload.js
|
|
37873
|
+
import { randomUUID } from "node:crypto";
|
|
37874
|
+
var MAX_UPLOAD_FILES = 10;
|
|
37875
|
+
var UPLOAD_KEY_PREFIX = "uploads";
|
|
37876
|
+
var UPLOAD_SLOT_DEFAULT_TTL_MS = 30 * 60 * 1e3;
|
|
37877
|
+
var MIB = 1024 * 1024;
|
|
37878
|
+
var OCTET_STREAM = "application/octet-stream";
|
|
37879
|
+
var inlineFileSchema = external_exports.object({
|
|
37880
|
+
filename: external_exports.string().describe("Filename the upstream will show \u2014 a bare name, no path, not starting with a dot. Its extension sets the MIME type when mimeType is omitted."),
|
|
37881
|
+
contentBase64: external_exports.string().describe(`The file's bytes, standard base64 (line breaks and a "data:<type>;base64," prefix are tolerated).`),
|
|
37882
|
+
mimeType: external_exports.string().describe('MIME type, e.g. "image/jpeg". Default: inferred from the filename extension.').optional()
|
|
37883
|
+
});
|
|
37884
|
+
var uploadSlotRequestSchema = external_exports.object({
|
|
37885
|
+
filename: external_exports.string().describe("Filename the upstream will show \u2014 a bare name, no path, not starting with a dot."),
|
|
37886
|
+
mimeType: external_exports.string().describe("MIME type. Default: inferred from the filename extension. The PUT must send exactly this Content-Type.").optional()
|
|
37887
|
+
});
|
|
37888
|
+
function sanitizeUploadFilename(raw) {
|
|
37889
|
+
const name = raw.trim();
|
|
37890
|
+
if (name.length === 0)
|
|
37891
|
+
throw new Error("filename is empty.");
|
|
37892
|
+
if (/[/\\]/.test(name)) {
|
|
37893
|
+
throw new Error(`filename ${JSON.stringify(name)} must be a bare filename, not a path \u2014 pass just the name to show, e.g. "photo.jpg".`);
|
|
37894
|
+
}
|
|
37895
|
+
if (/[\x00-\x1f\x7f]/.test(name))
|
|
37896
|
+
throw new Error(`filename ${JSON.stringify(name)} contains control characters.`);
|
|
37897
|
+
if (name.startsWith(".")) {
|
|
37898
|
+
throw new Error(`filename ${JSON.stringify(name)} must not start with a dot (hidden files are never uploaded).`);
|
|
37899
|
+
}
|
|
37900
|
+
if (name.length > 200)
|
|
37901
|
+
throw new Error("filename is longer than 200 characters.");
|
|
37902
|
+
return name;
|
|
37903
|
+
}
|
|
37904
|
+
var MIME_BY_EXT = {
|
|
37905
|
+
pdf: "application/pdf",
|
|
37906
|
+
png: "image/png",
|
|
37907
|
+
jpg: "image/jpeg",
|
|
37908
|
+
jpeg: "image/jpeg",
|
|
37909
|
+
gif: "image/gif",
|
|
37910
|
+
webp: "image/webp",
|
|
37911
|
+
heic: "image/heic",
|
|
37912
|
+
heif: "image/heif",
|
|
37913
|
+
tif: "image/tiff",
|
|
37914
|
+
tiff: "image/tiff",
|
|
37915
|
+
svg: "image/svg+xml",
|
|
37916
|
+
txt: "text/plain",
|
|
37917
|
+
md: "text/markdown",
|
|
37918
|
+
csv: "text/csv",
|
|
37919
|
+
html: "text/html",
|
|
37920
|
+
htm: "text/html",
|
|
37921
|
+
json: "application/json",
|
|
37922
|
+
xml: "application/xml",
|
|
37923
|
+
ics: "text/calendar",
|
|
37924
|
+
doc: "application/msword",
|
|
37925
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
37926
|
+
xls: "application/vnd.ms-excel",
|
|
37927
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
37928
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
37929
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
37930
|
+
zip: "application/zip",
|
|
37931
|
+
mp3: "audio/mpeg",
|
|
37932
|
+
m4a: "audio/mp4",
|
|
37933
|
+
wav: "audio/wav",
|
|
37934
|
+
mp4: "video/mp4",
|
|
37935
|
+
mov: "video/quicktime",
|
|
37936
|
+
webm: "video/webm"
|
|
37937
|
+
};
|
|
37938
|
+
function mimeFromFileName(name) {
|
|
37939
|
+
const dot = name.lastIndexOf(".");
|
|
37940
|
+
if (dot <= 0)
|
|
37941
|
+
return OCTET_STREAM;
|
|
37942
|
+
return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? OCTET_STREAM;
|
|
37943
|
+
}
|
|
37944
|
+
var MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/;
|
|
37945
|
+
function resolveUploadMime(fileName, mimeType) {
|
|
37946
|
+
if (mimeType === void 0)
|
|
37947
|
+
return mimeFromFileName(fileName);
|
|
37948
|
+
const bare = (mimeType.split(";", 1)[0] ?? "").trim().toLowerCase();
|
|
37949
|
+
if (!MEDIA_TYPE.test(bare)) {
|
|
37950
|
+
throw new Error(`mimeType ${JSON.stringify(mimeType)} is not a type/subtype media type such as "image/jpeg".`);
|
|
37951
|
+
}
|
|
37952
|
+
return bare;
|
|
37953
|
+
}
|
|
37954
|
+
function limitText(maxBytes) {
|
|
37955
|
+
return `${Math.floor(maxBytes / MIB)} MiB (${maxBytes} bytes)`;
|
|
37956
|
+
}
|
|
37957
|
+
function checkUploadSize(fileName, size, maxBytes) {
|
|
37958
|
+
if (size === 0)
|
|
37959
|
+
throw new Error(`${fileName} is empty (0 bytes); nothing to upload.`);
|
|
37960
|
+
if (size > maxBytes)
|
|
37961
|
+
throw new Error(`${fileName} is too large (${size} bytes); the upload limit is ${limitText(maxBytes)}.`);
|
|
37962
|
+
}
|
|
37963
|
+
var PREFIX2 = /^[A-Za-z0-9._~-]+$/;
|
|
37964
|
+
function checkPrefix(prefix) {
|
|
37965
|
+
if (!PREFIX2.test(prefix) || prefix === "." || prefix === "..") {
|
|
37966
|
+
throw new Error(`Upload key prefix ${JSON.stringify(prefix)} must be one key segment of [A-Za-z0-9._~-].`);
|
|
37967
|
+
}
|
|
37968
|
+
}
|
|
37969
|
+
function mintUploadSlot(store, req, opts = {}) {
|
|
37970
|
+
const prefix = opts.prefix ?? UPLOAD_KEY_PREFIX;
|
|
37971
|
+
checkPrefix(prefix);
|
|
37972
|
+
const filename = sanitizeUploadFilename(req.filename);
|
|
37973
|
+
const mimeType = resolveUploadMime(filename, req.mimeType);
|
|
37974
|
+
const blobKey = `${prefix}/${randomUUID()}/${Buffer.from(filename, "utf8").toString("base64url")}`;
|
|
37975
|
+
const { url: url2, expiresAtMs } = store.signPutUrl(blobKey, mimeType, {
|
|
37976
|
+
ttlMs: opts.ttlMs ?? UPLOAD_SLOT_DEFAULT_TTL_MS,
|
|
37977
|
+
...opts.now !== void 0 ? { now: opts.now } : {}
|
|
37978
|
+
});
|
|
37979
|
+
const shellName = filename.replace(/'/g, `'\\''`);
|
|
37980
|
+
return {
|
|
37981
|
+
filename,
|
|
37982
|
+
mimeType,
|
|
37983
|
+
blobKey,
|
|
37984
|
+
method: "PUT",
|
|
37985
|
+
uploadUrl: url2,
|
|
37986
|
+
expiresAtMs,
|
|
37987
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
37988
|
+
// --fail turns a refused PUT (expired, wrong Content-Type) into a non-zero exit.
|
|
37989
|
+
curl: `curl -sS --fail -X PUT -H 'Content-Type: ${mimeType}' --data-binary @'<path to ${shellName}>' '${url2}'`
|
|
37990
|
+
};
|
|
37991
|
+
}
|
|
37992
|
+
var UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
37993
|
+
function uploadFileNameFromKey(key, opts = {}) {
|
|
37994
|
+
const prefix = opts.prefix ?? UPLOAD_KEY_PREFIX;
|
|
37995
|
+
checkPrefix(prefix);
|
|
37996
|
+
const escaped = prefix.replace(/[.~-]/g, "\\$&");
|
|
37997
|
+
const match = new RegExp(`^${escaped}/${UUID}/([A-Za-z0-9_-]+)$`).exec(key);
|
|
37998
|
+
if (!match) {
|
|
37999
|
+
throw new Error(`blobKey ${JSON.stringify(key)} is not an upload key. Pass the blobKey exactly as the upload slot returned it.`);
|
|
38000
|
+
}
|
|
38001
|
+
return sanitizeUploadFilename(Buffer.from(match[1], "base64url").toString("utf8"));
|
|
38002
|
+
}
|
|
38003
|
+
|
|
38004
|
+
// ../gogcli-mcp/src/blob-urls.ts
|
|
38005
|
+
var BLOB_URL_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
38006
|
+
var BLOB_URL_CEILING_MARGIN_MS = 60 * 1e3;
|
|
38007
|
+
var BLOB_URL_DEFAULT_TTL_MS = 60 * 60 * 1e3;
|
|
38008
|
+
function blobStoreFromEnv2() {
|
|
38009
|
+
const baseUrl = readEnvVar("MCP_BLOB_BASE_URL");
|
|
38010
|
+
const signingKey = readEnvVar("MCP_BLOB_SIGNING_KEY");
|
|
38011
|
+
if (!baseUrl || !signingKey) return void 0;
|
|
38012
|
+
return { baseUrl, signingKey };
|
|
38013
|
+
}
|
|
38014
|
+
|
|
38015
|
+
// ../gogcli-mcp/src/upload-slots.ts
|
|
38016
|
+
var UPLOAD_SLOTS_TOOL = "gog_upload_slots";
|
|
38017
|
+
var MAX_BLOB_FILE_BYTES = 25 * 1024 * 1024;
|
|
38018
|
+
var ATTACH_INLINE_ALTERNATIVE = `or, for a small file, pass its bytes base64-encoded in \`attachInline\` (${INLINE_ATTACHMENT_LIMITS_TEXT})`;
|
|
38019
|
+
function isHostedDeployment() {
|
|
38020
|
+
return readEnvVar("MCP_DATA_DIR") !== void 0 || blobStoreFromEnv2() !== void 0;
|
|
38021
|
+
}
|
|
38022
|
+
function requireUploadStore(what) {
|
|
38023
|
+
const store = blobStoreFromEnv();
|
|
38024
|
+
if (store) return store;
|
|
38025
|
+
throw new Error(
|
|
38026
|
+
`${what} needs the upload store a hosted (mcp-host) deployment provides, and this server has none \u2014 it is a local install, so gog can read your files directly. Pass a server path instead (attach / localPath / file / image, inside GOG_FILE_ROOTS), or for a small file the bytes with attachInline / content.`
|
|
38027
|
+
);
|
|
38028
|
+
}
|
|
38029
|
+
function blobFileName(key) {
|
|
38030
|
+
try {
|
|
38031
|
+
return uploadFileNameFromKey(key, { prefix: UPLOAD_KEY_PREFIX });
|
|
38032
|
+
} catch (e) {
|
|
38033
|
+
const message = e.message;
|
|
38034
|
+
if (!message.includes("is not an upload key")) throw e;
|
|
38035
|
+
throw new Error(`blobKey ${JSON.stringify(key)} is not an upload key. Pass each blobKey exactly as ${UPLOAD_SLOTS_TOOL} returned it.`);
|
|
38036
|
+
}
|
|
38037
|
+
}
|
|
38038
|
+
var blobKeysText = `Hosted deployments: blobKeys from ${UPLOAD_SLOTS_TOOL} for files you have ALREADY PUT to their uploadUrl. This is how to send a file you hold (a photo, a PDF) when this server is remote: no base64 in the call, up to ${MAX_BLOB_FILE_BYTES / (1024 * 1024)} MiB a file. Each file keeps the filename its slot was minted for. An uploaded file is deleted once this call succeeds and kept if it fails, so a retry can reuse the same keys.`;
|
|
38039
|
+
var blobKeysParam = external_exports.array(external_exports.string()).max(MAX_UPLOAD_FILES).optional().describe(
|
|
38040
|
+
`${blobKeysText} Up to ${MAX_UPLOAD_FILES}; combine freely with attach / attachInline.`
|
|
38041
|
+
);
|
|
38042
|
+
var singleBlobKeyParam = external_exports.array(external_exports.string()).max(1).optional().describe(
|
|
38043
|
+
`${blobKeysText} Exactly one key; use it INSTEAD of the path parameter.`
|
|
38044
|
+
);
|
|
38045
|
+
async function readBlobFiles(keys, flag, opts = {}) {
|
|
38046
|
+
if (!keys?.length) return [];
|
|
38047
|
+
if (new Set(keys).size !== keys.length) throw new Error("blobKeys lists the same blobKey twice; pass each uploaded file once.");
|
|
38048
|
+
const names = keys.map(blobFileName);
|
|
38049
|
+
const store = requireUploadStore("blobKeys");
|
|
38050
|
+
const files = [];
|
|
38051
|
+
for (const [i, blobKey] of keys.entries()) {
|
|
38052
|
+
const filename = names[i];
|
|
38053
|
+
let bytes;
|
|
38054
|
+
try {
|
|
38055
|
+
bytes = (await store.get(blobKey, { maxBytes: MAX_BLOB_FILE_BYTES })).bytes;
|
|
38056
|
+
} catch (e) {
|
|
38057
|
+
throw new Error(
|
|
38058
|
+
`${filename} could not be read from the upload store: ${e.message} PUT the file to its uploadUrl before calling this tool (files over ${MAX_BLOB_FILE_BYTES} bytes are refused), and mint a new slot with ${UPLOAD_SLOTS_TOOL} if this key was already used by a call that succeeded.`
|
|
38059
|
+
);
|
|
38060
|
+
}
|
|
38061
|
+
checkUploadSize(filename, bytes.length, MAX_BLOB_FILE_BYTES);
|
|
38062
|
+
const arg = { kind: "file", flag, contents: Buffer.from(bytes).toString("base64"), encoding: "base64", filename };
|
|
38063
|
+
if (opts.positional) arg.positional = true;
|
|
38064
|
+
files.push({ blobKey, filename, size: bytes.length, sha256: createHash4("sha256").update(bytes).digest("hex"), arg, store });
|
|
38065
|
+
}
|
|
38066
|
+
return files;
|
|
38067
|
+
}
|
|
38068
|
+
async function releaseBlobs(result, files) {
|
|
38069
|
+
if (files.length === 0) return result;
|
|
38070
|
+
if (result.isError) {
|
|
38071
|
+
return withNote(
|
|
38072
|
+
result,
|
|
38073
|
+
`The uploaded files (${files.map((f) => f.filename).join(", ")}) were kept in the upload store, so you can retry with the same blobKeys.`
|
|
38074
|
+
);
|
|
38075
|
+
}
|
|
38076
|
+
const failed = [];
|
|
38077
|
+
for (const file2 of files) {
|
|
38078
|
+
try {
|
|
38079
|
+
await file2.store.delete(file2.blobKey);
|
|
38080
|
+
} catch (e) {
|
|
38081
|
+
failed.push(`${file2.filename} (${e instanceof Error ? e.message : String(e)})`);
|
|
38082
|
+
}
|
|
38083
|
+
}
|
|
38084
|
+
if (failed.length === 0) return result;
|
|
38085
|
+
return withNote(
|
|
38086
|
+
result,
|
|
38087
|
+
`WARNING: the call succeeded, but these uploaded files could not be deleted from the upload store: ${failed.join("; ")}. They expire with the store's retention. Do NOT retry the call \u2014 it already happened.`
|
|
38088
|
+
);
|
|
38089
|
+
}
|
|
38090
|
+
function withNote(result, text) {
|
|
38091
|
+
return { ...result, content: [...result.content, { type: "text", text }] };
|
|
38092
|
+
}
|
|
38093
|
+
function hostedPathRefusal(path, param, alternative) {
|
|
38094
|
+
return new Error(
|
|
38095
|
+
`This server is remote (a hosted deployment), so \`${param}\` ${JSON.stringify(path)} names a file on the SERVER, not on your machine, and there is no such file here. Upload the bytes instead: call ${UPLOAD_SLOTS_TOOL} with files:[{filename}], PUT each file to its uploadUrl (the response includes a ready curl command), then call this tool again with those blobKeys in \`blobKeys\`` + (alternative ? ` \u2014 ${alternative}.` : ".")
|
|
38096
|
+
);
|
|
38097
|
+
}
|
|
38098
|
+
function confineUploadPath(path, param, alternative) {
|
|
38099
|
+
if (!isHostedDeployment()) return confinePath(path, param);
|
|
38100
|
+
try {
|
|
38101
|
+
confinePath(path, param);
|
|
38102
|
+
} catch {
|
|
38103
|
+
throw hostedPathRefusal(path, param, alternative);
|
|
38104
|
+
}
|
|
38105
|
+
if (!existsSync2(path)) throw hostedPathRefusal(path, param, alternative);
|
|
38106
|
+
return path;
|
|
38107
|
+
}
|
|
38108
|
+
function assertOneFileSource(sources) {
|
|
38109
|
+
const names = Object.keys(sources);
|
|
38110
|
+
const given = names.filter((n) => {
|
|
38111
|
+
const v = sources[n];
|
|
38112
|
+
return Array.isArray(v) ? v.length > 0 : v !== void 0;
|
|
38113
|
+
});
|
|
38114
|
+
if (given.length === 1) return;
|
|
38115
|
+
const list = `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
|
|
38116
|
+
throw new Error(`Pass exactly one of ${list}${given.length > 1 ? ` (got ${given.join(" and ")})` : ""}.`);
|
|
38117
|
+
}
|
|
38118
|
+
|
|
37511
38119
|
// ../gogcli-mcp/src/tools/docs.ts
|
|
37512
38120
|
function vetDocsRun(subcommand, args) {
|
|
37513
38121
|
return vetCommentsRun("docs", subcommand, args);
|
|
@@ -37650,8 +38258,9 @@ var replySchema = {
|
|
|
37650
38258
|
remove: external_exports.array(external_exports.string()).optional().describe("Remove these recipients from all fields (repeatable) \u2014 e.g. to drop someone from a reply-all."),
|
|
37651
38259
|
subject: external_exports.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
37652
38260
|
noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
|
|
37653
|
-
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension. Must be inside the server's GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).`),
|
|
38261
|
+
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" \u2014 use blobKeys (gog_upload_slots) or, for a small file, attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension. Must be inside the server's GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).`),
|
|
37654
38262
|
attachInline: attachInlineParam,
|
|
38263
|
+
blobKeys: blobKeysParam,
|
|
37655
38264
|
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
37656
38265
|
autoFromAddressedAlias: external_exports.boolean().optional().describe("When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account's primary address \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
|
|
37657
38266
|
...signatureSchema,
|
|
@@ -37668,8 +38277,50 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
|
|
|
37668
38277
|
'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.'
|
|
37669
38278
|
);
|
|
37670
38279
|
|
|
38280
|
+
// ../gogcli-mcp/src/tools/uploads.ts
|
|
38281
|
+
var BLOB_KEY_TOOLS = [
|
|
38282
|
+
"gog_gmail_send",
|
|
38283
|
+
"gog_gmail_reply",
|
|
38284
|
+
"gog_gmail_reply_all",
|
|
38285
|
+
"gog_gmail_drafts_create",
|
|
38286
|
+
"gog_gmail_drafts_update",
|
|
38287
|
+
"gog_gmail_drafts_reply",
|
|
38288
|
+
"gog_gmail_drafts_reply_all",
|
|
38289
|
+
"gog_gmail_import",
|
|
38290
|
+
"gog_chat_messages_send",
|
|
38291
|
+
"gog_drive_upload",
|
|
38292
|
+
"gog_slides_add_slide",
|
|
38293
|
+
"gog_slides_insert_image",
|
|
38294
|
+
"gog_slides_replace_slide",
|
|
38295
|
+
"gog_docs_insert_image",
|
|
38296
|
+
"gog_docs_replace_image"
|
|
38297
|
+
];
|
|
38298
|
+
var MIB2 = MAX_BLOB_FILE_BYTES / (1024 * 1024);
|
|
38299
|
+
function registerUploadTools(server) {
|
|
38300
|
+
server.registerTool(UPLOAD_SLOTS_TOOL, {
|
|
38301
|
+
description: `HOSTED DEPLOYMENTS: how to give this server a FILE you hold (a photo, a PDF, an .eml). This server runs remotely and cannot read paths on your machine, so call this first with the files' names: it returns one signed upload slot per file. PUT each file's bytes to its uploadUrl (each slot carries a ready \`curl\` command \u2014 replace the <path to \u2026> placeholder; the Content-Type must be exactly the slot's mimeType), then pass the slots' blobKeys in the \`blobKeys\` parameter of the tool that takes the file: ${BLOB_KEY_TOOLS.join(", ")}. Up to ${MAX_UPLOAD_FILES} files per call, ${MIB2} MiB each; the URLs expire after 30 minutes. Each uploaded file is deleted once the call that uses it succeeds, and kept if that call fails. Prefer this over attachInline for anything but a small file \u2014 no base64 in the call. Mints URLs only: nothing reaches Google until you use the keys. On a local install this refuses: pass a path, or attachInline.`,
|
|
38302
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
38303
|
+
inputSchema: external_exports.object({
|
|
38304
|
+
files: external_exports.array(uploadSlotRequestSchema).min(1).max(MAX_UPLOAD_FILES).describe(
|
|
38305
|
+
"One entry per file: {filename, mimeType?}. The filename is the name the recipient / Drive will show, and its extension is how gog types an attachment \u2014 give it the right one."
|
|
38306
|
+
)
|
|
38307
|
+
})
|
|
38308
|
+
}, async ({ files }) => {
|
|
38309
|
+
const store = requireUploadStore(UPLOAD_SLOTS_TOOL);
|
|
38310
|
+
const slots = files.map((file2) => {
|
|
38311
|
+
const { expiresAtMs: _expiresAtMs, ...slot } = mintUploadSlot(store, file2);
|
|
38312
|
+
return slot;
|
|
38313
|
+
});
|
|
38314
|
+
return minifiedResult({
|
|
38315
|
+
slots,
|
|
38316
|
+
maxBytesPerFile: MAX_BLOB_FILE_BYTES,
|
|
38317
|
+
next: "PUT each file to its uploadUrl (run each slot's curl with the real path), check every PUT succeeded, then pass the blobKeys to the file-taking tool's blobKeys parameter."
|
|
38318
|
+
});
|
|
38319
|
+
});
|
|
38320
|
+
}
|
|
38321
|
+
|
|
37671
38322
|
// ../gogcli-mcp/src/server.ts
|
|
37672
|
-
var VERSION = true ? "4.
|
|
38323
|
+
var VERSION = true ? "4.10.0" : "0.0.0";
|
|
37673
38324
|
|
|
37674
38325
|
// ../gogcli-mcp/src/bootstrap-auth.ts
|
|
37675
38326
|
import { join as join7 } from "node:path";
|
|
@@ -37706,12 +38357,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
|
|
|
37706
38357
|
const [clientId, clientSecret, refreshToken, account] = values;
|
|
37707
38358
|
const { spawner } = options;
|
|
37708
38359
|
try {
|
|
37709
|
-
const { createHash:
|
|
38360
|
+
const { createHash: createHash5 } = await import("node:crypto");
|
|
37710
38361
|
const { mkdir, readFile, writeFile, chmod } = await import("node:fs/promises");
|
|
37711
38362
|
const home = options.home ?? (await import("node:os")).homedir();
|
|
37712
38363
|
const dir = join7(home, ".gogcli-mcp");
|
|
37713
38364
|
const marker = join7(dir, AUTH_BOOTSTRAP_MARKER);
|
|
37714
|
-
const fingerprint =
|
|
38365
|
+
const fingerprint = createHash5("sha256").update(values.join("\0")).digest("hex");
|
|
37715
38366
|
const previous = await readFile(marker, "utf8").catch(() => void 0);
|
|
37716
38367
|
if (previous === fingerprint && await accountListed(account, spawner)) return "present";
|
|
37717
38368
|
await run(
|
|
@@ -37735,15 +38386,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
|
|
|
37735
38386
|
}
|
|
37736
38387
|
}
|
|
37737
38388
|
|
|
37738
|
-
// ../gogcli-mcp/src/blob-urls.ts
|
|
37739
|
-
var BLOB_URL_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
37740
|
-
var BLOB_URL_CEILING_MARGIN_MS = 60 * 1e3;
|
|
37741
|
-
var BLOB_URL_DEFAULT_TTL_MS = 60 * 60 * 1e3;
|
|
37742
|
-
|
|
37743
38389
|
// ../gogcli-mcp/src/blob-upload.ts
|
|
37744
38390
|
var MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
37745
38391
|
|
|
37746
38392
|
// src/tools/docs-extra.ts
|
|
38393
|
+
var URL_ALTERNATIVE = "or pass a public HTTPS image `url`";
|
|
38394
|
+
var IMAGE_BLOB_NOTE = " On a hosted deployment, where no path on your machine exists, upload the image with gog_upload_slots and pass blobKeys instead of file.";
|
|
37747
38395
|
function docTitle(raw) {
|
|
37748
38396
|
try {
|
|
37749
38397
|
const parsed = JSON.parse(raw);
|
|
@@ -38755,12 +39403,13 @@ function registerExtraDocsTools(server) {
|
|
|
38755
39403
|
return runOrDiagnose(args, { account });
|
|
38756
39404
|
});
|
|
38757
39405
|
server.registerTool("gog_docs_insert_image", {
|
|
38758
|
-
description: "Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.",
|
|
39406
|
+
description: "Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc." + IMAGE_BLOB_NOTE,
|
|
38759
39407
|
annotations: { destructiveHint: true, openWorldHint: true },
|
|
38760
39408
|
inputSchema: external_exports.object({
|
|
38761
39409
|
docId: external_exports.string().describe("Doc ID (from the URL)"),
|
|
38762
|
-
file: external_exports.string().optional().describe("
|
|
38763
|
-
url: external_exports.string().optional().describe("Public HTTPS image URL to insert directly \u2014 no Drive upload or temporary public sharing (exactly one of file or
|
|
39410
|
+
file: external_exports.string().optional().describe("PNG, JPEG, or GIF image on the gog server to upload and insert (exactly one of file, url or blobKeys)"),
|
|
39411
|
+
url: external_exports.string().optional().describe("Public HTTPS image URL to insert directly \u2014 no Drive upload or temporary public sharing (exactly one of file, url or blobKeys)"),
|
|
39412
|
+
blobKeys: singleBlobKeyParam,
|
|
38764
39413
|
at: external_exports.string().optional().describe('Placeholder text to replace, or "end" to append (default: end)'),
|
|
38765
39414
|
before: external_exports.string().optional().describe("Insert the image immediately before the first literal text match (no text is deleted)"),
|
|
38766
39415
|
after: external_exports.string().optional().describe("Insert the image immediately after the first literal text match (no text is deleted)"),
|
|
@@ -38772,10 +39421,13 @@ function registerExtraDocsTools(server) {
|
|
|
38772
39421
|
tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
|
|
38773
39422
|
account: accountParam
|
|
38774
39423
|
})
|
|
38775
|
-
}, async ({ docId, file: file2, url: url2, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
|
|
38776
|
-
if (file2)
|
|
39424
|
+
}, async ({ docId, file: file2, url: url2, blobKeys, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
|
|
39425
|
+
if (file2) confineUploadPath(file2, "file", URL_ALTERNATIVE);
|
|
39426
|
+
assertOneFileSource({ file: file2, url: url2, blobKeys });
|
|
39427
|
+
const blobs = await readBlobFiles(blobKeys, "file");
|
|
38777
39428
|
const args = ["docs", "insert-image", pos(docId)];
|
|
38778
39429
|
if (file2) args.push(`--file=${file2}`);
|
|
39430
|
+
for (const blob of blobs) args.push(blob.arg);
|
|
38779
39431
|
if (url2) args.push(`--url=${url2}`);
|
|
38780
39432
|
if (at) args.push(`--at=${at}`);
|
|
38781
39433
|
if (before) args.push(`--before=${before}`);
|
|
@@ -38786,8 +39438,8 @@ function registerExtraDocsTools(server) {
|
|
|
38786
39438
|
if (parent) args.push(`--parent=${parent}`);
|
|
38787
39439
|
if (onRestricted) args.push(`--on-restricted=${onRestricted}`);
|
|
38788
39440
|
if (tab) args.push(`--tab=${tab}`);
|
|
38789
|
-
if (file2) args.push("--force");
|
|
38790
|
-
return runOrDiagnose(args, { account });
|
|
39441
|
+
if (file2 || blobs.length > 0) args.push("--force");
|
|
39442
|
+
return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
|
|
38791
39443
|
});
|
|
38792
39444
|
server.registerTool("gog_docs_insert_person", {
|
|
38793
39445
|
description: "Insert a native Google Docs person smart chip (the interactive @-mention chip) for an email address, at a character index or end-of-doc.",
|
|
@@ -39113,12 +39765,13 @@ function registerExtraDocsTools(server) {
|
|
|
39113
39765
|
return runOrDiagnose(args, { account });
|
|
39114
39766
|
});
|
|
39115
39767
|
server.registerTool("gog_docs_replace_image", {
|
|
39116
|
-
description: "Replace an existing image in a Google Doc in place \u2014 keeping its position and bounds \u2014 with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.",
|
|
39768
|
+
description: "Replace an existing image in a Google Doc in place \u2014 keeping its position and bounds \u2014 with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab." + IMAGE_BLOB_NOTE,
|
|
39117
39769
|
annotations: { destructiveHint: true, openWorldHint: true },
|
|
39118
39770
|
inputSchema: external_exports.object({
|
|
39119
39771
|
docId: external_exports.string().describe("Doc ID (from the URL)"),
|
|
39120
|
-
file: external_exports.string().optional().describe("
|
|
39121
|
-
url: external_exports.string().optional().describe("Public HTTPS image URL to use directly \u2014 no Drive upload (exactly one of file or
|
|
39772
|
+
file: external_exports.string().optional().describe("PNG, JPEG, or GIF image on the gog server to upload and use (exactly one of file, url or blobKeys)"),
|
|
39773
|
+
url: external_exports.string().optional().describe("Public HTTPS image URL to use directly \u2014 no Drive upload (exactly one of file, url or blobKeys)"),
|
|
39774
|
+
blobKeys: singleBlobKeyParam,
|
|
39122
39775
|
objectId: external_exports.string().optional().describe("Exact image object ID to replace (from gog_docs_images_list)"),
|
|
39123
39776
|
matchAlt: external_exports.string().optional().describe("Select the image whose alt text contains this value (case-insensitive)"),
|
|
39124
39777
|
name: external_exports.string().optional().describe("Override the uploaded Drive filename (file mode)"),
|
|
@@ -39126,18 +39779,21 @@ function registerExtraDocsTools(server) {
|
|
|
39126
39779
|
tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
|
|
39127
39780
|
account: accountParam
|
|
39128
39781
|
})
|
|
39129
|
-
}, async ({ docId, file: file2, url: url2, objectId, matchAlt, name, parent, tab, account }) => {
|
|
39130
|
-
if (file2)
|
|
39782
|
+
}, async ({ docId, file: file2, url: url2, blobKeys, objectId, matchAlt, name, parent, tab, account }) => {
|
|
39783
|
+
if (file2) confineUploadPath(file2, "file", URL_ALTERNATIVE);
|
|
39784
|
+
assertOneFileSource({ file: file2, url: url2, blobKeys });
|
|
39785
|
+
const blobs = await readBlobFiles(blobKeys, "file");
|
|
39131
39786
|
const args = ["docs", "replace-image", pos(docId)];
|
|
39132
39787
|
if (file2) args.push(`--file=${file2}`);
|
|
39788
|
+
for (const blob of blobs) args.push(blob.arg);
|
|
39133
39789
|
if (url2) args.push(`--url=${url2}`);
|
|
39134
39790
|
if (objectId) args.push(`--object-id=${objectId}`);
|
|
39135
39791
|
if (matchAlt) args.push(`--match-alt=${matchAlt}`);
|
|
39136
39792
|
if (name) args.push(`--name=${name}`);
|
|
39137
39793
|
if (parent) args.push(`--parent=${parent}`);
|
|
39138
39794
|
if (tab) args.push(`--tab=${tab}`);
|
|
39139
|
-
if (file2) args.push("--force");
|
|
39140
|
-
return runOrDiagnose(args, { account });
|
|
39795
|
+
if (file2 || blobs.length > 0) args.push("--force");
|
|
39796
|
+
return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
|
|
39141
39797
|
});
|
|
39142
39798
|
server.registerTool("gog_docs_table_row_pin_header", {
|
|
39143
39799
|
description: "Pin (or unpin) leading rows of a native Google Docs table as repeating header rows. rows=N pins the first N rows; rows=0 unpins all header rows.",
|
|
@@ -39183,7 +39839,7 @@ await bootstrapGogAuth();
|
|
|
39183
39839
|
await runMcp({
|
|
39184
39840
|
name: "gogcli-docs",
|
|
39185
39841
|
version: VERSION,
|
|
39186
|
-
tools: [authToolsFor("docs"), registerDocsTools, registerExtraDocsTools]
|
|
39842
|
+
tools: [authToolsFor("docs"), registerDocsTools, registerExtraDocsTools, registerUploadTools]
|
|
39187
39843
|
});
|
|
39188
39844
|
/*! Bundled license information:
|
|
39189
39845
|
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp-docs",
|
|
5
5
|
"display_name": "gogcli (Docs)",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.10.0",
|
|
7
7
|
"description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -414,6 +414,10 @@
|
|
|
414
414
|
{
|
|
415
415
|
"name": "gog_docs_insert_date_chip",
|
|
416
416
|
"description": "Insert a native Google Docs date smart chip."
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"name": "gog_upload_slots",
|
|
420
|
+
"description": "Get signed upload URLs so a hosted (remote) server can receive files you hold; pass the returned blobKeys to the tool that takes the file"
|
|
417
421
|
}
|
|
418
422
|
],
|
|
419
423
|
"compatibility": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-docs",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.10.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp-docs",
|
|
5
5
|
"description": "Extended Google Docs MCP server via gogcli — all base tools plus full Docs support",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"test:coverage": "vitest run --coverage"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@chrischall/mcp-utils": "^3.
|
|
27
|
+
"@chrischall/mcp-utils": "^3.6.0",
|
|
28
28
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
29
29
|
"zod": "^4.6.1"
|
|
30
30
|
},
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp-docs"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.
|
|
10
|
+
"version": "4.10.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-docs",
|
|
15
|
-
"version": "4.
|
|
15
|
+
"version": "4.10.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
|
-
import { VERSION, authToolsFor, registerDocsTools, bootstrapGogAuth } from '../../gogcli-mcp/src/lib.js';
|
|
3
|
+
import { VERSION, authToolsFor, registerDocsTools, registerUploadTools, bootstrapGogAuth } from '../../gogcli-mcp/src/lib.js';
|
|
4
4
|
import { registerExtraDocsTools } from './tools/docs-extra.js';
|
|
5
5
|
|
|
6
6
|
|
|
@@ -10,5 +10,5 @@ await bootstrapGogAuth();
|
|
|
10
10
|
await runMcp({
|
|
11
11
|
name: 'gogcli-docs',
|
|
12
12
|
version: VERSION,
|
|
13
|
-
tools: [authToolsFor('docs'), registerDocsTools, registerExtraDocsTools],
|
|
13
|
+
tools: [authToolsFor('docs'), registerDocsTools, registerExtraDocsTools, registerUploadTools],
|
|
14
14
|
});
|
package/src/tools/docs-extra.ts
CHANGED
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
payloadArg,
|
|
9
9
|
pos,
|
|
10
10
|
confinePath,
|
|
11
|
+
assertOneFileSource,
|
|
12
|
+
confineUploadPath,
|
|
13
|
+
readBlobFiles,
|
|
14
|
+
releaseBlobs,
|
|
15
|
+
singleBlobKeyParam,
|
|
11
16
|
bodyPreview,
|
|
12
17
|
commentMentions,
|
|
13
18
|
commentSnapshot,
|
|
@@ -18,6 +23,9 @@ import {
|
|
|
18
23
|
} from '../../../gogcli-mcp/src/lib.js';
|
|
19
24
|
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
20
25
|
|
|
26
|
+
const URL_ALTERNATIVE = 'or pass a public HTTPS image `url`';
|
|
27
|
+
const IMAGE_BLOB_NOTE = ' On a hosted deployment, where no path on your machine exists, upload the image with gog_upload_slots and pass blobKeys instead of file.';
|
|
28
|
+
|
|
21
29
|
/** A Doc's title from `gog docs info`, for a comment's prompt. Unreadable output names nothing. */
|
|
22
30
|
export function docTitle(raw: string): string | undefined {
|
|
23
31
|
try {
|
|
@@ -1135,12 +1143,13 @@ export function registerExtraDocsTools(server: McpServer): void {
|
|
|
1135
1143
|
});
|
|
1136
1144
|
|
|
1137
1145
|
server.registerTool('gog_docs_insert_image', {
|
|
1138
|
-
description: 'Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.',
|
|
1146
|
+
description: 'Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.' + IMAGE_BLOB_NOTE,
|
|
1139
1147
|
annotations: { destructiveHint: true, openWorldHint: true },
|
|
1140
1148
|
inputSchema: z.object({
|
|
1141
1149
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
1142
|
-
file: z.string().optional().describe('
|
|
1143
|
-
url: z.string().optional().describe('Public HTTPS image URL to insert directly — no Drive upload or temporary public sharing (exactly one of file or
|
|
1150
|
+
file: z.string().optional().describe('PNG, JPEG, or GIF image on the gog server to upload and insert (exactly one of file, url or blobKeys)'),
|
|
1151
|
+
url: z.string().optional().describe('Public HTTPS image URL to insert directly — no Drive upload or temporary public sharing (exactly one of file, url or blobKeys)'),
|
|
1152
|
+
blobKeys: singleBlobKeyParam,
|
|
1144
1153
|
at: z.string().optional().describe('Placeholder text to replace, or "end" to append (default: end)'),
|
|
1145
1154
|
before: z.string().optional().describe('Insert the image immediately before the first literal text match (no text is deleted)'),
|
|
1146
1155
|
after: z.string().optional().describe('Insert the image immediately after the first literal text match (no text is deleted)'),
|
|
@@ -1152,10 +1161,13 @@ export function registerExtraDocsTools(server: McpServer): void {
|
|
|
1152
1161
|
tab: z.string().optional().describe('Target a specific tab by title or ID'),
|
|
1153
1162
|
account: accountParam,
|
|
1154
1163
|
}),
|
|
1155
|
-
}, async ({ docId, file, url, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
|
|
1156
|
-
if (file)
|
|
1164
|
+
}, async ({ docId, file, url, blobKeys, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
|
|
1165
|
+
if (file) confineUploadPath(file, 'file', URL_ALTERNATIVE);
|
|
1166
|
+
assertOneFileSource({ file, url, blobKeys });
|
|
1167
|
+
const blobs = await readBlobFiles(blobKeys, 'file');
|
|
1157
1168
|
const args: GogArg[] = ['docs', 'insert-image', pos(docId)];
|
|
1158
1169
|
if (file) args.push(`--file=${file}`);
|
|
1170
|
+
for (const blob of blobs) args.push(blob.arg);
|
|
1159
1171
|
if (url) args.push(`--url=${url}`);
|
|
1160
1172
|
if (at) args.push(`--at=${at}`);
|
|
1161
1173
|
if (before) args.push(`--before=${before}`);
|
|
@@ -1166,8 +1178,8 @@ export function registerExtraDocsTools(server: McpServer): void {
|
|
|
1166
1178
|
if (parent) args.push(`--parent=${parent}`);
|
|
1167
1179
|
if (onRestricted) args.push(`--on-restricted=${onRestricted}`);
|
|
1168
1180
|
if (tab) args.push(`--tab=${tab}`);
|
|
1169
|
-
if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
|
|
1170
|
-
return runOrDiagnose(args, { account });
|
|
1181
|
+
if (file || blobs.length > 0) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
|
|
1182
|
+
return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
|
|
1171
1183
|
});
|
|
1172
1184
|
|
|
1173
1185
|
server.registerTool('gog_docs_insert_person', {
|
|
@@ -1517,12 +1529,13 @@ export function registerExtraDocsTools(server: McpServer): void {
|
|
|
1517
1529
|
// --- gog 0.30 image replacement & table-row pin/style ---
|
|
1518
1530
|
|
|
1519
1531
|
server.registerTool('gog_docs_replace_image', {
|
|
1520
|
-
description: 'Replace an existing image in a Google Doc in place — keeping its position and bounds — with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.',
|
|
1532
|
+
description: 'Replace an existing image in a Google Doc in place — keeping its position and bounds — with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.' + IMAGE_BLOB_NOTE,
|
|
1521
1533
|
annotations: { destructiveHint: true, openWorldHint: true },
|
|
1522
1534
|
inputSchema: z.object({
|
|
1523
1535
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
1524
|
-
file: z.string().optional().describe('
|
|
1525
|
-
url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file or
|
|
1536
|
+
file: z.string().optional().describe('PNG, JPEG, or GIF image on the gog server to upload and use (exactly one of file, url or blobKeys)'),
|
|
1537
|
+
url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file, url or blobKeys)'),
|
|
1538
|
+
blobKeys: singleBlobKeyParam,
|
|
1526
1539
|
objectId: z.string().optional().describe('Exact image object ID to replace (from gog_docs_images_list)'),
|
|
1527
1540
|
matchAlt: z.string().optional().describe('Select the image whose alt text contains this value (case-insensitive)'),
|
|
1528
1541
|
name: z.string().optional().describe('Override the uploaded Drive filename (file mode)'),
|
|
@@ -1530,18 +1543,21 @@ export function registerExtraDocsTools(server: McpServer): void {
|
|
|
1530
1543
|
tab: z.string().optional().describe('Target a specific tab by title or ID'),
|
|
1531
1544
|
account: accountParam,
|
|
1532
1545
|
}),
|
|
1533
|
-
}, async ({ docId, file, url, objectId, matchAlt, name, parent, tab, account }) => {
|
|
1534
|
-
if (file)
|
|
1546
|
+
}, async ({ docId, file, url, blobKeys, objectId, matchAlt, name, parent, tab, account }) => {
|
|
1547
|
+
if (file) confineUploadPath(file, 'file', URL_ALTERNATIVE);
|
|
1548
|
+
assertOneFileSource({ file, url, blobKeys });
|
|
1549
|
+
const blobs = await readBlobFiles(blobKeys, 'file');
|
|
1535
1550
|
const args: GogArg[] = ['docs', 'replace-image', pos(docId)];
|
|
1536
1551
|
if (file) args.push(`--file=${file}`);
|
|
1552
|
+
for (const blob of blobs) args.push(blob.arg);
|
|
1537
1553
|
if (url) args.push(`--url=${url}`);
|
|
1538
1554
|
if (objectId) args.push(`--object-id=${objectId}`);
|
|
1539
1555
|
if (matchAlt) args.push(`--match-alt=${matchAlt}`);
|
|
1540
1556
|
if (name) args.push(`--name=${name}`);
|
|
1541
1557
|
if (parent) args.push(`--parent=${parent}`);
|
|
1542
1558
|
if (tab) args.push(`--tab=${tab}`);
|
|
1543
|
-
if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
|
|
1544
|
-
return runOrDiagnose(args, { account });
|
|
1559
|
+
if (file || blobs.length > 0) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
|
|
1560
|
+
return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
|
|
1545
1561
|
});
|
|
1546
1562
|
|
|
1547
1563
|
server.registerTool('gog_docs_table_row_pin_header', {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { authToolsFor, registerDocsTools } from '../../gogcli-mcp/src/lib.js';
|
|
1
|
+
import { authToolsFor, registerDocsTools, registerUploadTools } from '../../gogcli-mcp/src/lib.js';
|
|
2
2
|
import { registerExtraDocsTools } from '../src/tools/docs-extra.js';
|
|
3
3
|
import { describeToolAnnotations, LOCAL_AUTH_TOOLS } from '../../gogcli-mcp/tests/support/tool-annotations.js';
|
|
4
4
|
|
|
5
5
|
// The same registrar list src/index.ts hands to runMcp.
|
|
6
|
-
describeToolAnnotations([authToolsFor('docs'), registerDocsTools, registerExtraDocsTools] as never, {
|
|
7
|
-
tools:
|
|
6
|
+
describeToolAnnotations([authToolsFor('docs'), registerDocsTools, registerExtraDocsTools, registerUploadTools] as never, {
|
|
7
|
+
tools: 84,
|
|
8
8
|
destructive: 52,
|
|
9
|
-
local: [...LOCAL_AUTH_TOOLS, 'gog_batch_abort', 'gog_batch_begin', 'gog_batch_list', 'gog_batch_prune', 'gog_batch_show'],
|
|
9
|
+
local: [...LOCAL_AUTH_TOOLS, 'gog_upload_slots', 'gog_batch_abort', 'gog_batch_begin', 'gog_batch_list', 'gog_batch_prune', 'gog_batch_show'],
|
|
10
10
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
|
|
6
|
+
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
7
|
+
import { blobStoreFromEnv, mintUploadSlot } from '@chrischall/mcp-utils/export';
|
|
8
|
+
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
9
|
+
import type { GogArg, GogFileArg } from '../../../gogcli-mcp/src/lib.js';
|
|
10
|
+
import { registerExtraDocsTools } from '../../src/tools/docs-extra.js';
|
|
11
|
+
import { installFakeBlobStore, type FakeBlobStore } from '../../../gogcli-mcp/tests/support/fake-blob-store.js';
|
|
12
|
+
|
|
13
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
14
|
+
const actual = await importOriginal<typeof lib>();
|
|
15
|
+
return { ...actual, runOrDiagnose: vi.fn() };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
19
|
+
let fake: FakeBlobStore;
|
|
20
|
+
let harness: TestHarness;
|
|
21
|
+
let root: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
process.env = { ...ORIGINAL_ENV };
|
|
26
|
+
delete process.env.MCP_DATA_DIR;
|
|
27
|
+
root = mkdtempSync(join(tmpdir(), 'docs-blobkeys-'));
|
|
28
|
+
process.env.GOG_FILE_ROOTS = root;
|
|
29
|
+
fake = installFakeBlobStore();
|
|
30
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
31
|
+
harness = await createTestHarness(registerExtraDocsTools, {
|
|
32
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
process.env = ORIGINAL_ENV;
|
|
38
|
+
vi.unstubAllGlobals();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function upload(filename: string, bytes: string): string {
|
|
42
|
+
const key = mintUploadSlot(blobStoreFromEnv()!, { filename }).blobKey;
|
|
43
|
+
fake.put(key, bytes);
|
|
44
|
+
return key;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lastArgs = () => vi.mocked(lib.runOrDiagnose).mock.calls.at(-1)![0] as GogArg[];
|
|
48
|
+
const fileArg = (args: GogArg[]) => args.find((a): a is GogFileArg => typeof a === 'object' && a.kind === 'file');
|
|
49
|
+
|
|
50
|
+
describe('docs image tools + blobKeys', () => {
|
|
51
|
+
it.each(['gog_docs_insert_image', 'gog_docs_replace_image'])('%s passes the blob as --file, with --force, and deletes it after', async (tool) => {
|
|
52
|
+
const key = upload('logo.png', 'PNGBYTES');
|
|
53
|
+
const result = await harness.callTool(tool, { docId: 'd1', blobKeys: [key] });
|
|
54
|
+
expect(result.isError).toBeFalsy();
|
|
55
|
+
const args = lastArgs();
|
|
56
|
+
expect(fileArg(args)).toMatchObject({ flag: 'file', filename: 'logo.png', encoding: 'base64' });
|
|
57
|
+
expect(fileArg(args)!.positional).toBeUndefined();
|
|
58
|
+
expect(args.at(-1)).toBe('--force');
|
|
59
|
+
expect(fake.deleted).toEqual([key]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps the blob when gog fails', async () => {
|
|
63
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(errorResult('Error: restricted'));
|
|
64
|
+
const key = upload('logo.png', 'PNGBYTES');
|
|
65
|
+
await harness.callTool('gog_docs_insert_image', { docId: 'd1', blobKeys: [key] });
|
|
66
|
+
expect(fake.deleted).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('wants exactly one image source', async () => {
|
|
70
|
+
const result = await harness.callTool('gog_docs_replace_image', { docId: 'd1' });
|
|
71
|
+
expect(result.content[0]!.text).toMatch(/exactly one of file, url or blobKeys/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('url mode needs no --force', async () => {
|
|
75
|
+
await harness.callTool('gog_docs_insert_image', { docId: 'd1', url: 'https://x.test/i.png' });
|
|
76
|
+
expect(lastArgs()).not.toContain('--force');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('on a hosted deployment, a file path that is not on the server points at the slot tool', async () => {
|
|
80
|
+
const result = await harness.callTool('gog_docs_insert_image', { docId: 'd1', file: '/Users/someone/a.png' });
|
|
81
|
+
expect(result.content[0]!.text).toMatch(/server is remote.*gog_upload_slots/s);
|
|
82
|
+
});
|
|
83
|
+
});
|