outrank-cli 0.6.0 → 0.7.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.
@@ -0,0 +1,69 @@
1
+ import { CliErrorCode, codedError } from '../../core/errors.js';
2
+ import { EntitlementMode, IdentityStatus } from './constants.js';
3
+
4
+ export const WHOAMI_RESOURCE_URI = 'outrank://whoami';
5
+ const WHOAMI_RESOURCE_NAME = 'whoami';
6
+ const JSON_MIME_TYPE = 'application/json';
7
+ const JSON_INDENT = 2;
8
+ const MAX_URI_CHARS = 200;
9
+ const WHOAMI_RESOURCE_DESCRIPTION =
10
+ 'The organization this server works on, what the server knows about its entitlements, and which toolsets are ' +
11
+ 'listed. Read it instead of calling auth_whoami when the question is only "who am I connected as".';
12
+
13
+ const toolsetsIn = (selection, mode) =>
14
+ Object.entries(selection.enabled)
15
+ .filter(([, toolsetMode]) => toolsetMode === mode)
16
+ .map(([toolset]) => toolset);
17
+
18
+ const MAX_ERROR_TEXT_CHARS = 500;
19
+
20
+ const boundedError = (error) => {
21
+ if (!error || typeof error !== 'object') return null;
22
+ const code = typeof error.code === 'string' ? error.code.slice(0, MAX_ERROR_TEXT_CHARS) : null;
23
+ const message = typeof error.message === 'string' ? error.message.slice(0, MAX_ERROR_TEXT_CHARS) : null;
24
+ return { code, message };
25
+ };
26
+
27
+ export const buildWhoamiResource = (session) => {
28
+ const identity = session.identity();
29
+ const selection = session.selection();
30
+ const failure = session.failure();
31
+ return {
32
+ organization: session.organization(),
33
+ identity: {
34
+ status: identity.status,
35
+ ...(identity.status === IdentityStatus.OK ? { entitlements: identity.entitlements ?? null } : {}),
36
+ ...(identity.status === IdentityStatus.FAILED ? { error: boundedError(identity.error) } : {}),
37
+ },
38
+ configuration_error: failure ? boundedError(failure.error) : null,
39
+ toolsets: {
40
+ source: selection.source,
41
+ fallback_reason: session.fallbackReason(),
42
+ read_only: selection.readOnly,
43
+ full: toolsetsIn(selection, EntitlementMode.FULL),
44
+ read: toolsetsIn(selection, EntitlementMode.READ),
45
+ off: selection.disabledToolsets,
46
+ },
47
+ tool_count: selection.tools.length,
48
+ };
49
+ };
50
+
51
+ export const listResources = () => [
52
+ {
53
+ uri: WHOAMI_RESOURCE_URI,
54
+ name: WHOAMI_RESOURCE_NAME,
55
+ description: WHOAMI_RESOURCE_DESCRIPTION,
56
+ mimeType: JSON_MIME_TYPE,
57
+ },
58
+ ];
59
+
60
+ const UNKNOWN_RESOURCE_GUIDANCE = `The only resource is ${WHOAMI_RESOURCE_URI}.`;
61
+
62
+ export const readResource = (session, uri) => {
63
+ if (uri !== WHOAMI_RESOURCE_URI) {
64
+ const shown = String(uri).slice(0, MAX_URI_CHARS);
65
+ throw codedError(CliErrorCode.INVALID_USAGE, `Unknown resource: ${shown}.`, UNKNOWN_RESOURCE_GUIDANCE);
66
+ }
67
+ const text = JSON.stringify(buildWhoamiResource(session), null, JSON_INDENT);
68
+ return { contents: [{ uri: WHOAMI_RESOURCE_URI, mimeType: JSON_MIME_TYPE, text }] };
69
+ };
@@ -1,9 +1,55 @@
1
1
  const CONFIG_PATH_DETAIL = 'config_path';
2
2
  const DETAILS_FIELD = 'details';
3
3
  const TEXT_CONTENT_TYPE = 'text';
4
+ export const UNTRUSTED_TEXT_FIELD = 'untrusted_counterparty_text';
5
+ const PATH_SEPARATOR = '.';
6
+ const EACH_ELEMENT_SUFFIX = '[]';
4
7
 
