graph-excel-mcp 0.3.0 → 0.4.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/README.md +35 -2
- package/dist/cli.js +13 -0
- package/dist/excel.js +372 -0
- package/dist/graph.js +152 -0
- package/dist/mcp.js +168 -0
- package/package.json +20 -9
- package/excel.js +0 -360
- package/graph.js +0 -153
- package/mcp.js +0 -192
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# graph-excel-mcp
|
|
2
2
|
|
|
3
|
-
MCP server for reading and editing Excel workbooks on SharePoint, OneDrive or Teams through Microsoft Graph, without a browser. It has no dependencies and
|
|
3
|
+
MCP server for reading and editing Excel workbooks on SharePoint, OneDrive or Teams through Microsoft Graph, without a browser. It is written in TypeScript, has no runtime dependencies, and runs on Node 18 or later.
|
|
4
4
|
|
|
5
5
|
It is built for workbooks that several people edit at once:
|
|
6
6
|
|
|
@@ -20,7 +20,9 @@ claude mcp add --scope user graph-excel -- npx -y graph-excel-mcp
|
|
|
20
20
|
{ "mcpServers": { "graph-excel": { "command": "npx", "args": ["-y", "graph-excel-mcp"] } } }
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
**Claude Desktop extension
|
|
23
|
+
**Claude Desktop extension:** download `graph-excel-mcp-<version>.mcpb` from the [latest release](https://github.com/mickey-ichi/graph-excel-mcp/releases/latest) and open it.
|
|
24
|
+
|
|
25
|
+
**Claude plugin (Cowork, Claude Code):** install the `plugin/` folder of this repo. It contains the server and a `graph-excel` skill.
|
|
24
26
|
|
|
25
27
|
## Sign in
|
|
26
28
|
|
|
@@ -48,6 +50,37 @@ Instead of pasting the SharePoint URL every time, you can add aliases to `~/.gra
|
|
|
48
50
|
- Every call runs inside a workbook session. Sessionless calls on large workbooks can time out with a 504 even though the write was applied.
|
|
49
51
|
- Line breaks are sent as `\n`, which Excel stores as `\r\n`. If you write `\r\n` directly, the cell ends up with a stray `_x000D_`.
|
|
50
52
|
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
Development needs Node 22.18 or later, which runs the `.ts` sources directly.
|
|
56
|
+
|
|
57
|
+
| Command | What it does |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `npm run mcp` / `npm run login` | Runs the server or the terminal sign-in from `src/` |
|
|
60
|
+
| `npm run typecheck` | Runs `tsc` in strict mode with no emit |
|
|
61
|
+
| `npm test` | Runs offline tests: range parsing, update composition, and the MCP handshake on `src/` and `dist/` |
|
|
62
|
+
| `npm run build` | Compiles `src/` to `dist/`, which is what npm, the `.mcpb` and the plugin run |
|
|
63
|
+
| `npm run pack` | Builds, then writes `build/*.mcpb` (gitignored) and `plugin/` (committed) |
|
|
64
|
+
|
|
65
|
+
Layout:
|
|
66
|
+
|
|
67
|
+
| Path | Contents |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `src/graph.ts` | Auth and the Graph client |
|
|
70
|
+
| `src/excel.ts` | Workbook operations |
|
|
71
|
+
| `src/mcp.ts` | Tools and the JSON-RPC server |
|
|
72
|
+
| `src/cli.ts` | Entry point |
|
|
73
|
+
|
|
74
|
+
## Release
|
|
75
|
+
|
|
76
|
+
Bump `version` in `package.json`, run `npm run pack`, commit, then push a matching tag:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
git tag v0.3.1 && git push origin v0.3.1
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `Publish` workflow runs the tests, publishes to npm through trusted publishing (no token or OTP; provenance attached) and creates a GitHub release with the `.mcpb` attached.
|
|
83
|
+
|
|
51
84
|
## License
|
|
52
85
|
|
|
53
86
|
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point: `graph-excel-mcp` serves MCP over stdio; `graph-excel-mcp login` signs in from a terminal.
|
|
3
|
+
import { cliLogin } from "./graph.js";
|
|
4
|
+
import { serve } from "./mcp.js";
|
|
5
|
+
if (process.argv[2] === "login") {
|
|
6
|
+
cliLogin().catch((e) => {
|
|
7
|
+
console.error(e.message);
|
|
8
|
+
process.exit(1);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
serve();
|
|
13
|
+
}
|
package/dist/excel.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Excel-on-SharePoint operations with the guards shared test/spec workbooks need:
|
|
2
|
+
// workbook sessions (sessionless calls on big files time out), base checks before writing,
|
|
3
|
+
// read-back hash verification after writing, dry runs until send=true.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { HOME, graph, GraphError, resolveUrl, wb } from "./graph.js";
|
|
8
|
+
export const SEP = "-----update-----";
|
|
9
|
+
const BATCH_MAX = 20;
|
|
10
|
+
const SESSION_IDLE_MS = 4 * 60 * 1000; // Graph drops idle persistent sessions after ~5 min
|
|
11
|
+
// Workbook aliases: $GRAPH_EXCEL_HOME/workbooks.json (see workbooks.example.json).
|
|
12
|
+
const ALIAS_FILES = [path.join(HOME, "workbooks.json")];
|
|
13
|
+
// ---------- helpers ----------
|
|
14
|
+
export const hash = (v) => crypto.createHash("sha256").update(String(v ?? "")).digest("hex").slice(0, 12);
|
|
15
|
+
const normEol = (s) => String(s ?? "").replace(/\r\n/g, "\n");
|
|
16
|
+
const preview = (v, n = 80) => {
|
|
17
|
+
const s = String(v ?? "").replace(/\r?\n/g, "⏎");
|
|
18
|
+
return s.length > n ? `${s.slice(0, n)}… (${s.length} chars)` : s;
|
|
19
|
+
};
|
|
20
|
+
const colNum = (letters) => [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0);
|
|
21
|
+
const colName = (n) => {
|
|
22
|
+
let s = "";
|
|
23
|
+
for (; n > 0; n = Math.floor((n - 1) / 26))
|
|
24
|
+
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
|
25
|
+
return s;
|
|
26
|
+
};
|
|
27
|
+
const ADDR = /^([A-Z]{1,3})([1-9][0-9]{0,6})(?::([A-Z]{1,3})([1-9][0-9]{0,6}))?$/;
|
|
28
|
+
export function parseRange(address) {
|
|
29
|
+
const a = String(address ?? "").trim().toUpperCase().replace(/\$/g, "");
|
|
30
|
+
const m = a.match(ADDR);
|
|
31
|
+
if (!m)
|
|
32
|
+
throw new Error(`Invalid range "${address}" (use one contiguous range like H60 or H60:J60)`);
|
|
33
|
+
const c1 = colNum(m[1]), r1 = Number(m[2]);
|
|
34
|
+
const c2 = m[3] ? colNum(m[3]) : c1, r2 = m[4] ? Number(m[4]) : r1;
|
|
35
|
+
if (c2 < c1 || r2 < r1)
|
|
36
|
+
throw new Error(`Invalid range "${address}" (top-left first)`);
|
|
37
|
+
return { address: a, c1, r1, rows: r2 - r1 + 1, cols: c2 - c1 + 1 };
|
|
38
|
+
}
|
|
39
|
+
const cellAddr = (rg, i, j) => `${colName(rg.c1 + j)}${rg.r1 + i}`;
|
|
40
|
+
function to2d(values, rg, name) {
|
|
41
|
+
let grid;
|
|
42
|
+
if (!Array.isArray(values))
|
|
43
|
+
grid = [[values]];
|
|
44
|
+
else if (!Array.isArray(values[0]))
|
|
45
|
+
grid = rg.rows === 1 ? [values] : values.map((v) => [v]);
|
|
46
|
+
else
|
|
47
|
+
grid = values;
|
|
48
|
+
if (grid.length !== rg.rows || grid.some((r) => !Array.isArray(r) || r.length !== rg.cols))
|
|
49
|
+
throw new Error(`${name} for ${rg.address} must be ${rg.rows}×${rg.cols}`);
|
|
50
|
+
return grid;
|
|
51
|
+
}
|
|
52
|
+
function matches(current, expected) {
|
|
53
|
+
if (expected === null || expected === undefined)
|
|
54
|
+
return true;
|
|
55
|
+
const e = String(expected);
|
|
56
|
+
if (e.startsWith("sha256:")) {
|
|
57
|
+
const want = e.slice(7);
|
|
58
|
+
return want.length >= 8 && (hash(current).startsWith(want) || hash(normEol(current)).startsWith(want));
|
|
59
|
+
}
|
|
60
|
+
return String(current ?? "") === e || normEol(current) === normEol(e);
|
|
61
|
+
}
|
|
62
|
+
const items = new Map(); // file ref -> driveItem
|
|
63
|
+
const sessions = new Map(); // workbook path -> session
|
|
64
|
+
function aliases() {
|
|
65
|
+
const all = {};
|
|
66
|
+
for (const f of ALIAS_FILES) {
|
|
67
|
+
try {
|
|
68
|
+
Object.assign(all, JSON.parse(fs.readFileSync(f, "utf8")));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// no alias file
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return all;
|
|
75
|
+
}
|
|
76
|
+
async function item(file) {
|
|
77
|
+
const ref = String(file ?? "").trim();
|
|
78
|
+
if (!ref)
|
|
79
|
+
throw new Error("Missing file (SharePoint URL, alias from ~/.graph-excel/workbooks.json, or driveId:itemId)");
|
|
80
|
+
const cached = items.get(ref);
|
|
81
|
+
if (cached)
|
|
82
|
+
return cached;
|
|
83
|
+
let it;
|
|
84
|
+
const alias = aliases()[ref];
|
|
85
|
+
if (alias)
|
|
86
|
+
it = { id: alias.itemId, name: alias.name, parentReference: { driveId: alias.driveId } };
|
|
87
|
+
else if (/^https:\/\//.test(ref))
|
|
88
|
+
it = await resolveUrl(ref);
|
|
89
|
+
else if (/^b![^:]+:[A-Z0-9]+$/.test(ref)) {
|
|
90
|
+
const [driveId, id] = ref.split(":");
|
|
91
|
+
it = { id, parentReference: { driveId } };
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
throw new Error(`Unknown workbook "${ref}" — pass the SharePoint URL or an alias: ${Object.keys(aliases()).join(", ") || "(none)"}`);
|
|
95
|
+
items.set(ref, it);
|
|
96
|
+
return it;
|
|
97
|
+
}
|
|
98
|
+
async function session(base, { fresh = false } = {}) {
|
|
99
|
+
const s = sessions.get(base);
|
|
100
|
+
if (!fresh && s && Date.now() - s.last < SESSION_IDLE_MS) {
|
|
101
|
+
s.last = Date.now();
|
|
102
|
+
return s.id;
|
|
103
|
+
}
|
|
104
|
+
const { id } = await graph("POST", `${base}/createSession`, { persistChanges: true }, { retries: 2 });
|
|
105
|
+
sessions.set(base, { id, last: Date.now() });
|
|
106
|
+
return id;
|
|
107
|
+
}
|
|
108
|
+
const sessionLost = (e) => e instanceof GraphError && /session/i.test(e.message) && [400, 404, 410].includes(e.status);
|
|
109
|
+
// Workbook context: call(method, relUrl, body) and batch(requests) inside a session, re-opened once if it expired.
|
|
110
|
+
async function open(file) {
|
|
111
|
+
const it = await item(file);
|
|
112
|
+
const base = wb(it);
|
|
113
|
+
const withSession = async (fn) => {
|
|
114
|
+
try {
|
|
115
|
+
return await fn(await session(base));
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (!sessionLost(e))
|
|
119
|
+
throw e;
|
|
120
|
+
return fn(await session(base, { fresh: true }));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const sheetUrl = (sheet) => {
|
|
124
|
+
if (!sheet)
|
|
125
|
+
throw new Error("Missing sheet name");
|
|
126
|
+
return `${base}/worksheets('${encodeURIComponent(String(sheet).replace(/'/g, "''"))}')`;
|
|
127
|
+
};
|
|
128
|
+
const rangeUrl = (sheet, address) => `${sheetUrl(sheet)}/range(address='${address}')`;
|
|
129
|
+
const call = (method, url, body, opts = {}) => withSession((sid) => graph(method, url, body, { ...opts, session: sid }));
|
|
130
|
+
// requests -> responses in the same order. Retryable read failures are retried one by one; writes are never
|
|
131
|
+
// blindly retried here (callers verify by reading back).
|
|
132
|
+
async function batch(requests) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (let i = 0; i < requests.length; i += BATCH_MAX) {
|
|
135
|
+
const chunk = requests.slice(i, i + BATCH_MAX);
|
|
136
|
+
const res = await withSession((sid) => graph("POST", "/$batch", {
|
|
137
|
+
requests: chunk.map((r, k) => ({
|
|
138
|
+
id: String(k + 1),
|
|
139
|
+
method: r.method,
|
|
140
|
+
url: r.url,
|
|
141
|
+
headers: { "Content-Type": "application/json", "workbook-session-id": sid },
|
|
142
|
+
...(r.body === undefined ? {} : { body: r.body }),
|
|
143
|
+
})),
|
|
144
|
+
}, { retries: 1 }));
|
|
145
|
+
const byId = new Map(res.responses.map((r) => [r.id, r]));
|
|
146
|
+
for (let k = 0; k < chunk.length; k++) {
|
|
147
|
+
let r = byId.get(String(k + 1)) ?? { status: 500, body: { error: { message: "missing batch response" } } };
|
|
148
|
+
if (r.status >= 400 && chunk[k].method === "GET" && [429, 502, 503, 504].includes(r.status)) {
|
|
149
|
+
try {
|
|
150
|
+
r = { status: 200, body: await call("GET", chunk[k].url) };
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
r = { status: e instanceof GraphError ? e.status : 500, body: { error: { message: e.message } } };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
out.push(r);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
const ok = (r, what) => {
|
|
162
|
+
if (r.status >= 400)
|
|
163
|
+
throw new Error(`${what}: ${r.status} ${JSON.stringify(r.body?.error ?? r.body).slice(0, 300)}`);
|
|
164
|
+
return r.body;
|
|
165
|
+
};
|
|
166
|
+
return { it, base, sheetUrl, rangeUrl, call, batch, ok };
|
|
167
|
+
}
|
|
168
|
+
async function readValues(wbx, sheet, ranges) {
|
|
169
|
+
const res = await wbx.batch(ranges.map((rg) => ({ method: "GET", url: `${wbx.rangeUrl(sheet, rg.address)}?$select=address,values` })));
|
|
170
|
+
return res.map((r, i) => wbx.ok(r, `read ${ranges[i].address}`).values);
|
|
171
|
+
}
|
|
172
|
+
async function readFills(wbx, sheet, addresses) {
|
|
173
|
+
const res = await wbx.batch(addresses.map((a) => ({ method: "GET", url: `${wbx.rangeUrl(sheet, a)}/format/fill` })));
|
|
174
|
+
return res.map((r, i) => wbx.ok(r, `read fill ${addresses[i]}`).color || null);
|
|
175
|
+
}
|
|
176
|
+
// ---------- operations ----------
|
|
177
|
+
export async function resolve({ file }) {
|
|
178
|
+
const wbx = await open(file);
|
|
179
|
+
const full = await graph("GET", `/drives/${wbx.it.parentReference.driveId}/items/${wbx.it.id}?$select=name,webUrl,lastModifiedDateTime,lastModifiedBy`);
|
|
180
|
+
const ws = await wbx.call("GET", `${wbx.base}/worksheets?$select=name,position,visibility`);
|
|
181
|
+
const used = await wbx.batch(ws.value.map((w) => ({ method: "GET", url: `${wbx.sheetUrl(w.name)}/usedRange(valuesOnly=true)?$select=address` })));
|
|
182
|
+
return {
|
|
183
|
+
name: full.name,
|
|
184
|
+
driveId: wbx.it.parentReference.driveId,
|
|
185
|
+
itemId: wbx.it.id,
|
|
186
|
+
webUrl: full.webUrl,
|
|
187
|
+
lastModified: `${full.lastModifiedDateTime} by ${full.lastModifiedBy?.user?.displayName ?? "?"}`,
|
|
188
|
+
sheets: ws.value.map((w, i) => ({
|
|
189
|
+
position: w.position,
|
|
190
|
+
name: w.name,
|
|
191
|
+
...(w.visibility !== "Visible" ? { visibility: w.visibility } : {}),
|
|
192
|
+
usedRange: used[i].status < 400 ? String(used[i].body.address).replace(/^.*!/, "") : null,
|
|
193
|
+
})),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export async function readRange({ file, sheet, range, fills = false, hashOnly = false }) {
|
|
197
|
+
const rg = parseRange(range);
|
|
198
|
+
const wbx = await open(file);
|
|
199
|
+
const [values] = await readValues(wbx, sheet, [rg]);
|
|
200
|
+
const cells = [];
|
|
201
|
+
values.forEach((row, i) => row.forEach((v, j) => cells.push({ address: cellAddr(rg, i, j), hash: `sha256:${hash(v)}`, ...(hashOnly ? { length: String(v).length } : { value: v }) })));
|
|
202
|
+
if (fills) {
|
|
203
|
+
if (cells.length > 400)
|
|
204
|
+
throw new Error("fills=true is limited to 400 cells per call");
|
|
205
|
+
const colors = await readFills(wbx, sheet, cells.map((c) => c.address));
|
|
206
|
+
cells.forEach((c, k) => (c.fill = colors[k]));
|
|
207
|
+
}
|
|
208
|
+
return { sheet, range: rg.address, cells };
|
|
209
|
+
}
|
|
210
|
+
// Each block is one contiguous range. A block whose current content does not match `expected` is skipped as a
|
|
211
|
+
// conflict (never partially written).
|
|
212
|
+
export async function writeValues({ file, sheet, writes, send = false }) {
|
|
213
|
+
if (!Array.isArray(writes) || !writes.length)
|
|
214
|
+
throw new Error("writes must be a non-empty array");
|
|
215
|
+
const plan = writes.map((w) => {
|
|
216
|
+
const rg = parseRange(w.range);
|
|
217
|
+
const values = to2d(w.values, rg, "values").map((row) => row.map((v) => (typeof v === "string" ? normEol(v) : v)));
|
|
218
|
+
return { rg, values, expected: w.expected === undefined ? null : to2d(w.expected, rg, "expected") };
|
|
219
|
+
});
|
|
220
|
+
const wbx = await open(file);
|
|
221
|
+
const current = await readValues(wbx, sheet, plan.map((p) => p.rg));
|
|
222
|
+
const report = plan.map((p, k) => {
|
|
223
|
+
const conflicts = [];
|
|
224
|
+
const changes = [];
|
|
225
|
+
p.values.forEach((row, i) => row.forEach((v, j) => {
|
|
226
|
+
const cur = current[k][i][j];
|
|
227
|
+
const addr = cellAddr(p.rg, i, j);
|
|
228
|
+
if (p.expected && !matches(cur, p.expected[i][j]))
|
|
229
|
+
conflicts.push({ address: addr, current: preview(cur), currentHash: `sha256:${hash(cur)}` });
|
|
230
|
+
if (String(cur ?? "") !== String(v ?? ""))
|
|
231
|
+
changes.push({ address: addr, from: preview(cur), to: preview(v) });
|
|
232
|
+
}));
|
|
233
|
+
return { range: p.rg.address, status: conflicts.length ? "conflict" : changes.length ? "will-write" : "unchanged", conflicts, changes };
|
|
234
|
+
});
|
|
235
|
+
if (!send)
|
|
236
|
+
return { dryRun: true, sheet, blocks: report, note: "Nothing written. Call again with send=true after the user approved." };
|
|
237
|
+
const todo = plan.map((p, k) => ({ p, r: report[k] })).filter(({ r }) => r.status === "will-write");
|
|
238
|
+
const res = await wbx.batch(todo.map(({ p }) => ({ method: "PATCH", url: wbx.rangeUrl(sheet, p.rg.address), body: { values: p.values } })));
|
|
239
|
+
// Read back everything (a 504 may still have applied the write), retry once what didn't land.
|
|
240
|
+
let after = await readValues(wbx, sheet, todo.map(({ p }) => p.rg));
|
|
241
|
+
const landed = (k) => todo[k].p.values.every((row, i) => row.every((v, j) => normEol(after[k][i][j]) === normEol(v)));
|
|
242
|
+
const retry = todo.map((_, k) => k).filter((k) => !landed(k) && res[k].status >= 500);
|
|
243
|
+
if (retry.length) {
|
|
244
|
+
await wbx.batch(retry.map((k) => ({ method: "PATCH", url: wbx.rangeUrl(sheet, todo[k].p.rg.address), body: { values: todo[k].p.values } })));
|
|
245
|
+
after = await readValues(wbx, sheet, todo.map(({ p }) => p.rg));
|
|
246
|
+
}
|
|
247
|
+
todo.forEach(({ p, r }, k) => {
|
|
248
|
+
const bad = [];
|
|
249
|
+
p.values.forEach((row, i) => row.forEach((v, j) => {
|
|
250
|
+
const got = after[k][i][j];
|
|
251
|
+
if (String(got) === String(v) || normEol(got) === normEol(v))
|
|
252
|
+
return;
|
|
253
|
+
bad.push({ address: cellAddr(p.rg, i, j), wanted: preview(v), got: preview(got) });
|
|
254
|
+
}));
|
|
255
|
+
r.status = bad.length ? "written-mismatch" : "written-verified";
|
|
256
|
+
if (bad.length)
|
|
257
|
+
r.mismatches = bad;
|
|
258
|
+
if (res[k].status >= 400 && !bad.length)
|
|
259
|
+
r.note = `request returned ${res[k].status} but the read-back matches`;
|
|
260
|
+
if (res[k].status >= 400 && bad.length)
|
|
261
|
+
r.error = JSON.stringify(res[k].body?.error ?? res[k].body).slice(0, 300);
|
|
262
|
+
r.hashes = after[k].flat().map((v) => `sha256:${hash(v)}`);
|
|
263
|
+
});
|
|
264
|
+
return { dryRun: false, sheet, blocks: report };
|
|
265
|
+
}
|
|
266
|
+
export function composeUpdate(cur, text, mode = "replace") {
|
|
267
|
+
const s = String(cur ?? "");
|
|
268
|
+
if (!s.trim())
|
|
269
|
+
throw new Error("cell is empty — there is no original part to keep; use write_values");
|
|
270
|
+
if (s.includes("\r\n"))
|
|
271
|
+
return composeUpdate(normEol(s), text, mode);
|
|
272
|
+
// Graph returns line breaks as \n and Excel stores a written \n as \r\n; a written \r\n would be stored
|
|
273
|
+
// with a stray _x000D_, so always compose with \n.
|
|
274
|
+
const eol = "\n";
|
|
275
|
+
const body = normEol(text).replace(/\s+$/, "");
|
|
276
|
+
const idx = s.indexOf(SEP);
|
|
277
|
+
if (idx < 0) {
|
|
278
|
+
const head = s.replace(/\s+$/, "") + eol + eol + SEP;
|
|
279
|
+
return { head, next: head + eol + body };
|
|
280
|
+
}
|
|
281
|
+
const head = s.slice(0, idx + SEP.length);
|
|
282
|
+
if (mode === "append") {
|
|
283
|
+
const old = s.slice(head.length).replace(/^\s+/, "").replace(/\s+$/, "");
|
|
284
|
+
return { head, next: head + eol + (old ? old + eol : "") + body };
|
|
285
|
+
}
|
|
286
|
+
return { head, next: head + eol + body };
|
|
287
|
+
}
|
|
288
|
+
export async function appendUpdate({ file, sheet, row, update, expected = {}, mode = "replace", fill = "#FFFF00", fillRange, send = false }) {
|
|
289
|
+
const r = Number(row);
|
|
290
|
+
if (!Number.isInteger(r) || r < 1)
|
|
291
|
+
throw new Error("row must be a positive integer");
|
|
292
|
+
const keys = Object.keys(update ?? {});
|
|
293
|
+
const cols = keys.map((c) => c.toUpperCase());
|
|
294
|
+
if (!cols.length || cols.some((c) => !/^[A-Z]{1,3}$/.test(c)))
|
|
295
|
+
throw new Error('update must map column letters to text, e.g. {"H": "..."}');
|
|
296
|
+
if (!["replace", "append"].includes(mode))
|
|
297
|
+
throw new Error('mode must be "replace" or "append"');
|
|
298
|
+
const wbx = await open(file);
|
|
299
|
+
const current = (await readValues(wbx, sheet, cols.map((c) => parseRange(`${c}${r}`)))).map((v) => v[0][0]);
|
|
300
|
+
const exp = Object.fromEntries(Object.entries(expected ?? {}).map(([k, v]) => [k.toUpperCase(), v]));
|
|
301
|
+
const cells = cols.map((c, k) => {
|
|
302
|
+
const cur = current[k];
|
|
303
|
+
const address = `${c}${r}`;
|
|
304
|
+
if (exp[c] !== undefined && !matches(cur, exp[c]))
|
|
305
|
+
return { address, status: "conflict", current: preview(cur), currentHash: `sha256:${hash(cur)}` };
|
|
306
|
+
const { head, next } = composeUpdate(cur, update[keys[k]], mode);
|
|
307
|
+
return { address, status: next === String(cur) ? "unchanged" : "will-write", head, next, cur };
|
|
308
|
+
});
|
|
309
|
+
const fr = parseRange(fillRange || `${colName(Math.min(...cols.map(colNum)))}${r}:${colName(Math.max(...cols.map(colNum)))}${r}`);
|
|
310
|
+
const shown = () => cells.map((c) => c.status === "conflict"
|
|
311
|
+
? c
|
|
312
|
+
: { address: c.address, status: c.status, from: preview(c.cur, 60), updatePart: preview(c.next.slice(c.next.indexOf(SEP)), 300) });
|
|
313
|
+
if (!send) {
|
|
314
|
+
const [fillNow] = fill ? await readFills(wbx, sheet, [fr.address]) : [null];
|
|
315
|
+
return {
|
|
316
|
+
dryRun: true,
|
|
317
|
+
sheet,
|
|
318
|
+
row: r,
|
|
319
|
+
cells: shown(),
|
|
320
|
+
fill: fill ? { range: fr.address, from: fillNow, to: fill } : null,
|
|
321
|
+
note: "Nothing written. Call again with send=true after the user approved.",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (cells.some((c) => c.status === "conflict"))
|
|
325
|
+
return { dryRun: false, written: false, sheet, row: r, cells: shown(), note: "Conflict: someone changed a cell since the base was read — nothing written." };
|
|
326
|
+
const planned = cells;
|
|
327
|
+
const todo = planned.filter((c) => c.status === "will-write");
|
|
328
|
+
const result = todo.length
|
|
329
|
+
? await writeValues({ file, sheet, send: true, writes: todo.map((c) => ({ range: c.address, values: c.next, expected: c.cur === null ? null : String(c.cur) })) })
|
|
330
|
+
: { blocks: [] };
|
|
331
|
+
const after = (await readValues(wbx, sheet, planned.map((c) => parseRange(c.address)))).map((v) => v[0][0]);
|
|
332
|
+
const out = planned.map((c, k) => {
|
|
333
|
+
const block = result.blocks.find((b) => b.range === c.address);
|
|
334
|
+
return {
|
|
335
|
+
address: c.address,
|
|
336
|
+
status: block ? block.status : c.status,
|
|
337
|
+
originalKept: normEol(after[k]).startsWith(normEol(c.head)),
|
|
338
|
+
hash: `sha256:${hash(after[k])}`,
|
|
339
|
+
...(block?.conflicts.length ? { conflicts: block.conflicts } : {}),
|
|
340
|
+
...(block?.mismatches ? { mismatches: block.mismatches } : {}),
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
let fillResult = null;
|
|
344
|
+
if (fill && out.every((c) => c.originalKept && c.status !== "conflict")) {
|
|
345
|
+
fillResult = (await setFill({ file, sheet, ranges: [fr.address], color: fill, send: true })).ranges[0];
|
|
346
|
+
}
|
|
347
|
+
return { dryRun: false, sheet, row: r, cells: out, fill: fillResult };
|
|
348
|
+
}
|
|
349
|
+
/** color: "#FFFF00" etc., or null to clear the fill. */
|
|
350
|
+
export async function setFill({ file, sheet, ranges, color = null, send = false }) {
|
|
351
|
+
if (!Array.isArray(ranges) || !ranges.length)
|
|
352
|
+
throw new Error("ranges must be a non-empty array of contiguous ranges");
|
|
353
|
+
const rgs = ranges.map(parseRange);
|
|
354
|
+
if (color !== null && !/^#[0-9A-Fa-f]{6}$/.test(String(color)))
|
|
355
|
+
throw new Error('color must be like "#FFFF00", or null to clear');
|
|
356
|
+
const want = color === null ? null : String(color).toUpperCase();
|
|
357
|
+
const wbx = await open(file);
|
|
358
|
+
const before = await readFills(wbx, sheet, rgs.map((r) => r.address));
|
|
359
|
+
const rows = rgs.map((r, k) => ({ range: r.address, from: before[k] ?? "(none or mixed)", to: want ?? "(cleared)" }));
|
|
360
|
+
if (!send)
|
|
361
|
+
return { dryRun: true, sheet, ranges: rows, note: "Nothing written. Call again with send=true after the user approved." };
|
|
362
|
+
await wbx.batch(rgs.map((r) => want === null
|
|
363
|
+
? { method: "POST", url: `${wbx.rangeUrl(sheet, r.address)}/format/fill/clear`, body: {} }
|
|
364
|
+
: { method: "PATCH", url: `${wbx.rangeUrl(sheet, r.address)}/format/fill`, body: { color: want } }));
|
|
365
|
+
const after = await readFills(wbx, sheet, rgs.map((r) => r.address));
|
|
366
|
+
rows.forEach((row, k) => {
|
|
367
|
+
const got = after[k] ? after[k].toUpperCase() : null;
|
|
368
|
+
row.now = got ?? "(none)";
|
|
369
|
+
row.status = got === want ? "verified" : "mismatch";
|
|
370
|
+
});
|
|
371
|
+
return { dryRun: false, sheet, ranges: rows };
|
|
372
|
+
}
|
package/dist/graph.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Minimal Microsoft Graph client for editing SharePoint Excel files. Zero dependencies.
|
|
2
|
+
// Auth: OAuth device code flow with the Microsoft Graph PowerShell public client; the refresh token is kept in
|
|
3
|
+
// $GRAPH_EXCEL_HOME/token.json (default ~/.graph-excel, chmod 600) so it survives extension/plugin updates.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
const CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; // Microsoft Graph PowerShell
|
|
8
|
+
const TENANT = "organizations";
|
|
9
|
+
const SCOPE = "https://graph.microsoft.com/Files.ReadWrite.All offline_access openid profile";
|
|
10
|
+
const AUTH = `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0`;
|
|
11
|
+
// Hosts such as Claude Desktop may pass an unfilled template ("${user_config.x}") or "" — treat as unset.
|
|
12
|
+
const usable = (v) => (v && !v.startsWith("${") ? v : undefined);
|
|
13
|
+
export const HOME = usable(process.env.GRAPH_EXCEL_HOME) ?? path.join(os.homedir(), ".graph-excel");
|
|
14
|
+
const TOKEN_FILE = path.join(HOME, "token.json");
|
|
15
|
+
function loadToken() {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function saveToken(res, previous = {}) {
|
|
24
|
+
let account = previous.account;
|
|
25
|
+
if (res.id_token) {
|
|
26
|
+
try {
|
|
27
|
+
const claims = JSON.parse(Buffer.from(res.id_token.split(".")[1], "base64url").toString());
|
|
28
|
+
account = claims.preferred_username || claims.upn || account;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// keep the previous account name
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const refresh_token = res.refresh_token ?? previous.refresh_token;
|
|
35
|
+
if (!refresh_token)
|
|
36
|
+
throw new Error("Microsoft did not return a refresh token");
|
|
37
|
+
const t = {
|
|
38
|
+
account,
|
|
39
|
+
access_token: res.access_token,
|
|
40
|
+
refresh_token,
|
|
41
|
+
expires_at: Date.now() + (Number(res.expires_in) - 120) * 1000,
|
|
42
|
+
};
|
|
43
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
44
|
+
fs.writeFileSync(TOKEN_FILE, JSON.stringify(t, null, 2), { mode: 0o600 });
|
|
45
|
+
fs.chmodSync(TOKEN_FILE, 0o600);
|
|
46
|
+
return t;
|
|
47
|
+
}
|
|
48
|
+
async function post(url, form) {
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
52
|
+
body: new URLSearchParams(form),
|
|
53
|
+
});
|
|
54
|
+
return { ok: res.ok, body: await res.json().catch(() => ({})) };
|
|
55
|
+
}
|
|
56
|
+
async function refresh(t) {
|
|
57
|
+
const r = await post(`${AUTH}/token`, { client_id: CLIENT_ID, grant_type: "refresh_token", refresh_token: t.refresh_token, scope: SCOPE });
|
|
58
|
+
return r.ok ? saveToken(r.body, t) : null;
|
|
59
|
+
}
|
|
60
|
+
let refreshing = null;
|
|
61
|
+
export async function silentToken() {
|
|
62
|
+
let t = loadToken();
|
|
63
|
+
if (!t?.refresh_token)
|
|
64
|
+
return null;
|
|
65
|
+
if (!t.access_token || Date.now() >= t.expires_at) {
|
|
66
|
+
const current = t;
|
|
67
|
+
refreshing ??= refresh(current).finally(() => (refreshing = null));
|
|
68
|
+
t = await refreshing;
|
|
69
|
+
}
|
|
70
|
+
return t && { token: t.access_token, account: t.account, expiresOn: new Date(t.expires_at).toISOString() };
|
|
71
|
+
}
|
|
72
|
+
export async function getToken() {
|
|
73
|
+
const t = await silentToken();
|
|
74
|
+
if (!t)
|
|
75
|
+
throw new Error("Not signed in to Microsoft Graph: call login_status (or run `graph-excel-mcp login`)");
|
|
76
|
+
return t.token;
|
|
77
|
+
}
|
|
78
|
+
// Starts a device code login. Resolves with the sign-in message right away; `done` settles when the user
|
|
79
|
+
// finishes signing in (token saved) or the code expires.
|
|
80
|
+
let pendingLogin = null;
|
|
81
|
+
export async function startLogin() {
|
|
82
|
+
if (pendingLogin)
|
|
83
|
+
return pendingLogin;
|
|
84
|
+
const r = await post(`${AUTH}/devicecode`, { client_id: CLIENT_ID, scope: SCOPE });
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
throw new Error(`Device code request failed: ${r.body.error_description || JSON.stringify(r.body)}`);
|
|
87
|
+
const { device_code, message, interval = 5, expires_in = 900 } = r.body;
|
|
88
|
+
const done = (async () => {
|
|
89
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
90
|
+
let wait = interval * 1000;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
await new Promise((s) => setTimeout(s, wait));
|
|
93
|
+
const p = await post(`${AUTH}/token`, { client_id: CLIENT_ID, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code });
|
|
94
|
+
if (p.ok)
|
|
95
|
+
return saveToken(p.body);
|
|
96
|
+
if (p.body.error === "slow_down")
|
|
97
|
+
wait += 5000;
|
|
98
|
+
else if (p.body.error !== "authorization_pending")
|
|
99
|
+
throw new Error(p.body.error_description || p.body.error);
|
|
100
|
+
}
|
|
101
|
+
throw new Error("Device code expired before sign-in finished");
|
|
102
|
+
})().finally(() => (pendingLogin = null));
|
|
103
|
+
done.catch(() => { });
|
|
104
|
+
pendingLogin = { message, done };
|
|
105
|
+
return pendingLogin;
|
|
106
|
+
}
|
|
107
|
+
// Terminal sign-in (`graph-excel-mcp login`).
|
|
108
|
+
export async function cliLogin() {
|
|
109
|
+
const t = await silentToken();
|
|
110
|
+
if (t)
|
|
111
|
+
return void console.log(`SIGNED_IN ${t.account}`);
|
|
112
|
+
const { message, done } = await startLogin();
|
|
113
|
+
console.log(message);
|
|
114
|
+
const saved = await done;
|
|
115
|
+
console.log(`SIGNED_IN ${saved.account}`);
|
|
116
|
+
}
|
|
117
|
+
const RETRYABLE = new Set([429, 502, 503, 504]);
|
|
118
|
+
export class GraphError extends Error {
|
|
119
|
+
status;
|
|
120
|
+
constructor(method, url, status, text) {
|
|
121
|
+
super(`${method} ${url} -> ${status}: ${text.slice(0, 500)}`);
|
|
122
|
+
this.status = status;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function graph(method, url, body, { session, retries = method === "GET" ? 3 : 0 } = {}) {
|
|
126
|
+
const token = await getToken();
|
|
127
|
+
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
128
|
+
if (session)
|
|
129
|
+
headers["workbook-session-id"] = session;
|
|
130
|
+
for (let attempt = 0;; attempt++) {
|
|
131
|
+
const res = await fetch(url.startsWith("http") ? url : `https://graph.microsoft.com/v1.0${url}`, {
|
|
132
|
+
method,
|
|
133
|
+
headers,
|
|
134
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
135
|
+
});
|
|
136
|
+
const text = await res.text();
|
|
137
|
+
if (res.ok)
|
|
138
|
+
return text ? JSON.parse(text) : null;
|
|
139
|
+
if (attempt < retries && RETRYABLE.has(res.status)) {
|
|
140
|
+
const wait = Number(res.headers.get("retry-after")) * 1000 || 1000 * 2 ** attempt;
|
|
141
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
throw new GraphError(method, url, res.status, text);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Sharing / web URL -> driveItem (driveId + id)
|
|
148
|
+
export async function resolveUrl(webUrl) {
|
|
149
|
+
const enc = "u!" + Buffer.from(webUrl).toString("base64").replace(/=+$/, "").replace(/\//g, "_").replace(/\+/g, "-");
|
|
150
|
+
return graph("GET", `/shares/${enc}/driveItem`);
|
|
151
|
+
}
|
|
152
|
+
export const wb = (item) => `/drives/${item.parentReference.driveId}/items/${item.id}/workbook`;
|