@rockhopper-co/mcp-server 2.0.1 → 2.1.1-staging.2786a25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +2 -1
  2. package/dist/api-client.d.ts +136 -2
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +185 -2
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/capabilities.d.ts +5 -6
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +6 -9
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/drive-search.d.ts +182 -0
  11. package/dist/drive-search.d.ts.map +1 -0
  12. package/dist/drive-search.js +210 -0
  13. package/dist/drive-search.js.map +1 -0
  14. package/dist/enrollment.d.ts +121 -0
  15. package/dist/enrollment.d.ts.map +1 -0
  16. package/dist/enrollment.js +247 -0
  17. package/dist/enrollment.js.map +1 -0
  18. package/dist/prompts/index.d.ts.map +1 -1
  19. package/dist/prompts/index.js +3 -2
  20. package/dist/prompts/index.js.map +1 -1
  21. package/dist/resources/orchestration-guide.md +62 -8
  22. package/dist/tools/connect-microsoft.d.ts +21 -0
  23. package/dist/tools/connect-microsoft.d.ts.map +1 -0
  24. package/dist/tools/connect-microsoft.js +138 -0
  25. package/dist/tools/connect-microsoft.js.map +1 -0
  26. package/dist/tools/drive-search-lanes.d.ts +65 -0
  27. package/dist/tools/drive-search-lanes.d.ts.map +1 -0
  28. package/dist/tools/drive-search-lanes.js +95 -0
  29. package/dist/tools/drive-search-lanes.js.map +1 -0
  30. package/dist/tools/drive-search.contract.d.ts +165 -0
  31. package/dist/tools/drive-search.contract.d.ts.map +1 -0
  32. package/dist/tools/drive-search.contract.js +376 -0
  33. package/dist/tools/drive-search.contract.js.map +1 -0
  34. package/dist/tools/drive-search.d.ts +31 -0
  35. package/dist/tools/drive-search.d.ts.map +1 -0
  36. package/dist/tools/drive-search.js +236 -0
  37. package/dist/tools/drive-search.js.map +1 -0
  38. package/dist/tools/enroll-file.contract.d.ts +52 -0
  39. package/dist/tools/enroll-file.contract.d.ts.map +1 -0
  40. package/dist/tools/enroll-file.contract.js +105 -0
  41. package/dist/tools/enroll-file.contract.js.map +1 -0
  42. package/dist/tools/enroll-file.d.ts +4 -0
  43. package/dist/tools/enroll-file.d.ts.map +1 -0
  44. package/dist/tools/enroll-file.js +184 -0
  45. package/dist/tools/enroll-file.js.map +1 -0
  46. package/dist/tools/get-versions.d.ts.map +1 -1
  47. package/dist/tools/get-versions.js +6 -1
  48. package/dist/tools/get-versions.js.map +1 -1
  49. package/dist/tools/index.d.ts.map +1 -1
  50. package/dist/tools/index.js +12 -0
  51. package/dist/tools/index.js.map +1 -1
  52. package/dist/tools/search.d.ts.map +1 -1
  53. package/dist/tools/search.js +25 -2
  54. package/dist/tools/search.js.map +1 -1
  55. package/dist/tools/write-files.d.ts.map +1 -1
  56. package/dist/tools/write-files.js +5 -0
  57. package/dist/tools/write-files.js.map +1 -1
  58. package/dist/tools/write-versions.d.ts.map +1 -1
  59. package/dist/tools/write-versions.js +2 -1
  60. package/dist/tools/write-versions.js.map +1 -1
  61. package/dist/types.d.ts +150 -0
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/version-format.d.ts +32 -0
  64. package/dist/version-format.d.ts.map +1 -0
  65. package/dist/version-format.js +35 -0
  66. package/dist/version-format.js.map +1 -0
  67. package/package.json +2 -2