5
8
  const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6
9
 
10
+ const wrapText = (value) => {
11
+ if (typeof value === 'string') return { [UNTRUSTED_TEXT_FIELD]: value };
12
+ if (Array.isArray(value)) return value.map(wrapText);
13
+ if (!isPlainObject(value)) return value;
14
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, wrapText(entry)]));
15
+ };
16
+
17
+ const transformAtPath = (value, segments, transform) => {
18
+ if (segments.length === 0) return transform(value);
19
+ const [head, ...rest] = segments;
20
+ const eachElement = head.endsWith(EACH_ELEMENT_SUFFIX);
21
+ const key = eachElement ? head.slice(0, -EACH_ELEMENT_SUFFIX.length) : head;
22
+ if (!isPlainObject(value) || !Object.hasOwn(value, key)) return value;
23
+ const child = value[key];
24
+ if (eachElement) {
25
+ if (!Array.isArray(child)) return value;
26
+ return { ...value, [key]: child.map((element) => transformAtPath(element, rest, transform)) };
27
+ }
28
+ return { ...value, [key]: transformAtPath(child, rest, transform) };
29
+ };
30
+
31
+ const segmentsOf = (fieldPath) => (fieldPath ? fieldPath.split(PATH_SEPARATOR) : []);
32
+
33
+ const wrapPaths = (data, paths) =>
34
+ paths.reduce((current, fieldPath) => transformAtPath(current, segmentsOf(fieldPath), wrapText), data);
35
+
36
+ const wrapFlaggedRecords = (data, paths, { record, flag }) => {
37
+ const prefixLength = record ? record.length + PATH_SEPARATOR.length : 0;
38
+ const recordPaths = paths.map((fieldPath) => fieldPath.slice(prefixLength));
39
+ return transformAtPath(data, segmentsOf(record), (entry) =>
40
+ !isPlainObject(entry) || entry[flag] === false ? entry : wrapPaths(entry, recordPaths),
41
+ );
42
+ };
43
+
44
+ export const wrapUntrustedFields = (data, paths, when = null) =>
45
+ when ? wrapFlaggedRecords(data, paths, when) : wrapPaths(data, paths);
46
+
47
+ const untrustedFields = (spec) =>
48
+ isPlainObject(spec?.mcp) && Array.isArray(spec.mcp.untrustedFields) ? spec.mcp.untrustedFields : [];
49
+
50
+ const untrustedWhen = (spec) =>
51
+ isPlainObject(spec?.mcp) && isPlainObject(spec.mcp.untrustedWhen) ? spec.mcp.untrustedWhen : null;
52
+
7
53
  const omitKeys = (value, keys) => {
8
54
  if (!isPlainObject(value)) return value;
9
55
  return Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
@@ -20,7 +66,8 @@ export const sanitizeEnvelope = (envelope, spec) => {
20
66
  const rest = omitKeys(error, [DETAILS_FIELD]);
21
67
  return { ...envelope, error: hasDetails ? { ...rest, details } : rest };
22
68
  }
23
- return { ...envelope, data: omitKeys(envelope.data, hidden) };
69
+ const visible = omitKeys(envelope.data, hidden);
70
+ return { ...envelope, data: wrapUntrustedFields(visible, untrustedFields(spec), untrustedWhen(spec)) };
24
71
  };
25
72
 
