@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.
- package/README.md +2 -1
- package/dist/api-client.d.ts +136 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +185 -2
- package/dist/api-client.js.map +1 -1
- package/dist/capabilities.d.ts +5 -6
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +6 -9
- package/dist/capabilities.js.map +1 -1
- package/dist/drive-search.d.ts +182 -0
- package/dist/drive-search.d.ts.map +1 -0
- package/dist/drive-search.js +210 -0
- package/dist/drive-search.js.map +1 -0
- package/dist/enrollment.d.ts +121 -0
- package/dist/enrollment.d.ts.map +1 -0
- package/dist/enrollment.js +247 -0
- package/dist/enrollment.js.map +1 -0
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +3 -2
- package/dist/prompts/index.js.map +1 -1
- package/dist/resources/orchestration-guide.md +62 -8
- package/dist/tools/connect-microsoft.d.ts +21 -0
- package/dist/tools/connect-microsoft.d.ts.map +1 -0
- package/dist/tools/connect-microsoft.js +138 -0
- package/dist/tools/connect-microsoft.js.map +1 -0
- package/dist/tools/drive-search-lanes.d.ts +65 -0
- package/dist/tools/drive-search-lanes.d.ts.map +1 -0
- package/dist/tools/drive-search-lanes.js +95 -0
- package/dist/tools/drive-search-lanes.js.map +1 -0
- package/dist/tools/drive-search.contract.d.ts +165 -0
- package/dist/tools/drive-search.contract.d.ts.map +1 -0
- package/dist/tools/drive-search.contract.js +376 -0
- package/dist/tools/drive-search.contract.js.map +1 -0
- package/dist/tools/drive-search.d.ts +31 -0
- package/dist/tools/drive-search.d.ts.map +1 -0
- package/dist/tools/drive-search.js +236 -0
- package/dist/tools/drive-search.js.map +1 -0
- package/dist/tools/enroll-file.contract.d.ts +52 -0
- package/dist/tools/enroll-file.contract.d.ts.map +1 -0
- package/dist/tools/enroll-file.contract.js +105 -0
- package/dist/tools/enroll-file.contract.js.map +1 -0
- package/dist/tools/enroll-file.d.ts +4 -0
- package/dist/tools/enroll-file.d.ts.map +1 -0
- package/dist/tools/enroll-file.js +184 -0
- package/dist/tools/enroll-file.js.map +1 -0
- package/dist/tools/get-versions.d.ts.map +1 -1
- package/dist/tools/get-versions.js +6 -1
- package/dist/tools/get-versions.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +12 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +25 -2
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/write-files.d.ts.map +1 -1
- package/dist/tools/write-files.js +5 -0
- package/dist/tools/write-files.js.map +1 -1
- package/dist/tools/write-versions.d.ts.map +1 -1
- package/dist/tools/write-versions.js +2 -1
- package/dist/tools/write-versions.js.map +1 -1
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version-format.d.ts +32 -0
- package/dist/version-format.d.ts.map +1 -0
- package/dist/version-format.js +35 -0
- package/dist/version-format.js.map +1 -0
- 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
|