@@ -0,0 +1,376 @@
1
+ /**
2
+ * ENG-2204 — everything `search_drive_files` SAYS, split from everything it
3
+ * DOES (`drive-search.ts`), on the seam ENG-2200 cut for `enroll_file`.
4
+ *
5
+ * This half is the only interface a language model ever sees: it cannot read
6
+ * the handler, so the tool description and the schema descriptions ARE the
7
+ * contract, and a wrong word here is a behaviour bug no type checks. Keeping
8
+ * it apart means a person can review it as prose — deciding whether a model
9
+ * would do the right thing — without the lane branching in the way.
10
+ */
11
+ import { z } from 'zod';
12
+ import { DRIVE_SEARCH_SESSION_BUDGET, } from '../drive-search.js';
13
+ export const DRIVE_SEARCH_INPUT_SCHEMA = z.object({
14
+ query: z
15
+ .string()
16
+ .optional()
17
+ .describe('Part of the file name to look for, in the user\'s own words — "Q3 ' +
18
+ 'forecast", "Becklar model". Required unless `scope` is "recent".'),
19
+ scope: z
20
+ .enum(['search', 'recent'])
21
+ .optional()
22
+ .describe('"search" (default) looks across everything the user can reach in ' +
23
+ 'OneDrive and SharePoint. "recent" lists the workbooks they have ' +
24
+ 'worked on lately and needs no `query` — use it when the user cannot ' +
25
+ 'remember the name.'),
26
+ limit: z
27
+ .number()
28
+ .int()
29
+ .min(1)
30
+ .max(50)
31
+ .optional()
32
+ .describe('How many candidates to return. Default 10, maximum 50.'),
33
+ /**
34
+ * The confirmation lane every client has. `confirm_index` is how a client
35
+ * with no elicitation and no multi-round-trip support closes the loop: the
36
+ * model puts the question to the user in the conversation, and calls again
37
+ * carrying the number the user chose.
38
+ *
39
+ * It is an INDEX into a set this session offered, not a file identifier, and
40
+ * that is the whole point. A model that has read a malicious file name
41
+ * cannot invent a `driveMsId` here and have the tool bless it: the index is
42
+ * resolved against server memory, and an index into a set that was never
43
+ * offered resolves to nothing.
44
+ */
45
+ confirm_index: z
46
+ .number()
47
+ .int()
48
+ .optional()
49
+ .describe('The number of the candidate the USER picked from a previous ' +
50
+ '`search_drive_files` answer, after you asked them which file they ' +
51
+ 'meant. Send it together with `confirm_token` from that same answer. ' +
52
+ 'Send 0 if they said none of them is the right file. ' +
53
+ 'Never guess it and never pick on the user\'s behalf.'),
54
+ confirm_token: z
55
+ .string()
56
+ .optional()
57
+ .describe('The `confirm_token` from the answer whose list the user picked from. ' +
58
+ 'Pass it back exactly as it was given.'),
59
+ });
60
+ export const DRIVE_SEARCH_DESCRIPTION = "Find a Microsoft Excel workbook in the user's own OneDrive or SharePoint — " +
61
+ 'including files Rockhopper has never seen. Each candidate comes back marked ' +
62
+ 'as already in Rockhopper or not. ' +
63
+ 'USE THIS when the user names a workbook that `search_files` and ' +
64
+ '`list_files` cannot find: those two see only files already added to ' +
65
+ 'Rockhopper, so "no match" there means "never added", not "no such file". ' +
66
+ 'YOU MUST CONFIRM THE FILE WITH THE USER before enrolling it. Show them the ' +
67
+ 'candidates, ask which one they mean, then call this tool again with their ' +
68
+ 'pick as `confirm_index` plus the `confirm_token`; the answer carries the ' +
69
+ 'identifiers to hand to `enroll_file`. Never enroll a file the user has not ' +
70
+ 'named, and never claim a file is already in Rockhopper because one search ' +
71
+ 'result looked similar. ' +
72
+ 'Read the outcome rather than assuming: `declined` means the user saw the ' +
73
+ 'candidates and rejected them, `dismissed` means the prompt closed with no ' +
74
+ 'answer at all — put the list to them yourself and do not search again — ' +
75
+ 'and `link_supplied` means they pasted the workbook address, which goes to ' +
76
+ '`enroll_file` as `url` on its own. ' +
77
+ 'Searching is capped per session, so search deliberately rather than ' +
78
+ 'browsing. Requires a connected Microsoft account — if none is connected, ' +
79
+ 'this returns the link the user must open.';
80
+ export const DRIVE_SEARCH_ANNOTATIONS = {
81
+ readOnlyHint: true,
82
+ // It reaches SharePoint / OneDrive through Rockhopper.
83
+ openWorldHint: true,
84
+ };
85
+ /**
86
+ * Render an answer as a tool result: prose for the human, then one JSON line
87
+ * for the model. Both, because prose alone invites the model to paraphrase a
88
+ * refusal into a success, and JSON alone gives the user nothing to read.
89
+ * Same shape as `enroll_file`'s, so the two tools read alike in a transcript.
90
+ */
91
+ export function toolResult(answer) {
92
+ const payload = { outcome: answer.outcome, ...(answer.detail ?? {}) };
93
+ return {
94
+ content: [
95
+ {
96
+ type: 'text',
97
+ text: `${answer.text}\n${JSON.stringify(payload)}`,
98
+ },
99
+ ],
100
+ ...(answer.isError ? { isError: true } : {}),
101
+ };
102
+ }
103
+ /**
104
+ * How one candidate reads to a user, without its identifiers.
105
+ *
106
+ * ENG-2787 — the enrolment marking comes FIRST, and that ordering is the whole
107
+ * fix. The flag was already on the wire; the client truncated it off the END of
108
+ * the row, so ten candidates rendered as
109
+ * `RE_Forecast.xlsx · /drives/b!YmtA8jQX3OSHJRQ88O…` and the tool
110
+ * description's promise that "each candidate comes back marked as already in
111
+ * Rockhopper or not" was true of the payload and false of the screen. A marking
112
+ * whose position depends on the length of a customer's file name is not
113
+ * visible; one at position zero always is.
114
+ *
115
+ * `lastModifiedAt` and `parentPath` are dropped for the same reason they did
116
+ * harm: an ISO timestamp and a raw `/drives/b!…` id are forty characters
117
+ * nobody can read, sitting in front of the one phrase that decides whether
118
+ * this row is worth picking. Both still appear in the prompt's own message,
119
+ * which wraps instead of truncating.
120
+ */
121
+ export function candidateLabel(candidate) {
122
+ return `${ENROLLMENT_NOTE[candidate.enrollmentState]} · ${candidate.name}`;
123
+ }
124
+ /**
125
+ * The same facts MINUS the name, for the places that print the name already.
126
+ *
127
+ * A picker row has to stand alone, so {@link candidateLabel} repeats the name;
128
+ * prose that has just written `**Becklar_RMR_Model.xlsx**` has not.
129
+ */
130
+ function candidateNote(candidate) {
131
+ return [ENROLLMENT_NOTE[candidate.enrollmentState]]
132
+ .concat(candidate.parentPath ?? [])
133
+ .join(' · ');
134
+ }
135
+ /**
136
+ * What each enrolment state MEANS to the person choosing, in their words.
137
+ *
138
+ * `hidden` is spelled out rather than folded into "already added" because the
139
+ * two lead to different conversations: one is done, and the other needs the
140
+ * user to say whether they want a file they removed put back. Collapsing them
141
+ * is the ENG-1647 answer — "already enrolled" about a file the user cannot see.
142
+ */
143
+ const ENROLLMENT_NOTE = {
144
+ enrolled: 'already in Rockhopper',
145
+ hidden: 'previously removed from Rockhopper',
146
+ not_enrolled: 'not in Rockhopper yet',
147
+ };
148
+ /** The numbered list the user picks from, and the model quotes back. */
149
+ export function renderCandidates(candidates) {
150
+ return candidates
151
+ .map((c, index) => `${index + 1}. **${c.name}** — ${candidateNote(c)}`)
152
+ .join('\n');
153
+ }
154
+ /** The question the model puts to the user when no richer lane exists. */
155
+ export function confirmationPrompt(candidates, token) {
156
+ return (`Found ${candidates.length} possible file(s). ` +
157
+ 'DO NOT enroll any of these yet.\n\n' +
158
+ `${renderCandidates(candidates)}\n\n` +
159
+ 'Ask the user which one they meant — quote the names, do not choose for ' +
160
+ 'them. When they answer, call `search_drive_files` again with ' +
161
+ `confirm_index: <their number> and confirm_token: "${token}", or ` +
162
+ 'confirm_index: 0 if none of these is the file.');
163
+ }
164
+ /**
165
+ * What the user READS before they touch anything (ENG-2789).
166
+ *
167
+ * The candidates are written into the message because the message is the only
168
+ * part of an elicitation this client renders unprompted. The prompt David
169
+ * photographed on 2026-08-19 showed exactly two things on screen — this
170
+ * sentence, and a field collapsed to the words `not set` — while ten workbooks
171
+ * sat behind a `→` documented in a footnote under the buttons. Three
172
+ * consecutive real attempts returned `declined` from a prompt whose options
173
+ * were never on screen.
174
+ *
175
+ * So the list is prose now, and the field is a way to answer a question the
176
+ * user has already read rather than the only place the question exists.
177
+ */
178
+ function pickerMessage(candidates) {
179
+ return (`Which of these ${candidates.length} workbooks did you mean? ` +
180
+ 'Rockhopper will not add anything until you say.\n\n' +
181
+ `${renderCandidates(candidates)}\n\n` +
182
+ 'Pick one by number in the File field below.\n\n' +
183
+ 'If none of them is the right file, you can paste the workbook\'s own ' +
184
+ 'link instead: open it in your browser and copy the address from the bar ' +
185
+ 'into the Link field. That names the file exactly, so nothing has to be ' +
186
+ 'guessed from its name.');
187
+ }
188
+ /**
189
+ * The elicitation form both richer lanes send.
190
+ *
191
+ * NOTHING IS REQUIRED, and that is deliberate (ENG-2789). A required `choice`
192
+ * is what turned an unreadable prompt into a trap: the only visible buttons
193
+ * were Accept and Decline, and Accept was REFUSED because the invisible field
194
+ * was unset, leaving Decline as the sole affordance that did anything. An
195
+ * unanswered form now comes back as `dismissed` — a state the tool can name
196
+ * and act on — instead of a button the user is herded into pressing.
197
+ */
198
+ export function confirmationForm(candidates) {
199
+ return {
200
+ message: pickerMessage(candidates),
201
+ requestedSchema: {
202
+ type: 'object',
203
+ properties: {
204
+ // The values are POSITIONS, never identifiers. A client (or anything
205
+ // steering it) can only send back a number, and a number that does not
206
+ // index a set this session offered resolves to nothing.
207
+ choice: {
208
+ type: 'string',
209
+ enum: [
210
+ ...candidates.map((_, index) => String(index + 1)),
211
+ DECLINE_CHOICE,
212
+ ],
213
+ // Numbered so a row the client truncates is still resolvable
214
+ // against the list in the message above it.
215
+ enumNames: [
216
+ ...candidates.map((c, index) => `${index + 1}. ${candidateLabel(c)}`),
217
+ 'None of these',
218
+ ],
219
+ title: 'File',
220
+ description: 'The workbook to add to Rockhopper.',
221
+ },
222
+ // ENG-2784. The link route used to be spoken only to the MODEL, in the
223
+ // text returned after the user had already given up: whether they ever
224
+ // heard it depended on the assistant choosing to relay it. A recovery
225
+ // path that needs the agent to speak IS the defect, so it is a field on
226
+ // the prompt the user is already looking at.
227
+ link: {
228
+ type: 'string',
229
+ title: 'Link',
230
+ description: 'Or paste the workbook\'s address here — open it in your browser ' +
231
+ 'and copy the address from the bar.',
232
+ },
233
+ },
234
+ },
235
+ };
236
+ }
237
+ /** The answer that means "none of these" rather than a position. */
238
+ export const DECLINE_CHOICE = 'none';
239
+ /**
240
+ * The answer once a specific candidate has been confirmed by a human.
241
+ *
242
+ * It hands over identifiers and stops. Enrolling here would mean a second copy
243
+ * of `enroll_file`'s logic — the share_with question, the hidden-file restore
244
+ * — living in a read tool, and two copies of a rule is how the two answers
245
+ * start to differ.
246
+ */
247
+ export function confirmedAnswer(candidate) {
248
+ if (!candidate.driveMsId) {
249
+ return toolResult({
250
+ outcome: 'unknown_candidate',
251
+ isError: true,
252
+ text: `Microsoft did not say which drive "${candidate.name}" lives in, so ` +
253
+ 'it cannot be added from the search result. Ask the user to open the ' +
254
+ 'workbook and paste the address from their browser bar, then call ' +
255
+ '`enroll_file` with that `url`.',
256
+ });
257
+ }
258
+ return toolResult({
259
+ outcome: 'confirmed',
260
+ text: `The user confirmed "${candidate.name}" (${candidateNote(candidate)}). ` +
261
+ 'Now call `enroll_file` with the `driveMsId` and `msId` below. ' +
262
+ '`enroll_file` will ask who may see the file — put that question to the ' +
263
+ 'user too, and never answer it yourself.',
264
+ detail: {
265
+ name: candidate.name,
266
+ driveMsId: candidate.driveMsId,
267
+ msId: candidate.msId,
268
+ enrollmentState: candidate.enrollmentState,
269
+ },
270
+ });
271
+ }
272
+ /** The answer when the user was asked and said none of these. */
273
+ export function declinedAnswer() {
274
+ return toolResult({
275
+ outcome: 'declined',
276
+ text: 'The user looked at the candidates and said none of them is the file, ' +
277
+ 'so nothing was added. Ask them to describe the workbook differently ' +
278
+ 'and search again, or to paste its SharePoint or OneDrive link for ' +
279
+ '`enroll_file`.',
280
+ });
281
+ }
282
+ /**
283
+ * The answer when the prompt closed without the user answering it (ENG-2789).
284
+ *
285
+ * Its own outcome and not a flavour of `declined`, because the two say opposite
286
+ * things about what the user knows. `declined` means they read ten workbooks
287
+ * and none was theirs — searching again with a different word is the right next
288
+ * move. This means they never told us anything, and the honest next move is to
289
+ * put the same list to them in the conversation, where it cannot be collapsed.
290
+ * Folding them together is what let three unreadable prompts report themselves
291
+ * as a decision the user had made.
292
+ */
293
+ export function dismissedAnswer(candidates) {
294
+ const list = candidates?.length
295
+ ? `\n\n${renderCandidates(candidates)}\n\n`
296
+ : ' ';
297
+ return toolResult({
298
+ outcome: 'dismissed',
299
+ text: 'The user closed the file picker without answering it, so nothing was ' +
300
+ 'added and they have told us nothing about which file they meant. Do ' +
301
+ 'NOT treat this as a rejection and do not search again yet.' +
302
+ list +
303
+ 'List these candidates in the conversation, ask which one they meant, ' +
304
+ 'and say they can paste the workbook\'s OneDrive or SharePoint link ' +
305
+ 'instead if none is right.',
306
+ });
307
+ }
308
+ /**
309
+ * The answer when the user pasted a link rather than picking a row (ENG-2784).
310
+ *
311
+ * The URL is the user's own typing, from a field only they can fill — it is
312
+ * not a model-chosen argument, and it is handed straight to `enroll_file`,
313
+ * which is the tool that resolves and validates it. Nothing here tries to
314
+ * parse it: `enroll_file`'s own description calls a pasted address "the only
315
+ * input that names one specific file with no guessing", and re-deriving that
316
+ * judgement in a second place is how two answers start to differ.
317
+ */
318
+ export function linkSuppliedAnswer(url) {
319
+ return toolResult({
320
+ outcome: 'link_supplied',
321
+ text: 'The user did not pick from the list. They pasted the workbook\'s own ' +
322
+ 'address instead, which names the file exactly. Call `enroll_file` with ' +
323
+ 'the `url` below and nothing else — no `driveMsId`, no `msId`. ' +
324
+ '`enroll_file` will ask who may see the file; put that question to the ' +
325
+ 'user too, and never answer it yourself.',
326
+ detail: { url },
327
+ });
328
+ }
329
+ /**
330
+ * A pasted value this tool will pass on, or `null`.
331
+ *
332
+ * The only check is that it is an http(s) address, and it exists so an empty
333
+ * box or a stray keystroke does not get announced to the model as "the user
334
+ * gave us the file". Anything further — which tenant, which drive, whether the
335
+ * file exists — is `enroll_file`'s to decide against Microsoft, not this
336
+ * tool's to guess from a string.
337
+ */
338
+ export function usableLink(raw) {
339
+ if (typeof raw !== 'string')
340
+ return null;
341
+ const trimmed = raw.trim();
342
+ if (!trimmed)
343
+ return null;
344
+ try {
345
+ const { protocol } = new URL(trimmed);
346
+ return protocol === 'https:' || protocol === 'http:' ? trimmed : null;
347
+ }
348
+ catch {
349
+ return null;
350
+ }
351
+ }
352
+ /** The answer when a confirmation names something this session never offered. */
353
+ export function unknownCandidateAnswer() {
354
+ return toolResult({
355
+ outcome: 'unknown_candidate',
356
+ isError: true,
357
+ text: 'That pick does not match any file this search offered, so nothing was ' +
358
+ 'added. Run `search_drive_files` again and confirm against the list it ' +
359
+ 'returns. Never enroll a file that did not come back from a search.',
360
+ });
361
+ }
362
+ /** Told to the model when the session has searched as much as it may. */
363
+ export const BUDGET_EXHAUSTED_TEXT = `This session has used all ${DRIVE_SEARCH_SESSION_BUDGET} of its file ` +
364
+ 'searches. This is a fixed limit and waiting does not restore it. Stop ' +
365
+ "searching and ask the user to paste the workbook's SharePoint or OneDrive " +
366
+ 'link, then call `enroll_file` with that link. Nothing was searched.';
367
+ /** Told to the model when the user has no delegated Microsoft grant. */
368
+ export function connectPrompt(authorizeUrl, expiresAt) {
369
+ return ('Rockhopper cannot look at this user\'s Microsoft files until they ' +
370
+ 'connect their Microsoft account. Give them this link to open ' +
371
+ `themselves — it expires at ${expiresAt}:\n\n${authorizeUrl}\n\n` +
372
+ 'Rockhopper asks only to READ their files. Once they have approved, call ' +
373
+ '`microsoft_link_status` to confirm, then search again. Do not compose a ' +
374
+ 'sign-in link yourself; this one is the only valid one.');
375
+ }
376
+ //# sourceMappingURL=drive-search.contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drive-search.contract.js","sourceRoot":"","sources":["../../src/tools/drive-search.contract.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ import { type InputRequiredResult, type McpServer } from '@modelcontextprotocol/server';
2
+ import type { ApiClient } from '../api-client.js';
3
+ import { CandidateRegistry, SearchBudget } from '../drive-search.js';
4
+ /**
5
+ * ENG-2204 (SP08) — the half of ENG-1647 that ENG-2200 did not fix.
6
+ *
7
+ * ENG-2200 built the enroll tool the customer could not find. This finds the
8
+ * FILE: a workbook Rockhopper has never seen, in the user's own OneDrive or
9
+ * SharePoint, confirmed with them before anything is written.
10
+ *
11
+ * Three things here are load-bearing and none is obvious from the happy path:
12
+ *
13
+ * 1. **The search set IS the permission trim.** Every candidate came back from
14
+ * a delegated Microsoft call made as this user. Nothing is added to it,
15
+ * nothing is inferred into it, and no file outside it is ever offered.
16
+ * 2. **A confirmation resolves against SERVER memory, never against what the
17
+ * client sent.** The confirmation carries a position and a nonce; the file
18
+ * it names is looked up in the set this session actually returned. A model
19
+ * that has read a hostile file name cannot conjure a `driveMsId` and have
20
+ * the tool bless it.
21
+ * 3. **The budget is claimed BEFORE the network call.** A cap that discards an
22
+ * answer already fetched has not capped anything — the enumeration already
23
+ * happened, and Microsoft already answered it.
24
+ */
25
+ export declare function registerDriveSearchTool(server: McpServer, api: ApiClient, session?: {
26
+ budget?: SearchBudget;
27
+ registry?: CandidateRegistry;
28
+ }): void;
29
+ /** Re-exported so `InputRequiredResult` is nameable where the tool is used. */
30
+ export type { InputRequiredResult };
31
+ //# sourceMappingURL=drive-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drive-search.d.ts","sourceRoot":"","sources":["../../src/tools/drive-search.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,mBAAmB,EACxB,KAAK,SAAS,EAEf,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EACL,iBAAiB,EACjB,YAAY,EAOb,MAAM,oBAAoB,CAAC;AAyB5B;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,SAAS,EACjB,GAAG,EAAE,SAAS,EACd,OAAO,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAA;CAAE,GAChE,IAAI,CAwMN;AAiCD,+EAA+E;AAC/E,YAAY,EAAE,mBAAmB,EAAE,CAAC"}
@@ -0,0 +1,236 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { inputRequired, inputResponse, } from '@modelcontextprotocol/server';
3
+ import { CandidateRegistry, SearchBudget, classifyDriveSearchFailure, ADMIN_CONSENT_TEXT, isAdminConsentRequired, isNoDelegatedToken, toCandidate, } from '../drive-search.js';
4
+ import { log } from '../logger.js';
5
+ import { BUDGET_EXHAUSTED_TEXT, DECLINE_CHOICE, DRIVE_SEARCH_ANNOTATIONS, DRIVE_SEARCH_DESCRIPTION, DRIVE_SEARCH_INPUT_SCHEMA, confirmationForm, confirmationPrompt, confirmedAnswer, connectPrompt, declinedAnswer, dismissedAnswer, linkSuppliedAnswer, toolResult, unknownCandidateAnswer, usableLink, } from './drive-search.contract.js';
6
+ import { CONFIRM_KEY, readRequestState, selectLane, } from './drive-search-lanes.js';
7
+ /**
8
+ * ENG-2204 (SP08) — the half of ENG-1647 that ENG-2200 did not fix.
9
+ *
10
+ * ENG-2200 built the enroll tool the customer could not find. This finds the
11
+ * FILE: a workbook Rockhopper has never seen, in the user's own OneDrive or
12
+ * SharePoint, confirmed with them before anything is written.
13
+ *
14
+ * Three things here are load-bearing and none is obvious from the happy path:
15
+ *
16
+ * 1. **The search set IS the permission trim.** Every candidate came back from
17
+ * a delegated Microsoft call made as this user. Nothing is added to it,
18
+ * nothing is inferred into it, and no file outside it is ever offered.
19
+ * 2. **A confirmation resolves against SERVER memory, never against what the
20
+ * client sent.** The confirmation carries a position and a nonce; the file
21
+ * it names is looked up in the set this session actually returned. A model
22
+ * that has read a hostile file name cannot conjure a `driveMsId` and have
23
+ * the tool bless it.
24
+ * 3. **The budget is claimed BEFORE the network call.** A cap that discards an
25
+ * answer already fetched has not capped anything — the enumeration already
26
+ * happened, and Microsoft already answered it.
27
+ */
28
+ export function registerDriveSearchTool(server, api, session) {
29
+ // Per-server, never per-module: two servers in one process (a test file, a
30
+ // gateway serving two sessions) must not share a ceiling, and a module-level
31
+ // counter is exactly the shape that silently does.
32
+ const budget = session?.budget ?? new SearchBudget();
33
+ const registry = session?.registry ?? new CandidateRegistry();
34
+ /**
35
+ * Resolve a (nonce, position) pair against what this session offered.
36
+ *
37
+ * Position, never identifier — the only thing a client can send is a number,
38
+ * and a number that does not index a set this session returned resolves to
39
+ * nothing. `0` and the elicitation form's "none of these" are the same
40
+ * answer: the user was asked and chose no file.
41
+ */
42
+ function resolvePick(nonce, choice) {
43
+ if (choice === DECLINE_CHOICE || choice === 0)
44
+ return declinedAnswer();
45
+ if (!nonce)
46
+ return unknownCandidateAnswer();
47
+ const position = typeof choice === 'number' ? choice : Number(choice);
48
+ const candidate = registry.resolve(nonce, position - 1);
49
+ return candidate ? confirmedAnswer(candidate) : unknownCandidateAnswer();
50
+ }
51
+ /** The set this session offered under `nonce`, for re-asking in prose. */
52
+ function offered(nonce) {
53
+ return (nonce ? registry.recall(nonce) : null) ?? undefined;
54
+ }
55
+ /**
56
+ * Read a filled-in elicitation form.
57
+ *
58
+ * Order matters and it is not the obvious one: a pasted LINK beats a picked
59
+ * row. The link is the user typing the file's own address, which
60
+ * `enroll_file` calls the only input that names one file with no guessing,
61
+ * while a row is a position in a list a search produced. If someone took the
62
+ * trouble to paste an address, that is the file they mean.
63
+ *
64
+ * An EMPTY form is `dismissed`, never `declined`. Nothing is required any
65
+ * more (ENG-2789), so Accept with nothing filled in is a reachable state, and
66
+ * it means the user answered nothing — which is a different fact from "none
67
+ * of these is my file" and leads to a different next move.
68
+ */
69
+ function resolveForm(nonce, content) {
70
+ const filled = content;
71
+ const link = usableLink(filled?.link);
72
+ if (link)
73
+ return linkSuppliedAnswer(link);
74
+ const choice = filled?.choice;
75
+ if (choice === undefined || choice === '')
76
+ return dismissedAnswer(offered(nonce));
77
+ return resolvePick(nonce, choice);
78
+ }
79
+ /**
80
+ * Put the question through the best lane this request can reach.
81
+ *
82
+ * Lane 2 is awaited INSIDE the call and lane 3 returns out of it; both end
83
+ * at the same three answers, and lane 1 hands the question to the model.
84
+ */
85
+ async function ask(ctx, candidates, nonce) {
86
+ const lane = selectLane(ctx, server.server?.getClientCapabilities?.());
87
+ const form = confirmationForm(candidates);
88
+ if (lane === 'input_required') {
89
+ return inputRequired({
90
+ inputRequests: { [CONFIRM_KEY]: inputRequired.elicit(form) },
91
+ requestState: nonce,
92
+ });
93
+ }
94
+ if (lane === 'elicitation' && ctx?.mcpReq?.elicitInput) {
95
+ try {
96
+ const answer = await ctx.mcpReq.elicitInput({ ...form, mode: 'form' });
97
+ if (answer?.action === 'accept')
98
+ return resolveForm(nonce, answer.content);
99
+ // Declined or cancelled. Neither is an error — a person is allowed to
100
+ // close a prompt — and neither is `declined` either: the user pressed a
101
+ // button, they did not tell us anything about these ten files. Saying
102
+ // "the user rejected the candidates" here is the ENG-2789 failure, and
103
+ // it is how three unreadable prompts reported themselves as decisions.
104
+ return dismissedAnswer(candidates);
105
+ }
106
+ catch {
107
+ // The client advertised elicitation and could not deliver it. Falling
108
+ // back to the universal lane keeps the question answerable instead of
109
+ // turning a capability mismatch into a dead end.
110
+ }
111
+ }
112
+ return toolResult({
113
+ outcome: 'candidates',
114
+ text: confirmationPrompt(candidates, nonce),
115
+ detail: { confirmToken: nonce, candidateCount: candidates.length },
116
+ });
117
+ }
118
+ server.registerTool('search_drive_files', {
119
+ title: "Find a File in the User's Microsoft Drive",
120
+ description: DRIVE_SEARCH_DESCRIPTION,
121
+ inputSchema: DRIVE_SEARCH_INPUT_SCHEMA,
122
+ annotations: DRIVE_SEARCH_ANNOTATIONS,
123
+ }, async ({ query, scope, limit, confirm_index, confirm_token }, ctx) => {
124
+ // A retried multi-round-trip request arrives with the user's answer
125
+ // embedded. It is checked FIRST and it never searches again: re-running
126
+ // the query on a retry would spend a second unit of budget and could
127
+ // return a different set than the one the user was looking at.
128
+ const embedded = inputResponse(ctx?.mcpReq?.inputResponses, CONFIRM_KEY);
129
+ if (embedded.kind === 'elicit') {
130
+ const answered = readRequestState(ctx);
131
+ if (embedded.action !== 'accept')
132
+ return dismissedAnswer(offered(answered));
133
+ return resolveForm(answered, embedded.content);
134
+ }
135
+ // The universal lane's confirmation: the model relaying a number the
136
+ // user said out loud. Same resolution, same server-side lookup.
137
+ if (confirm_index !== undefined || confirm_token !== undefined) {
138
+ return resolvePick(confirm_token ?? null, confirm_index ?? NaN);
139
+ }
140
+ if (!budget.claim()) {
141
+ log.warn({
142
+ event: 'drive_search_capped',
143
+ searches: budget.spent,
144
+ budget: budget.ceiling,
145
+ }, 'drive_search_capped');
146
+ return toolResult({
147
+ outcome: 'search_limit_reached',
148
+ isError: true,
149
+ text: BUDGET_EXHAUSTED_TEXT,
150
+ detail: { searchesUsed: budget.spent, searchBudget: budget.ceiling },
151
+ });
152
+ }
153
+ let items;
154
+ try {
155
+ const answer = await api.searchDriveFiles({
156
+ q: query,
157
+ scope,
158
+ limit: limit ?? 10,
159
+ });
160
+ items = answer.items;
161
+ }
162
+ catch (error) {
163
+ // ORDER MATTERS. Both refusals arrive as `NO_DELEGATED_TOKEN`, so
164
+ // checking "not connected" first would swallow the administrator
165
+ // case and hand the user a connect link that Microsoft refuses —
166
+ // which returns them here, with the same link, forever (ENG-2614).
167
+ if (isAdminConsentRequired(error)) {
168
+ return toolResult({
169
+ outcome: 'microsoft_admin_approval_required',
170
+ text: ADMIN_CONSENT_TEXT,
171
+ isError: true,
172
+ });
173
+ }
174
+ if (isNoDelegatedToken(error))
175
+ return connectAnswer(api);
176
+ const { outcome, message } = classifyDriveSearchFailure(error);
177
+ return toolResult({ outcome, text: message, isError: true });
178
+ }
179
+ // Mirrors the backend's own discovery log (`[DRIVE-SEARCH]`): the query
180
+ // LENGTH, never its text, and a count, never a name. Enough to tell an
181
+ // empty probe from a real one while reading an incident, and none of the
182
+ // customer's content.
183
+ log.info({
184
+ event: 'drive_search',
185
+ scope: scope ?? 'search',
186
+ queryLength: query?.length ?? 0,
187
+ results: items.length,
188
+ searches: budget.spent,
189
+ budget: budget.ceiling,
190
+ }, 'drive_search');
191
+ if (items.length === 0) {
192
+ return toolResult({
193
+ outcome: 'no_matches',
194
+ text: 'Microsoft returned no spreadsheets matching that. The search ran ' +
195
+ 'and came back empty — it did not fail. Ask the user to try a ' +
196
+ 'different part of the name, or call again with scope="recent" ' +
197
+ 'to list what they have worked on lately.',
198
+ });
199
+ }
200
+ const candidates = items.map(toCandidate);
201
+ const nonce = randomUUID();
202
+ registry.remember(nonce, candidates);
203
+ return ask(ctx, candidates, nonce);
204
+ });
205
+ }
206
+ /**
207
+ * The `NO_DELEGATED_TOKEN` answer: a link the BACKEND built.
208
+ *
209
+ * The tool takes no authorize URL, no redirect and no client id, and it never
210
+ * asks the model to compose one. An MCP tool's arguments are chosen by a model
211
+ * reading content it did not author, so a caller-supplied sign-in link is a
212
+ * consent-phishing lever — the user would see a genuine Microsoft consent
213
+ * screen the whole way while approving somebody else's application. Same
214
+ * reasoning as `connect_microsoft`, which is where this link comes from.
215
+ */
216
+ async function connectAnswer(api) {
217
+ try {
218
+ const handoff = await api.beginMicrosoftConnect();
219
+ return toolResult({
220
+ outcome: 'microsoft_not_connected',
221
+ text: connectPrompt(handoff.authorizeUrl, handoff.expiresAt),
222
+ detail: { authorizeUrl: handoff.authorizeUrl },
223
+ });
224
+ }
225
+ catch {
226
+ return toolResult({
227
+ outcome: 'microsoft_not_connected',
228
+ isError: true,
229
+ text: 'This user has no connected Microsoft account, and Rockhopper could ' +
230
+ 'not produce a sign-in link just now. Ask them to run ' +
231
+ '`connect_microsoft`, or to paste the workbook link for `enroll_file`. ' +
232
+ 'Do not compose a Microsoft sign-in link yourself.',
233
+ });
234
+ }
235
+ }
236
+ //# sourceMappingURL=drive-search.js.map