@manudota/artist-mcp 1.8.3-staging.90 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/attachments.d.ts +59 -4
- package/dist/attachments.js +61 -43
- package/dist/connect.d.ts +10 -0
- package/dist/connect.js +52 -4
- package/dist/dispatch.d.ts +10 -2
- package/dist/dispatch.js +18 -7
- package/dist/expiry.d.ts +62 -0
- package/dist/expiry.js +112 -0
- package/dist/index.js +7 -2
- package/dist/notes.d.ts +10 -0
- package/dist/notes.js +14 -1
- package/dist/oauth.d.ts +13 -1
- package/dist/oauth.js +99 -16
- package/dist/page-attachments.d.ts +56 -0
- package/dist/page-attachments.js +177 -0
- package/dist/server.d.ts +79 -0
- package/dist/server.js +269 -116
- package/dist/tokens.d.ts +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,8 +74,8 @@ Google is supporting evidence, and needs the separate Google connection:
|
|
|
74
74
|
| --- | --- |
|
|
75
75
|
| `list_emails` | Gmail search results — subject, sender, date, snippet |
|
|
76
76
|
| `read_email` | Message body, plus what is attached by name, type, and size |
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
77
|
+
| `map_gmail_attachment` | What is on each page of a PDF, without reading it |
|
|
78
|
+
| `read_gmail_attachment` | Images as pictures; PDFs and `.docx` as text, in page ranges |
|
|
79
79
|
| `list_events` | Calendar events in a window, recurrences expanded and flagged |
|
|
80
80
|
| `read_event` | Description and attendees for one event |
|
|
81
81
|
|
package/dist/attachments.d.ts
CHANGED
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
* - `npm:unpdf@1` became a normal dependency import.
|
|
12
12
|
* - Response shapes are declared, because json() is typed loosely under Deno.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* Two ceilings, and neither is decoration.
|
|
16
|
+
*
|
|
17
|
+
* Extraction decodes a PDF into memory, and the cost tracks pages and text
|
|
18
|
+
* volume rather than file size: a 195 KB, 176-page file measured 185 MB of
|
|
19
|
+
* heap when every page was read at once, against a 256 MB function. So the
|
|
20
|
+
* character cap is enforced *during* the page loop with an early stop, which
|
|
21
|
+
* held the same file to 19 MB. A byte cap alone does not protect this.
|
|
22
|
+
*
|
|
23
|
+
* The byte cap is the cruder guard, on what is fetched at all. Contracts and
|
|
24
|
+
* riders sit far below it — a real 7-page rider was 0.6 MB.
|
|
25
|
+
*/
|
|
26
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
14
27
|
/**
|
|
15
28
|
* Exported because `read_note` holds itself to the same number. One policy on
|
|
16
29
|
* how much text may arrive in a single answer, in one place, rather than two
|
|
@@ -131,6 +144,42 @@ export declare function imageResult(meta: {
|
|
|
131
144
|
text: string;
|
|
132
145
|
};
|
|
133
146
|
export declare function unsupportedNote(mimeType: string, filename: string): string;
|
|
147
|
+
/**
|
|
148
|
+
* How bytes arrive. Everything below this line works on the result and has no
|
|
149
|
+
* idea where it came from, which is what lets one PDF/image/.docx path serve
|
|
150
|
+
* both a mail message and a OneNote page. See issue #70.
|
|
151
|
+
*/
|
|
152
|
+
export type AttachmentLoader = () => Promise<{
|
|
153
|
+
oversized: true;
|
|
154
|
+
meta: AttachmentMeta;
|
|
155
|
+
} | {
|
|
156
|
+
oversized: false;
|
|
157
|
+
meta: AttachmentMeta;
|
|
158
|
+
bytes: Uint8Array;
|
|
159
|
+
}>;
|
|
160
|
+
/**
|
|
161
|
+
* The Gmail loader: the only part of this file that knows about messages.
|
|
162
|
+
*
|
|
163
|
+
* The ids are validated here, when the loader is built, rather than inside the
|
|
164
|
+
* closure. A malformed id is a caller mistake and should be reported before any
|
|
165
|
+
* other argument is looked at, which is where it was reported before the loader
|
|
166
|
+
* became a parameter.
|
|
167
|
+
*/
|
|
168
|
+
export declare function gmailLoader(token: string, emailId: unknown, attachmentId: unknown): AttachmentLoader;
|
|
169
|
+
/**
|
|
170
|
+
* What is known about a file before it is read, whatever it came from.
|
|
171
|
+
*
|
|
172
|
+
* Written out rather than derived from the Gmail entry it used to be, because a
|
|
173
|
+
* page resource has no Gmail id and `size` is genuinely unknown until the bytes
|
|
174
|
+
* arrive: OneNote offers no size ahead of the fetch at all. Gmail's own entries
|
|
175
|
+
* satisfy this structurally and carry their extra field along untouched.
|
|
176
|
+
*/
|
|
177
|
+
export type AttachmentMeta = {
|
|
178
|
+
id: string;
|
|
179
|
+
filename: string;
|
|
180
|
+
mime_type: string;
|
|
181
|
+
size: number | null;
|
|
182
|
+
};
|
|
134
183
|
/**
|
|
135
184
|
* Map an attachment: what is on each page, without reading any of it.
|
|
136
185
|
*
|
|
@@ -138,10 +187,16 @@ export declare function unsupportedNote(mimeType: string, filename: string): str
|
|
|
138
187
|
* page one. This costs one text pass and returns a few hundred bytes, so a
|
|
139
188
|
* caller can name the pages worth reading and then read only those.
|
|
140
189
|
*/
|
|
141
|
-
export declare function mapAttachment(
|
|
190
|
+
export declare function mapAttachment(load: AttachmentLoader,
|
|
191
|
+
/**
|
|
192
|
+
* The read tool to point at. Passed in rather than hardcoded: the map is
|
|
193
|
+
* source-neutral, and naming the mail tool while mapping a page attachment
|
|
194
|
+
* sends the model to a tool that cannot open it. See issue #70.
|
|
195
|
+
*/
|
|
196
|
+
readTool: string): Promise<{
|
|
142
197
|
filename: string;
|
|
143
198
|
mime_type: string;
|
|
144
|
-
size: number;
|
|
199
|
+
size: number | null;
|
|
145
200
|
kind: "too_large";
|
|
146
201
|
text: string;
|
|
147
202
|
note: string;
|
|
@@ -173,7 +228,7 @@ export declare function mapAttachment(token: string, emailId: unknown, attachmen
|
|
|
173
228
|
mime_type: string;
|
|
174
229
|
size: number;
|
|
175
230
|
}>;
|
|
176
|
-
export declare function readAttachment(
|
|
231
|
+
export declare function readAttachment(load: AttachmentLoader, fromPage: unknown, pageCount: unknown): Promise<{
|
|
177
232
|
kind: "unsupported";
|
|
178
233
|
images: never[];
|
|
179
234
|
note: string;
|
|
@@ -197,7 +252,7 @@ export declare function readAttachment(token: string, emailId: unknown, attachme
|
|
|
197
252
|
} | {
|
|
198
253
|
filename: string;
|
|
199
254
|
mime_type: string;
|
|
200
|
-
size: number;
|
|
255
|
+
size: number | null;
|
|
201
256
|
kind: "too_large";
|
|
202
257
|
text: string;
|
|
203
258
|
note: string;
|
package/dist/attachments.js
CHANGED
|
@@ -32,7 +32,7 @@ const failure = (message) => new GraphError(message, false);
|
|
|
32
32
|
* The byte cap is the cruder guard, on what is fetched at all. Contracts and
|
|
33
33
|
* riders sit far below it — a real 7-page rider was 0.6 MB.
|
|
34
34
|
*/
|
|
35
|
-
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
35
|
+
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
36
36
|
/**
|
|
37
37
|
* Exported because `read_note` holds itself to the same number. One policy on
|
|
38
38
|
* how much text may arrive in a single answer, in one place, rather than two
|
|
@@ -844,7 +844,15 @@ export function unsupportedNote(mimeType, filename) {
|
|
|
844
844
|
return `Reading ${mimeType} attachments is not built yet, so the contents of ` +
|
|
845
845
|
`${filename} have not been read.`;
|
|
846
846
|
}
|
|
847
|
-
|
|
847
|
+
/**
|
|
848
|
+
* The Gmail loader: the only part of this file that knows about messages.
|
|
849
|
+
*
|
|
850
|
+
* The ids are validated here, when the loader is built, rather than inside the
|
|
851
|
+
* closure. A malformed id is a caller mistake and should be reported before any
|
|
852
|
+
* other argument is looked at, which is where it was reported before the loader
|
|
853
|
+
* became a parameter.
|
|
854
|
+
*/
|
|
855
|
+
export function gmailLoader(token, emailId, attachmentId) {
|
|
848
856
|
if (typeof emailId !== "string" || !GMAIL_ID.test(emailId)) {
|
|
849
857
|
throw failure("email_id is missing or malformed.");
|
|
850
858
|
}
|
|
@@ -852,42 +860,52 @@ async function loadAttachment(token, emailId, attachmentId) {
|
|
|
852
860
|
throw failure("attachment_id is missing or malformed.");
|
|
853
861
|
}
|
|
854
862
|
const id = encodeURIComponent(emailId);
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
863
|
+
return async () => {
|
|
864
|
+
const msgRes = await gmailGet(`/users/me/messages/${id}?format=full`, token);
|
|
865
|
+
const msg = (await msgRes.json());
|
|
866
|
+
const attachments = extractAttachments(msg.payload);
|
|
867
|
+
const meta = attachments.find((a) => a.id === attachmentId);
|
|
868
|
+
if (!meta) {
|
|
869
|
+
// Name what is there. A bare "not found" invites the caller to retry with
|
|
870
|
+
// the same id, and this is the one error a caller can actually act on.
|
|
871
|
+
const available = attachments.length
|
|
872
|
+
? attachments.map((a) => `${a.id} (${a.filename})`).join(", ")
|
|
873
|
+
: "none";
|
|
874
|
+
throw failure(`That message has no attachment ${attachmentId}. Available: ${available}.`);
|
|
875
|
+
}
|
|
876
|
+
if (meta.size !== null && meta.size > MAX_ATTACHMENT_BYTES) {
|
|
877
|
+
return { oversized: true, meta };
|
|
878
|
+
}
|
|
879
|
+
const attRes = await gmailGet(`/users/me/messages/${id}/attachments/${encodeURIComponent(meta.gmail_id)}`, token);
|
|
880
|
+
const payload = (await attRes.json());
|
|
881
|
+
if (typeof payload.data !== "string") {
|
|
882
|
+
throw failure("Gmail returned no data for that attachment.");
|
|
883
|
+
}
|
|
884
|
+
return { oversized: false, meta, bytes: decodeBytes(payload.data) };
|
|
885
|
+
};
|
|
876
886
|
}
|
|
877
887
|
/** The refusal, worded once so reading and mapping cannot drift apart. */
|
|
878
888
|
function tooLargeResult(meta) {
|
|
879
889
|
return {
|
|
880
890
|
filename: meta.filename,
|
|
881
891
|
mime_type: meta.mime_type,
|
|
882
|
-
|
|
892
|
+
// Null, not 0. A size nobody measured must not arrive as a number: the
|
|
893
|
+
// renderer shows no size line at all rather than "0 B" beside a note
|
|
894
|
+
// saying the file is at least the limit. See issue #70.
|
|
895
|
+
size: meta.size,
|
|
883
896
|
kind: "too_large",
|
|
884
897
|
text: "",
|
|
885
|
-
note:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
898
|
+
note: meta.size === null
|
|
899
|
+
// No size was ever available -- a OneNote resource reports none, and the
|
|
900
|
+
// fetch was stopped at the limit rather than run to the end. "At least"
|
|
901
|
+
// is all that can be said, so it is all that is said.
|
|
902
|
+
? `This file is at least the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB ` +
|
|
903
|
+
`limit for reading in chat, so it was not read to the end.`
|
|
904
|
+
// One decimal: rounding 13.8 to "14" overstates a file sitting near
|
|
905
|
+
// the limit, and near the limit is exactly when the number is read.
|
|
906
|
+
: `This file is ${(meta.size / (1024 * 1024)).toFixed(1)} MB, above the ` +
|
|
907
|
+
`${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB limit for reading in chat. ` +
|
|
908
|
+
`It was not fetched.`,
|
|
891
909
|
};
|
|
892
910
|
}
|
|
893
911
|
/**
|
|
@@ -897,8 +915,14 @@ function tooLargeResult(meta) {
|
|
|
897
915
|
* page one. This costs one text pass and returns a few hundred bytes, so a
|
|
898
916
|
* caller can name the pages worth reading and then read only those.
|
|
899
917
|
*/
|
|
900
|
-
export async function mapAttachment(
|
|
901
|
-
|
|
918
|
+
export async function mapAttachment(load,
|
|
919
|
+
/**
|
|
920
|
+
* The read tool to point at. Passed in rather than hardcoded: the map is
|
|
921
|
+
* source-neutral, and naming the mail tool while mapping a page attachment
|
|
922
|
+
* sends the model to a tool that cannot open it. See issue #70.
|
|
923
|
+
*/
|
|
924
|
+
readTool) {
|
|
925
|
+
const loaded = await load();
|
|
902
926
|
if (loaded.oversized)
|
|
903
927
|
return tooLargeResult(loaded.meta);
|
|
904
928
|
const { meta, bytes } = loaded;
|
|
@@ -918,7 +942,7 @@ export async function mapAttachment(token, emailId, attachmentId) {
|
|
|
918
942
|
(doc.parts_total > 1 ? ` in ${doc.parts_total} parts` : "") +
|
|
919
943
|
`. There is no page map to give: a .docx records no pages, and its ` +
|
|
920
944
|
`heading styles are not dependable enough to divide it honestly. ` +
|
|
921
|
-
`Read it with
|
|
945
|
+
`Read it with ${readTool}.`
|
|
922
946
|
: `${meta.filename} could not be opened as a Word document.`,
|
|
923
947
|
};
|
|
924
948
|
}
|
|
@@ -956,18 +980,12 @@ export async function mapAttachment(token, emailId, attachmentId) {
|
|
|
956
980
|
: `Every page of this ${map.pages_total}-page file is a picture, so ` +
|
|
957
981
|
`there is nothing to map. `) +
|
|
958
982
|
`A scan cannot be searched without reading the pages themselves — ask ` +
|
|
959
|
-
|
|
960
|
-
: `${map.pages_total} pages. Use
|
|
983
|
+
`${readTool} for them.`
|
|
984
|
+
: `${map.pages_total} pages. Use ${readTool} with from_page and ` +
|
|
961
985
|
`page_count to read the ones that matter, rather than reading it all.`,
|
|
962
986
|
};
|
|
963
987
|
}
|
|
964
|
-
export async function readAttachment(
|
|
965
|
-
if (typeof emailId !== "string" || !GMAIL_ID.test(emailId)) {
|
|
966
|
-
throw failure("email_id is missing or malformed.");
|
|
967
|
-
}
|
|
968
|
-
if (typeof attachmentId !== "string" || !ATTACHMENT_ID.test(attachmentId)) {
|
|
969
|
-
throw failure("attachment_id is missing or malformed.");
|
|
970
|
-
}
|
|
988
|
+
export async function readAttachment(load, fromPage, pageCount) {
|
|
971
989
|
if (fromPage !== undefined && fromPage !== null &&
|
|
972
990
|
(typeof fromPage !== "number" || !Number.isFinite(fromPage) || fromPage < 1)) {
|
|
973
991
|
throw failure("from_page must be a page number, 1 or greater.");
|
|
@@ -976,7 +994,7 @@ export async function readAttachment(token, emailId, attachmentId, fromPage, pag
|
|
|
976
994
|
(typeof pageCount !== "number" || !Number.isFinite(pageCount) || pageCount < 1)) {
|
|
977
995
|
throw failure("page_count must be a number of pages, 1 or greater.");
|
|
978
996
|
}
|
|
979
|
-
const loaded = await
|
|
997
|
+
const loaded = await load();
|
|
980
998
|
if (loaded.oversized)
|
|
981
999
|
return tooLargeResult(loaded.meta);
|
|
982
1000
|
const { meta, bytes } = loaded;
|
package/dist/connect.d.ts
CHANGED
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
* opt-in — the same asymmetry the product has everywhere else.
|
|
12
12
|
*/
|
|
13
13
|
export declare const runConnect: (input?: string) => Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Reconnect whatever has lapsed, without the user having to work out which.
|
|
16
|
+
*
|
|
17
|
+
* The reconnect was always one command; knowing that it was needed, and for
|
|
18
|
+
* which provider, was the part that cost people a session. This closes that
|
|
19
|
+
* gap — and refuses to do anything when nothing has lapsed, so it can be run on
|
|
20
|
+
* a hunch without silently sending someone through a consent screen they did
|
|
21
|
+
* not need.
|
|
22
|
+
*/
|
|
23
|
+
export declare const runReconnect: () => Promise<void>;
|
|
14
24
|
/**
|
|
15
25
|
* Removing the stored token stops this machine using the connection, but it
|
|
16
26
|
* does not revoke the grant at the provider — only the user's account page can
|
package/dist/connect.js
CHANGED
|
@@ -16,6 +16,7 @@ import { ENTRY_NAME, configPath, readConfig } from './config.js';
|
|
|
16
16
|
import { parseGrants } from './grants.js';
|
|
17
17
|
import { WRITE_SCOPES, connect } from './oauth.js';
|
|
18
18
|
import { PROVIDERS } from './oauth.js';
|
|
19
|
+
import { expiryAtConnect, expiryNotice, expiryState } from './expiry.js';
|
|
19
20
|
import { clearProvider, loadTokens, readProvider } from './tokens.js';
|
|
20
21
|
const PROVIDER_NAMES = Object.keys(PROVIDERS);
|
|
21
22
|
const parseProvider = (input) => {
|
|
@@ -40,7 +41,7 @@ export const runConnect = async (input) => {
|
|
|
40
41
|
// provider, so passing all of them asks each provider for only its own.
|
|
41
42
|
const grants = await installedGrants();
|
|
42
43
|
const relevant = grants.filter((name) => WRITE_SCOPES[name]?.provider === provider);
|
|
43
|
-
await connect(provider, grants);
|
|
44
|
+
const expiresAt = await connect(provider, grants);
|
|
44
45
|
console.log(`\n${PROVIDERS[provider].label} connected — ${PURPOSE[provider]}.`);
|
|
45
46
|
if (relevant.length === 0) {
|
|
46
47
|
console.log('Read-only. Nothing is ever written back to your account.');
|
|
@@ -56,10 +57,42 @@ export const runConnect = async (input) => {
|
|
|
56
57
|
: 'Nothing else is ever written back: not mail, and no event is ' +
|
|
57
58
|
'changed except by the reschedule you granted.'));
|
|
58
59
|
}
|
|
60
|
+
// Said at the one moment the user is certainly reading, which is the whole
|
|
61
|
+
// point: #94 describes a connection that dies weekly, an error that correctly
|
|
62
|
+
// advises a reconnect, and a cause nobody could see from either.
|
|
63
|
+
const lapses = expiryAtConnect(provider, expiresAt);
|
|
64
|
+
if (lapses !== undefined)
|
|
65
|
+
console.log(`\n${lapses}`);
|
|
59
66
|
if (provider === 'microsoft' && (await readProvider('google')) === undefined) {
|
|
60
67
|
console.log('\nTo add Gmail and Calendar as evidence: artist-mcp connect google');
|
|
61
68
|
}
|
|
62
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* Reconnect whatever has lapsed, without the user having to work out which.
|
|
72
|
+
*
|
|
73
|
+
* The reconnect was always one command; knowing that it was needed, and for
|
|
74
|
+
* which provider, was the part that cost people a session. This closes that
|
|
75
|
+
* gap — and refuses to do anything when nothing has lapsed, so it can be run on
|
|
76
|
+
* a hunch without silently sending someone through a consent screen they did
|
|
77
|
+
* not need.
|
|
78
|
+
*/
|
|
79
|
+
export const runReconnect = async () => {
|
|
80
|
+
const { providers } = await loadTokens();
|
|
81
|
+
const lapsed = PROVIDER_NAMES.filter((name) => {
|
|
82
|
+
const entry = providers[name];
|
|
83
|
+
return entry !== undefined && expiryState(entry).kind === 'lapsed';
|
|
84
|
+
});
|
|
85
|
+
if (lapsed.length === 0) {
|
|
86
|
+
console.log('Nothing has lapsed. Run `artist-mcp status` to see what is connected.');
|
|
87
|
+
// Not an error: "nothing to do" is the answer, and exiting non-zero would
|
|
88
|
+
// make a scheduled check look like a failure every time it worked.
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const provider of lapsed) {
|
|
92
|
+
console.log(`\n${PROVIDERS[provider].label} lapsed — reconnecting.`);
|
|
93
|
+
await runConnect(provider);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
63
96
|
/**
|
|
64
97
|
* Removing the stored token stops this machine using the connection, but it
|
|
65
98
|
* does not revoke the grant at the provider — only the user's account page can
|
|
@@ -244,11 +277,26 @@ export const runStatus = async () => {
|
|
|
244
277
|
console.log('Nothing connected on this machine. Start with: artist-mcp connect');
|
|
245
278
|
return;
|
|
246
279
|
}
|
|
280
|
+
let anyLapsed = false;
|
|
247
281
|
for (const name of PROVIDER_NAMES) {
|
|
248
282
|
const entry = providers[name];
|
|
249
283
|
const label = PROVIDERS[name].label.padEnd(10);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
284
|
+
if (entry === undefined) {
|
|
285
|
+
console.log(`${label} not connected`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
console.log(`${label} connected ${entry.connectedAt.slice(0, 10)} — ${entry.scope}`);
|
|
289
|
+
// On its own line and only when there is something to say. A connection
|
|
290
|
+
// with a week left says nothing, so the day it does say something the line
|
|
291
|
+
// is new rather than furniture.
|
|
292
|
+
const notice = expiryNotice(name, entry);
|
|
293
|
+
if (notice !== undefined) {
|
|
294
|
+
console.log(`${' '.repeat(11)}${notice}`);
|
|
295
|
+
if (expiryState(entry).kind === 'lapsed')
|
|
296
|
+
anyLapsed = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (anyLapsed) {
|
|
300
|
+
console.log('\nOr reconnect everything that lapsed at once: artist-mcp reconnect');
|
|
253
301
|
}
|
|
254
302
|
};
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -50,14 +50,22 @@ export declare const OPERATIONS: {
|
|
|
50
50
|
readonly provider: "google";
|
|
51
51
|
readonly effect: "read";
|
|
52
52
|
};
|
|
53
|
-
readonly
|
|
53
|
+
readonly read_gmail_attachment: {
|
|
54
54
|
readonly provider: "google";
|
|
55
55
|
readonly effect: "read";
|
|
56
56
|
};
|
|
57
|
-
readonly
|
|
57
|
+
readonly map_gmail_attachment: {
|
|
58
58
|
readonly provider: "google";
|
|
59
59
|
readonly effect: "read";
|
|
60
60
|
};
|
|
61
|
+
readonly read_page_attachment: {
|
|
62
|
+
readonly provider: "microsoft";
|
|
63
|
+
readonly effect: "read";
|
|
64
|
+
};
|
|
65
|
+
readonly map_page_attachment: {
|
|
66
|
+
readonly provider: "microsoft";
|
|
67
|
+
readonly effect: "read";
|
|
68
|
+
};
|
|
61
69
|
readonly list_events: {
|
|
62
70
|
readonly provider: "google";
|
|
63
71
|
readonly effect: "read";
|
package/dist/dispatch.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { GraphError } from './client.js';
|
|
14
14
|
import { recordWrite } from './audit.js';
|
|
15
|
-
import { mapAttachment, readAttachment } from './attachments.js';
|
|
15
|
+
import { gmailLoader, mapAttachment, readAttachment } from './attachments.js';
|
|
16
|
+
import { oneNoteLoader } from './page-attachments.js';
|
|
16
17
|
import { createEvent, deleteEvent, listCalendars, listEvents, previewDeleteEvent, previewEvent, previewRescheduleEvent, readEvent, rescheduleEvent, } from './calendar.js';
|
|
17
18
|
import { listEmails, readEmail } from './mail.js';
|
|
18
19
|
import { listNotes, mapNotes, readNote } from './notes.js';
|
|
@@ -42,8 +43,14 @@ export const OPERATIONS = {
|
|
|
42
43
|
read_note: { provider: 'microsoft', effect: 'read' },
|
|
43
44
|
list_emails: { provider: 'google', effect: 'read' },
|
|
44
45
|
read_email: { provider: 'google', effect: 'read' },
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
read_gmail_attachment: { provider: 'google', effect: 'read' },
|
|
47
|
+
map_gmail_attachment: { provider: 'google', effect: 'read' },
|
|
48
|
+
// Separate rows rather than a source parameter on the two above. The token is
|
|
49
|
+
// resolved from this table before the call is made, so one operation cannot
|
|
50
|
+
// span two providers without breaking the rule that a Gmail call never spends
|
|
51
|
+
// a Microsoft token. The model still sees two tools; see issue #70.
|
|
52
|
+
read_page_attachment: { provider: 'microsoft', effect: 'read' },
|
|
53
|
+
map_page_attachment: { provider: 'microsoft', effect: 'read' },
|
|
47
54
|
list_events: { provider: 'google', effect: 'read' },
|
|
48
55
|
read_event: { provider: 'google', effect: 'read' },
|
|
49
56
|
list_calendars: { provider: 'google', effect: 'read' },
|
|
@@ -105,10 +112,14 @@ export const dispatchWith = (resolve, record = recordWrite) => async (op, params
|
|
|
105
112
|
return (await listEmails(token, params.query));
|
|
106
113
|
case 'read_email':
|
|
107
114
|
return (await readEmail(token, params.email_id));
|
|
108
|
-
case '
|
|
109
|
-
return (await mapAttachment(token, params.email_id, params.attachment_id));
|
|
110
|
-
case '
|
|
111
|
-
return (await readAttachment(token, params.email_id, params.attachment_id, params.from_page, params.page_count));
|
|
115
|
+
case 'map_gmail_attachment':
|
|
116
|
+
return (await mapAttachment(gmailLoader(token, params.email_id, params.attachment_id), 'read_gmail_attachment'));
|
|
117
|
+
case 'read_gmail_attachment':
|
|
118
|
+
return (await readAttachment(gmailLoader(token, params.email_id, params.attachment_id), params.from_page, params.page_count));
|
|
119
|
+
case 'map_page_attachment':
|
|
120
|
+
return (await mapAttachment(oneNoteLoader(token, params.note_id, params.attachment_id), 'read_page_attachment'));
|
|
121
|
+
case 'read_page_attachment':
|
|
122
|
+
return (await readAttachment(oneNoteLoader(token, params.note_id, params.attachment_id), params.from_page, params.page_count));
|
|
112
123
|
case 'list_events':
|
|
113
124
|
return (await listEvents(token, params.calendar_id, params.query, params.time_min, params.time_max));
|
|
114
125
|
case 'read_event':
|
package/dist/expiry.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a connection's age means, and what to say about it.
|
|
3
|
+
*
|
|
4
|
+
* Split out and pure because it is a decision rather than an integration: the
|
|
5
|
+
* refresh failure in oauth.ts and the report in connect.ts must not describe
|
|
6
|
+
* the same connection differently, and the only way to be sure of that is for
|
|
7
|
+
* both to call this. It also makes the arithmetic testable against a fixed
|
|
8
|
+
* clock, which is the half that is easy to get wrong and hard to notice.
|
|
9
|
+
*
|
|
10
|
+
* The fact being reasoned about is #94: Google expires refresh tokens issued
|
|
11
|
+
* by an app in Testing publishing status after seven days. Nothing here knows
|
|
12
|
+
* that number — it arrives with the connection, from the deployment (see
|
|
13
|
+
* `statedExpiry`), so this file stays true when the app is verified.
|
|
14
|
+
*/
|
|
15
|
+
import type { ProviderName, StoredTokens } from './tokens.js';
|
|
16
|
+
/** The one command that fixes any of this, per provider. */
|
|
17
|
+
export declare const reconnectCommand: (provider: ProviderName) => string;
|
|
18
|
+
export type ExpiryState =
|
|
19
|
+
/** The provider stated no limit, so there is nothing to say. */
|
|
20
|
+
{
|
|
21
|
+
kind: 'unlimited';
|
|
22
|
+
}
|
|
23
|
+
/** Stated a limit, still inside it. */
|
|
24
|
+
| {
|
|
25
|
+
kind: 'valid';
|
|
26
|
+
daysLeft: number;
|
|
27
|
+
expiresAt: string;
|
|
28
|
+
}
|
|
29
|
+
/** Stated a limit, and it has passed. */
|
|
30
|
+
| {
|
|
31
|
+
kind: 'lapsed';
|
|
32
|
+
daysAgo: number;
|
|
33
|
+
expiresAt: string;
|
|
34
|
+
};
|
|
35
|
+
export declare const expiryState: (tokens: StoredTokens, now?: Date) => ExpiryState;
|
|
36
|
+
/**
|
|
37
|
+
* The line `status` prints, or nothing when there is nothing to say.
|
|
38
|
+
*
|
|
39
|
+
* Silent while the limit is comfortably far off. A warning shown every single
|
|
40
|
+
* day is read as decoration by the third day, and the whole point of this is to
|
|
41
|
+
* be believed on the day it matters.
|
|
42
|
+
*/
|
|
43
|
+
export declare const expiryNotice: (provider: ProviderName, tokens: StoredTokens, now?: Date) => string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* What to say at the end of a successful connect.
|
|
46
|
+
*
|
|
47
|
+
* Said here rather than discovered later, which is the entire complaint in #94:
|
|
48
|
+
* the connection simply stopped, the error advised a reconnect, the advice was
|
|
49
|
+
* right, and the cause stayed invisible for as long as anyone cared to look.
|
|
50
|
+
*/
|
|
51
|
+
export declare const expiryAtConnect: (provider: ProviderName, expiresAt: string | undefined, now?: Date) => string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Why a refresh was rejected, inferred from the connection's own age.
|
|
54
|
+
*
|
|
55
|
+
* The provider cannot tell us: Google answers `invalid_grant` with "Token has
|
|
56
|
+
* been expired or revoked" whether it lapsed on schedule or someone withdrew
|
|
57
|
+
* the app in their account settings. Those are opposite situations — one is
|
|
58
|
+
* expected and one is worth investigating — so the distinction is drawn here,
|
|
59
|
+
* from the expiry the connection recorded, and worded as the inference it is.
|
|
60
|
+
* Nothing below claims the provider said any of it.
|
|
61
|
+
*/
|
|
62
|
+
export declare const explainRefreshFailure: (provider: ProviderName, tokens: StoredTokens, now?: Date) => string;
|
package/dist/expiry.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a connection's age means, and what to say about it.
|
|
3
|
+
*
|
|
4
|
+
* Split out and pure because it is a decision rather than an integration: the
|
|
5
|
+
* refresh failure in oauth.ts and the report in connect.ts must not describe
|
|
6
|
+
* the same connection differently, and the only way to be sure of that is for
|
|
7
|
+
* both to call this. It also makes the arithmetic testable against a fixed
|
|
8
|
+
* clock, which is the half that is easy to get wrong and hard to notice.
|
|
9
|
+
*
|
|
10
|
+
* The fact being reasoned about is #94: Google expires refresh tokens issued
|
|
11
|
+
* by an app in Testing publishing status after seven days. Nothing here knows
|
|
12
|
+
* that number — it arrives with the connection, from the deployment (see
|
|
13
|
+
* `statedExpiry`), so this file stays true when the app is verified.
|
|
14
|
+
*/
|
|
15
|
+
/** Where a provider's own page for withdrawing an app's access lives. */
|
|
16
|
+
const CONNECTED_APPS = {
|
|
17
|
+
google: 'https://myaccount.google.com/connections',
|
|
18
|
+
microsoft: 'https://account.live.com/consent/Manage',
|
|
19
|
+
};
|
|
20
|
+
/** The one command that fixes any of this, per provider. */
|
|
21
|
+
export const reconnectCommand = (provider) => `artist-mcp connect ${provider}`;
|
|
22
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
/**
|
|
24
|
+
* The two directions round opposite ways, because the honest answer differs.
|
|
25
|
+
*
|
|
26
|
+
* Time remaining rounds **up**: a connection with eleven hours left reads as
|
|
27
|
+
* "1 day", never "0 days", since reporting zero days left on something that
|
|
28
|
+
* still works teaches someone to ignore the line.
|
|
29
|
+
*
|
|
30
|
+
* Time elapsed rounds **down**: something that lapsed two days and three hours
|
|
31
|
+
* ago is "2 days ago", not "3". Overstating how long a connection has been
|
|
32
|
+
* dead is a small lie in the one place the user is checking the facts.
|
|
33
|
+
*/
|
|
34
|
+
const daysLeftFrom = (from, to) => Math.max(1, Math.ceil((to - from) / DAY_MS));
|
|
35
|
+
const daysSince = (from, to) => Math.floor((to - from) / DAY_MS);
|
|
36
|
+
export const expiryState = (tokens, now = new Date()) => {
|
|
37
|
+
const { expiresAt } = tokens;
|
|
38
|
+
if (expiresAt === undefined)
|
|
39
|
+
return { kind: 'unlimited' };
|
|
40
|
+
const at = Date.parse(expiresAt);
|
|
41
|
+
// A stored date that will not parse is treated as no limit rather than as
|
|
42
|
+
// an expiry. A connection wrongly declared dead is worse than one whose
|
|
43
|
+
// warning is missing: the second still works.
|
|
44
|
+
if (Number.isNaN(at))
|
|
45
|
+
return { kind: 'unlimited' };
|
|
46
|
+
const ms = now.getTime();
|
|
47
|
+
return ms >= at
|
|
48
|
+
? { kind: 'lapsed', daysAgo: daysSince(at, ms), expiresAt }
|
|
49
|
+
: { kind: 'valid', daysLeft: daysLeftFrom(ms, at), expiresAt };
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The line `status` prints, or nothing when there is nothing to say.
|
|
53
|
+
*
|
|
54
|
+
* Silent while the limit is comfortably far off. A warning shown every single
|
|
55
|
+
* day is read as decoration by the third day, and the whole point of this is to
|
|
56
|
+
* be believed on the day it matters.
|
|
57
|
+
*/
|
|
58
|
+
export const expiryNotice = (provider, tokens, now = new Date()) => {
|
|
59
|
+
const state = expiryState(tokens, now);
|
|
60
|
+
if (state.kind === 'lapsed') {
|
|
61
|
+
// Under a day is "today" rather than "0 days ago", which reads as a bug.
|
|
62
|
+
const when = state.daysAgo === 0 ? 'today' : `${state.daysAgo} day${state.daysAgo === 1 ? '' : 's'} ago`;
|
|
63
|
+
return `LAPSED ${when}. Reconnect: ${reconnectCommand(provider)}`;
|
|
64
|
+
}
|
|
65
|
+
if (state.kind === 'valid' && state.daysLeft <= 2) {
|
|
66
|
+
return (`expires in ${state.daysLeft} day${state.daysLeft === 1 ? '' : 's'}. ` +
|
|
67
|
+
`Reconnect any time: ${reconnectCommand(provider)}`);
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* What to say at the end of a successful connect.
|
|
73
|
+
*
|
|
74
|
+
* Said here rather than discovered later, which is the entire complaint in #94:
|
|
75
|
+
* the connection simply stopped, the error advised a reconnect, the advice was
|
|
76
|
+
* right, and the cause stayed invisible for as long as anyone cared to look.
|
|
77
|
+
*/
|
|
78
|
+
export const expiryAtConnect = (provider, expiresAt, now = new Date()) => {
|
|
79
|
+
if (expiresAt === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
const state = expiryState({ refreshToken: '', scope: '', connectedAt: '', expiresAt }, now);
|
|
82
|
+
if (state.kind !== 'valid')
|
|
83
|
+
return undefined;
|
|
84
|
+
return (`This connection lapses in ${state.daysLeft} days, on ${state.expiresAt.slice(0, 10)}. ` +
|
|
85
|
+
`That is a limit ${provider === 'google' ? 'Google' : 'the provider'} applies to this ` +
|
|
86
|
+
`app, not a fault, and reconnecting takes one command: ${reconnectCommand(provider)}`);
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Why a refresh was rejected, inferred from the connection's own age.
|
|
90
|
+
*
|
|
91
|
+
* The provider cannot tell us: Google answers `invalid_grant` with "Token has
|
|
92
|
+
* been expired or revoked" whether it lapsed on schedule or someone withdrew
|
|
93
|
+
* the app in their account settings. Those are opposite situations — one is
|
|
94
|
+
* expected and one is worth investigating — so the distinction is drawn here,
|
|
95
|
+
* from the expiry the connection recorded, and worded as the inference it is.
|
|
96
|
+
* Nothing below claims the provider said any of it.
|
|
97
|
+
*/
|
|
98
|
+
export const explainRefreshFailure = (provider, tokens, now = new Date()) => {
|
|
99
|
+
const state = expiryState(tokens, now);
|
|
100
|
+
const fix = `Reconnect with: ${reconnectCommand(provider)}`;
|
|
101
|
+
if (state.kind === 'lapsed') {
|
|
102
|
+
return (`This connection reached the ${state.expiresAt.slice(0, 10)} limit this app is ` +
|
|
103
|
+
`subject to, so it most likely expired on schedule rather than anything ` +
|
|
104
|
+
`going wrong. ${fix}`);
|
|
105
|
+
}
|
|
106
|
+
if (state.kind === 'valid') {
|
|
107
|
+
return (`This is earlier than the ${state.expiresAt.slice(0, 10)} limit this connection ` +
|
|
108
|
+
`was given, so something withdrew it rather than it lapsing — check whether ` +
|
|
109
|
+
`the app was removed at ${CONNECTED_APPS[provider]}. ${fix}`);
|
|
110
|
+
}
|
|
111
|
+
return fix;
|
|
112
|
+
};
|