outrank-cli 0.6.1 → 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.
@@ -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
  };
@@ -2,6 +2,7 @@ import { loadConfig } from '../../core/config.js';
2
2
  import { MCP_STARTUP_RESOLVE_TIMEOUT_MS, Surface } from '../../core/constants.js';
3
3
  import { createCommandContext, resolveContext } from '../../core/context.js';
4
4
  import { MCP_USAGE_GUIDANCE, cliError, toErrorEnvelope } from '../../core/errors.js';
5
+ import { RECEIVED_RESPONSE_PROPERTY } from '../../core/http.js';
5
6
  import { UUID_PATTERN } from '../../core/schema.js';
6
7
  import {
7
8
  FallbackReason,
@@ -10,6 +11,7 @@ import {
10
11
  MCP_IDENTITY_RETRY_INTERVAL_MS,
11
12
  SelectionSource,
12
13
  } from './constants.js';
14
+ import { createRequestThrottle } from './throttle.js';
13
15
  import { selectTools } from './toolsets.js';
14
16
 
15
17
  const WHOAMI_PATH = '/auth/whoami';
@@ -81,6 +83,7 @@ export const createSession = ({
81
83
  timeouts = {},
82
84
  now = Date.now,
83
85
  onToolsChanged = () => {},
86
+ throttle = createRequestThrottle(),
84
87
  }) => {
85
88
  const startupBudgetMs = timeouts.startupResolveMs ?? MCP_STARTUP_RESOLVE_TIMEOUT_MS;
86
89
  const retryIntervalMs = timeouts.identityRetryIntervalMs ?? MCP_IDENTITY_RETRY_INTERVAL_MS;
@@ -166,6 +169,20 @@ export const createSession = ({
166
169
  : {}),
167
170
  },
168
171
  });
172
+ const unthrottledRequest = ctx.request;
173
+ ctx.request = async (request) => {
174
+ await throttle.acquire(controller.signal);
175
+ let response;
176
+ try {
177
+ response = await unthrottledRequest(request);
178
+ } catch (error) {
179
+ if (error?.[RECEIVED_RESPONSE_PROPERTY]) throttle.observe(error[RECEIVED_RESPONSE_PROPERTY]);
180
+ throw error;
181
+ }
182
+ throttle.observe(response);
183
+ return response;
184
+ };
185
+ ctx.http = async (request) => (await ctx.request(request)).result;
169
186
  const release = () => {
170
187
  activeRequests.delete(controller);
171
188
  if (signal) signal.removeEventListener('abort', forward);
@@ -176,7 +193,8 @@ export const createSession = ({
176
193
  const applyWhoami = (envelope) => {
177
194
  if (envelope?.ok !== true) {
178
195
  if (isKeyRejection(envelope)) state.keyRejected = true;
179
- setIdentity({ status: IdentityStatus.FAILED, error: envelope?.error ?? {} });
196
+ const keepKnownIdentity = state.identity.status === IdentityStatus.OK && !isKeyRejection(envelope);
197
+ if (!keepKnownIdentity) setIdentity({ status: IdentityStatus.FAILED, error: envelope?.error ?? {} });
180
198
  return;
181
199
  }
182
200
  const data = envelope.data && typeof envelope.data === 'object' ? envelope.data : {};
@@ -209,6 +227,15 @@ export const createSession = ({
209
227
 
210
228
  const canIdentify = () => failure() === null && Boolean(state.resolved?.apiKey);
211
229
 
230
+ const refreshIdentity = () => {
231
+ if (!canIdentify() || state.closed) return;
232
+ if (state.pending === null) {
233
+ requestIdentity();
234
+ return;
235
+ }
236
+ state.pending.then(refreshIdentity).catch(() => {});
237
+ };
238
+
212
239
  const identityIsFinal = () => {
213
240
  if (state.identity.status === IdentityStatus.OK) return true;
214
241
  if (state.identity.status !== IdentityStatus.FAILED || state.pending) return false;
@@ -253,8 +280,9 @@ export const createSession = ({
253
280
  if (envelope?.ok === true) applyWhoami(envelope);
254
281
  },
255
282
  noteResult: (envelope) => {
256
- if (isKeyRejection(envelope)) state.keyRejected = true;
283
+ if (isKeyRejection(envelope)) applyWhoami(envelope);
257
284
  },
285
+ refreshIdentity,
258
286
  shutdown: () => {
259
287
  state.closed = true;
260
288
  for (const controller of activeRequests) controller.abort();