26
73
  export const toToolResult = (envelope, { spec, organization } = {}) => {
@@ -0,0 +1,17 @@
1
+ export const MULTI_REQUEST_COMMAND_IDS = Object.freeze(['marketplace.seller.onboard']);
2
+
3
+ export const SEQUENTIAL_ROUTE_KEYS = Object.freeze(['POST /billing/purchase-products']);
4
+
5
+ export const ROUTE_TOOL_OVERRIDES = Object.freeze({
6
+ 'POST /billing/purchase-products': ['billing.purchase-products.quote', 'billing.purchase-products.buy'],
7
+ 'GET /articles': ['articles.list'],
8
+ 'GET /products/{}/backlinks/subscribe-quote': ['products.backlinks.subscribe-quote'],
9
+ 'PATCH /marketplace/target-pages/{}': [
10
+ 'marketplace.buyer.target-pages.pause',
11
+ 'marketplace.buyer.target-pages.activate',
12
+ ],
13
+ 'POST /products/{}/marketplace/buyer/participation': [
14
+ 'marketplace.buyer.participation.on',
15
+ 'marketplace.buyer.participation.off',
16
+ ],
17
+ });
@@ -0,0 +1,346 @@
1
+ import { toSnakeCase, toolName } from '../../commands/registry.js';
2
+ import { API_BASE_PATH, Surface } from '../../core/constants.js';
3
+ import { renderMcpReference } from '../../core/context.js';
4
+ import { cliSuccess } from '../../core/errors.js';
5
+ import { parseFieldValue } from '../../core/schema.js';
6
+ import { MULTI_REQUEST_COMMAND_IDS, ROUTE_TOOL_OVERRIDES, SEQUENTIAL_ROUTE_KEYS } from './routeOverrides.js';
7
+
8
+ const PROBE_VALUE_PREFIX = '\u0000probe:';
9
+ const PROBE_VALUE_SUFFIX = '\u0000';
10
+ const PATH_PARAM_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
11
+ const PATH_PARAM_SEGMENT_PATTERN = /^\{[a-zA-Z0-9_]+\}$/;
12
+ const PLACEHOLDER_SEGMENT_PATTERN = /^(?:<[^>]+>|\{[^}]+\})$/;
13
+ const PATH_SEPARATOR = '/';
14
+ const METHOD_PAIR_SEPARATOR = '/';
15
+ const ROUTE_KEY_SEPARATOR = ' ';
16
+ const TOOL_ALTERNATIVE_SEPARATOR = ' or ';
17
+ const TOOL_SEQUENCE_SEPARATOR = ', then ';
18
+ const ATTACHED_CONTINUATION_PATTERN = /^[@+%=~\w-]/;
19
+ const UNPARSED_QUERY_PATTERN = /^\?[A-Za-z0-9_%<{]/;
20
+ const TIME_LIKE_CONTINUATION_PATTERN = /^:\w/;
21
+ const PATH_DOT_CONTINUATION_PATTERN = /^\.(?!\.\.(?![./]))[./\w]/;
22
+ const PREFERRED_AMBIGUOUS_METHOD = 'GET';
23
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
24
+ const SEGMENT_ALTERNATIVE_SEPARATOR = '|';
25
+ const LITERAL_SEGMENT = '[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*';
26
+ const PLACEHOLDER_SEGMENT = '<[^>\\s/]+>|\\{[^}\\s/]+\\}';
27
+ const LITERAL_ALTERNATIVES = `${LITERAL_SEGMENT}(?:\\${SEGMENT_ALTERNATIVE_SEPARATOR}${LITERAL_SEGMENT})*`;
28
+ const PATH_SEGMENT = `(?:${LITERAL_ALTERNATIVES}|${PLACEHOLDER_SEGMENT})`;
29
+ const ENCODED_BYTE = '%[0-9A-Fa-f]{2}';
30
+ const QUERY_VALUE =
31
+ `(?:(?:[A-Za-z0-9_.~-]|${ENCODED_BYTE})*(?:[A-Za-z0-9_~-]|${ENCODED_BYTE})|<[^>\\s&]+>|\\{[^}\\s&]+\\})`;
32
+ const QUERY_PAIR = `[A-Za-z0-9_]+=${QUERY_VALUE}`;
33
+ const MAX_METHOD_ALTERNATIVES = 2;
34
+ const METHOD_LIST =
35
+ `(?:${HTTP_METHODS.join('|')})` +
36
+ `(?:${METHOD_PAIR_SEPARATOR}(?:${HTTP_METHODS.join('|')})){0,${MAX_METHOD_ALTERNATIVES}}`;
37
+ const MAX_REWRITTEN_TEXT_CHARS = 20_000;
38
+ const MIN_LITERAL_MATCHES = 1;
39
+ const OFFSET_ARGUMENT_POSITION = -2;
40
+ const MENTION_BOUNDARY = '(?<![\\w./:-])';
41
+ const PATH_ELLIPSIS = '...';
42
+ const TRAILING_SEPARATOR = `(?:${PATH_SEPARATOR}(?=$|[\\s?.,;:)\\]'"\`]))?`;
43
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
+ const MENTION_PATTERN = new RegExp(
45
+ `${MENTION_BOUNDARY}(?:(${METHOD_LIST}) )?(${escapeRegExp(API_BASE_PATH)}|${escapeRegExp(PATH_ELLIPSIS)})` +
46
+ `((?:${PATH_SEPARATOR}${PATH_SEGMENT})+)${TRAILING_SEPARATOR}(\\?${QUERY_PAIR}(?:&${QUERY_PAIR})*)?`,
47
+ 'g',
48
+ );
49
+
50
+ const probeValue = (name) => `${PROBE_VALUE_PREFIX}${name}${PROBE_VALUE_SUFFIX}`;
51
+
52
+ const probedInputName = (value) =>
53
+ typeof value === 'string' && value.startsWith(PROBE_VALUE_PREFIX) && value.endsWith(PROBE_VALUE_SUFFIX)
54
+ ? value.slice(PROBE_VALUE_PREFIX.length, -PROBE_VALUE_SUFFIX.length)
55
+ : null;
56
+
57
+ const probeInput = (spec) => Object.fromEntries(Object.keys(spec.input).map((name) => [name, probeValue(name)]));
58
+
59
+ const probeContext = (calls) => {
60
+ const record = async (request) => {
61
+ calls.push(request);
62
+ return cliSuccess({}, '');
63
+ };
64
+ return {
65
+ surface: Surface.MCP,
66
+ longRunning: false,
67
+ http: record,
68
+ request: async (request) => ({ result: await record(request), status: 0 }),
69
+ ref: () => '',
70
+ };
71
+ };
72
+
73
+ const resolveTemplate = (path, pathParams = {}) => {
74
+ const params = [];
75
+ const template = path.replace(PATH_PARAM_PATTERN, (_, name) => {
76
+ const value = pathParams[name];
77
+ const input = probedInputName(value) ?? (value === undefined || value === null ? name : null);
78
+ if (input === null) return encodeURIComponent(String(value));
79
+ params.push({ name, input });
80
+ return `{${name}}`;
81
+ });
82
+ return { template, params };
83
+ };
84
+
85
+ const resolveQuery = (query = {}) =>
86
+ Object.fromEntries(
87
+ Object.entries(query)
88
+ .map(([key, value]) => [key, probedInputName(value)])
89
+ .filter(([, input]) => input !== null),
90
+ );
91
+
92
+ const segmentsOf = (pathname) => pathname.split(PATH_SEPARATOR).slice(1);
93
+
94
+ const ANY_PARAM_SEGMENT = '{}';
95
+ const routeKey = (method, template) =>
96
+ `${method}${ROUTE_KEY_SEPARATOR}${template.replace(PATH_PARAM_PATTERN, ANY_PARAM_SEGMENT)}`;
97
+
98
+ const UNMAPPED_THREW = 'run() threw';
99
+ const UNMAPPED_NO_PATH = 'the request has no path';
100
+ const unmappedCallCount = (count) => `run() made ${count} requests instead of one`;
101
+
102
+ const probeRoute = async (spec) => {
103
+ const calls = [];
104
+ try {
105
+ await spec.run(probeInput(spec), probeContext(calls));
106
+ } catch (error) {
107
+ return { reason: `${UNMAPPED_THREW}: ${String(error?.message ?? error)}` };
108
+ }
109
+ if (calls.length !== 1) return { reason: unmappedCallCount(calls.length) };
110
+ const [{ method = PREFERRED_AMBIGUOUS_METHOD, path, pathParams, query }] = calls;
111
+ if (typeof path !== 'string') return { reason: UNMAPPED_NO_PATH };
112
+ const { template, params } = resolveTemplate(path, pathParams);
113
+ return { method, template, segments: segmentsOf(template), params, query: resolveQuery(query), spec };
114
+ };
115
+
116
+ const isProbeCandidate = (spec) => spec.mcp !== false && spec.offline !== true;
117
+
118
+ const mergeCandidates = (key, candidates) => {
119
+ const [first] = candidates;
120
+ const chosen =
121
+ candidates.length === 1
122
+ ? candidates
123
+ : (ROUTE_TOOL_OVERRIDES[key] ?? []).map((id) => candidates.find((route) => route.spec.id === id));
124
+ if (chosen.length === 0 || chosen.includes(undefined)) return null;
125
+ const { method, template, segments, params, query } = first;
126
+ const specs = chosen.map((candidate) => candidate.spec);
127
+ const dropped = candidates.filter((candidate) => !chosen.includes(candidate));
128
+ const sequential = SEQUENTIAL_ROUTE_KEYS.includes(key);
129
+ return {
130
+ route: { key, method, template, segments, params, query, specs, sequential },
131
+ superseded: dropped.map(toId),
132
+ };
133
+ };
134
+
135
+ const toId = (candidate) => candidate.spec.id;
136
+
137
+ const describeCollision = (key, candidates) => `${key} is served by ${candidates.map(toId).join(', ')}`;
138
+
139
+ export const buildRouteIndex = async (registry) => {
140
+ const grouped = new Map();
141
+ const unmapped = [];
142
+ for (const spec of registry.commands.filter(isProbeCandidate)) {
143
+ const route = await probeRoute(spec);
144
+ if (route.reason) {
145
+ unmapped.push({ id: spec.id, reason: route.reason });
146
+ continue;
147
+ }
148
+ const key = routeKey(route.method, route.template);
149
+ grouped.set(key, [...(grouped.get(key) ?? []), route]);
150
+ }
151
+ const routes = [];
152
+ const superseded = [];
153
+ const collisions = [];
154
+ for (const [key, candidates] of grouped) {
155
+ const merged = mergeCandidates(key, candidates);
156
+ if (merged === null) {
157
+ collisions.push(describeCollision(key, candidates));
158
+ continue;
159
+ }
160
+ routes.push(merged.route);
161
+ superseded.push(...merged.superseded);
162
+ }
163
+ if (collisions.length > 0) {
164
+ throw new Error(`MCP routes: add these to ROUTE_TOOL_OVERRIDES: ${collisions.join('; ')}`);
165
+ }
166
+ return Object.freeze({ routes, unmapped, superseded });
167
+ };
168
+
169
+ export const unexpectedProbeFailures = (index) =>
170
+ index.unmapped.filter(({ id }) => !MULTI_REQUEST_COMMAND_IDS.includes(id));
171
+
172
+ const decodeQueryValue = (value) => {
173
+ try {
174
+ return decodeURIComponent(value);
175
+ } catch {
176
+ return value;
177
+ }
178
+ };
179
+
180
+ const isPlaceholderSegment = (segment) => PLACEHOLDER_SEGMENT_PATTERN.test(segment);
181
+ const isOpenSegment = (segment) => isPlaceholderSegment(segment) || segment.includes(SEGMENT_ALTERNATIVE_SEPARATOR);
182
+
183
+ const alignedSegments = (route, mention) => {
184
+ const count = mention.segments.length;
185
+ if (mention.suffix) return route.segments.length >= count ? route.segments.slice(-count) : null;
186
+ return route.segments.length === count ? route.segments : null;
187
+ };
188
+
189
+ const literalMatches = (route, mention, lenient) => {
190
+ const aligned = alignedSegments(route, mention);
191
+ if (aligned === null) return null;
192
+ let matches = 0;
193
+ for (const [position, templateSegment] of aligned.entries()) {
194
+ const segment = mention.segments[position];
195
+ if (PATH_PARAM_SEGMENT_PATTERN.test(templateSegment)) {
196
+ if (segment.includes(SEGMENT_ALTERNATIVE_SEPARATOR)) return null;
197
+ continue;
198
+ }
199
+ if (isPlaceholderSegment(segment)) {
200
+ if (!lenient) return null;
201
+ continue;
202
+ }
203
+ if (!segment.split(SEGMENT_ALTERNATIVE_SEPARATOR).includes(templateSegment)) return null;
204
+ matches += 1;
205
+ }
206
+ return matches;
207
+ };
208
+
209
+ const scoreRoutes = (routes, mention, lenient) =>
210
+ routes
211
+ .map((route) => ({ route, matches: literalMatches(route, mention, lenient) }))
212
+ .filter(({ matches }) => matches !== null && matches >= MIN_LITERAL_MATCHES);
213
+
214
+ const bestMatches = (scored) => {
215
+ const best = Math.max(...scored.map(({ matches }) => matches));
216
+ return scored.filter(({ matches }) => matches === best).map(({ route }) => route);
217
+ };
218
+
219
+ const preferMethod = (routes) => {
220
+ const methods = new Set(routes.map((route) => route.method));
221
+ if (methods.size === 1) return routes;
222
+ return routes.filter((route) => route.method === PREFERRED_AMBIGUOUS_METHOD);
223
+ };
224
+
225
+ const sharesToolset = (route, toolsets) =>
226
+ route.specs.some((spec) => spec.toolsets.some((toolset) => toolsets.includes(toolset)));
227
+
228
+ const preferToolsets = (routes, toolsets) => {
229
+ const related = routes.filter((route) => sharesToolset(route, toolsets));
230
+ return related.length > 0 ? related : routes;
231
+ };
232
+
233
+ const isParameterRouteOfAnotherMethod = (index, mention) => scoreRoutes(index.routes, mention, false).length > 0;
234
+
235
+ const matchRoutes = (index, method, mention, toolsets) => {
236
+ const candidates = index.routes.filter((route) => method === undefined || route.method === method);
237
+ const strict = scoreRoutes(candidates, mention, false);
238
+ if (strict.length === 0 && isParameterRouteOfAnotherMethod(index, mention)) return null;
239
+ const scored = strict.length > 0 ? strict : scoreRoutes(candidates, mention, true);
240
+ if (scored.length === 0) return null;
241
+ const byMethod = method === undefined ? preferMethod(bestMatches(scored)) : bestMatches(scored);
242
+ const tied = byMethod.length > 1 ? preferToolsets(byMethod, toolsets) : byMethod;
243
+ if (tied.length === 1 || (tied.length > 1 && mention.segments.some(isOpenSegment))) return tied;
244
+ return null;
245
+ };
246
+
247
+ const mentionedValues = (route, mention) => {
248
+ const values = [];
249
+ for (const [position, templateSegment] of alignedSegments(route, mention).entries()) {
250
+ const param = route.params.find((entry) => `{${entry.name}}` === templateSegment);
251
+ if (param) values.push([param.input, mention.segments[position]]);
252
+ }
253
+ for (const [key, value] of mention.query) {
254
+ values.push([Object.hasOwn(route.query, key) ? route.query[key] : key, decodeQueryValue(value)]);
255
+ }
256
+ return values;
257
+ };
258
+
259
+ const inputNamed = (spec, key) => Object.keys(spec.input).find((name) => name === key || toSnakeCase(name) === key);
260
+
261
+ const toArgumentValue = (field, value) => {
262
+ if (isPlaceholderSegment(value)) return value;
263
+ try {
264
+ return parseFieldValue(field, value, '');
265
+ } catch {
266
+ return undefined;
267
+ }
268
+ };
269
+
270
+ const argumentsFor = (spec, values) => {
271
+ const args = Object.create(null);
272
+ for (const [key, value] of values) {
273
+ const input = inputNamed(spec, key);
274
+ if (input === undefined) return null;
275
+ const argument = toArgumentValue(spec.input[input], value);
276
+ if (argument === undefined) return null;
277
+ args[input] = argument;
278
+ }
279
+ return args;
280
+ };
281
+
282
+ const toolReferences = (routes, mention) => {
283
+ const seen = new Set();
284
+ const references = [];
285
+ for (const route of routes) {
286
+ const values = mentionedValues(route, mention);
287
+ for (const spec of route.specs) {
288
+ const args = argumentsFor(spec, values);
289
+ if (args === null) return null;
290
+ const name = toolName(spec.id);
291
+ const argumentText = renderMcpReference(spec, args).slice(name.length);
292
+ const key = `${name}${argumentText}`;
293
+ if (seen.has(key)) continue;
294
+ seen.add(key);
295
+ references.push({ name, argumentText, args, sequential: route.sequential });
296
+ }
297
+ }
298
+ return references;
299
+ };
300
+
301
+ export const resolveMention = (index, mention, toolsets = []) => {
302
+ const matched = mention.methods.map((method) => matchRoutes(index, method, mention, toolsets));
303
+ if (matched.some((routes) => routes === null)) return null;
304
+ return toolReferences(matched.flat(), mention);
305
+ };
306
+
307
+ const renderMention = (index, mention, toolsets) => {
308
+ const references = resolveMention(index, mention, toolsets);
309
+ if (references === null) return null;
310
+ const separator = references.every((reference) => reference.sequential)
311
+ ? TOOL_SEQUENCE_SEPARATOR
312
+ : TOOL_ALTERNATIVE_SEPARATOR;
313
+ const argumentTexts = new Set(references.map((reference) => reference.argumentText));
314
+ if (argumentTexts.size === 1) {
315
+ const names = references.map((reference) => reference.name).join(separator);
316
+ return `${names}${references[0].argumentText}`;
317
+ }
318
+ return references.map((reference) => `${reference.name}${reference.argumentText}`).join(separator);
319
+ };
320
+
321
+ const continuesAfterMention = (rest) =>
322
+ rest.startsWith(PATH_SEPARATOR) ||
323
+ ATTACHED_CONTINUATION_PATTERN.test(rest) ||
324
+ UNPARSED_QUERY_PATTERN.test(rest) ||
325
+ TIME_LIKE_CONTINUATION_PATTERN.test(rest) ||
326
+ PATH_DOT_CONTINUATION_PATTERN.test(rest);
327
+
328
+ const parseMention = ([text, methodText, prefix, pathname, queryText]) => ({
329
+ text,
330
+ methods: methodText === undefined ? [undefined] : methodText.split(METHOD_PAIR_SEPARATOR),
331
+ suffix: prefix === PATH_ELLIPSIS,
332
+ segments: segmentsOf(pathname),
333
+ query: queryText === undefined ? [] : queryText.slice(1).split('&').map((pair) => pair.split('=')),
334
+ });
335
+
336
+ export const rewriteRouteMentions = (index, text, { toolsets = [] } = {}) => {
337
+ if (text.length > MAX_REWRITTEN_TEXT_CHARS) return text;
338
+ return text.replace(MENTION_PATTERN, (...match) => {
339
+ const mention = parseMention(match);
340
+ const offset = match.at(OFFSET_ARGUMENT_POSITION);
341
+ if (continuesAfterMention(text.slice(offset + mention.text.length))) return mention.text;
342
+ return renderMention(index, mention, toolsets) ?? mention.text;
343
+ });
344
+ };
345
+
346
+ export const findRouteMentions = (text) => [...text.matchAll(MENTION_PATTERN)].map(parseMention);
@@ -1,5 +1,14 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
+ import {
3
+ CallToolRequestSchema,
4
+ ErrorCode,
5
+ GetPromptRequestSchema,
6
+ ListPromptsRequestSchema,
7
+ ListResourcesRequestSchema,
8
+ ListToolsRequestSchema,
9
+ McpError,
10
+ ReadResourceRequestSchema,
11
+ } from '@modelcontextprotocol/sdk/types.js';
3
12
  import { z } from 'zod';
4
13
 
5
14
  import { toolName } from '../../commands/registry.js';
@@ -7,6 +16,7 @@ import { describeUntrustedValue } from '../../core/config.js';
7
16
  import { CliErrorCode, MCP_USAGE_GUIDANCE, cliError, toErrorEnvelope } from '../../core/errors.js';
8
17
  import { executeCommand } from '../../core/execute.js';
9
18
  import {
19
+ ENTITLEMENT_CHANGING_COMMAND_IDS,
10
20
  MCP_PROGRESS_INTERVAL_MS,
11
21
  MCP_SERVER_NAME,
12
22
  MILLISECONDS_PER_SECOND,
@@ -14,9 +24,13 @@ import {
14
24
  WHOAMI_COMMAND_ID,
15
25
  } from './constants.js';
16
26
  import { describeField, describeTool, toolAnnotations } from './descriptions.js';
27
+ import { createGuidanceRewriter } from './guidance.js';
17
28
  import { MCP_LABELS, buildToolInput, parseToolArguments, toRawInput } from './input.js';
18
29
  import { buildInstructions } from './instructions.js';
19
30
  import { createLogger, redactToolArguments } from './logger.js';
31
+ import { getPrompt, listPrompts } from './prompts.js';
32
+ import { unexpectedProbeFailures } from './routes.js';
33
+ import { listResources, readResource } from './resources.js';
20
34
  import { toToolResult } from './result.js';
21
35
  import { createSession } from './session.js';
22
36
  import { isBlockedByOperator, isReadKind } from './toolsets.js';
@@ -59,6 +73,7 @@ export const createMcpServer = async ({
59
73
  readConfig,
60
74
  timeouts = {},
61
75
  logger = createLogger(),
76
+ throttle,
62
77
  }) => {
63
78
  const progressIntervalMs = timeouts.progressIntervalMs ?? MCP_PROGRESS_INTERVAL_MS;
64
79
  const definitions = new Map();
@@ -75,9 +90,24 @@ export const createMcpServer = async ({
75
90
  server.sendToolListChanged().catch((error) => {
76
91
  logger.debug('tool_list_changed_not_sent', { reason: String(error?.message ?? error) });
77
92
  });
93
+ server.sendPromptListChanged().catch((error) => {
94
+ logger.debug('prompt_list_changed_not_sent', { reason: String(error?.message ?? error) });
95
+ });
78
96
  };
79
97
 
80
- const session = createSession({ registry, options, env, readConfig, timeouts, onToolsChanged: notifyToolsChanged });
98
+ const guidance = await createGuidanceRewriter(registry);
99
+ for (const { id, reason } of unexpectedProbeFailures(guidance.index)) {
100
+ logger.always('route_probe_failed', { command: id, reason });
101
+ }
102
+ const session = createSession({
103
+ registry,
104
+ options,
105
+ env,
106
+ readConfig,
107
+ timeouts,
108
+ onToolsChanged: notifyToolsChanged,
109
+ ...(throttle ? { throttle } : {}),
110
+ });
81
111
  await session.start();
82
112
 
83
113
  const withProgress = async (spec, extra, run) => {
@@ -122,6 +152,7 @@ export const createMcpServer = async ({
122
152
  );
123
153
  if (spec.id === WHOAMI_COMMAND_ID) session.noteWhoami(envelope);
124
154
  session.noteResult(envelope);
155
+ if (envelope.ok === true && ENTITLEMENT_CHANGING_COMMAND_IDS.includes(spec.id)) session.refreshIdentity();
125
156
  return envelope;
126
157
  } finally {
127
158
  release();
@@ -152,12 +183,24 @@ export const createMcpServer = async ({
152
183
  const spec = found && found.mcp !== false ? found : undefined;
153
184
  const envelope = await runTool(spec, params.name, params.arguments, extra);
154
185
  logToolCall({ spec, params, envelope, startedAt });
155
- return toToolResult(envelope, { spec, organization: session.organization() });
186
+ return toToolResult(guidance.rewrite(envelope, spec), { spec, organization: session.organization() });
187
+ };
188
+
189
+ const asProtocolError = (run) => {
190
+ try {
191
+ return run();
192
+ } catch (error) {
193
+ if (error?.cliCode === CliErrorCode.INVALID_USAGE) throw new McpError(ErrorCode.InvalidParams, error.message);
194
+ throw error;
195
+ }
156
196
  };
157
197
 
158
198
  server = new Server(
159
199
  { name: MCP_SERVER_NAME, version },
160
- { capabilities: { tools: { listChanged: true } }, instructions: buildInstructions({ session, version }) },
200
+ {
201
+ capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, resources: {} },
202
+ instructions: buildInstructions({ session, version }),
203
+ },
161
204
  );
162
205
  server.onerror = (error) => {
163
206
  logger.debug('protocol_error', { reason: String(error?.message ?? error) });
@@ -169,6 +212,14 @@ export const createMcpServer = async ({
169
212
  tools: session.selection().tools.map((spec) => definitionFor(spec).tool),
170
213
  }));
171
214
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => callTool(request.params, extra));
215
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: listPrompts(session.selection()) }));
216
+ server.setRequestHandler(GetPromptRequestSchema, async (request) =>
217
+ asProtocolError(() => getPrompt(session.selection(), request.params.name, request.params.arguments)),
218
+ );
219
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: listResources() }));
220
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) =>
221
+ asProtocolError(() => readResource(session, request.params.uri)),
222
+ );
172
223
 
173
224
  return { server, session };
174
225
  };