@xano/xanoscript-language-server 12.16.6 → 12.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lexer/mcp.js ADDED
@@ -0,0 +1,109 @@
1
+ import { Identifier } from "./identifier.js";
2
+ import { createTokenByName } from "./utils.js";
3
+
4
+ // MCP 2026-07-28 authoring keywords: the `prompt` and `resource` object kinds,
5
+ // the tool/prompt/resource metadata keys, and the `mcp.elicit` / `mcp.progress`
6
+ // statements. Grounded against cloud-client's schema DSL
7
+ // (`script/kind/schema/core/{prompt,resource,tool,mcp_server}.yaml` and
8
+ // `script/kind/schema/statement/mcp.{elicit,progress}.yaml`).
9
+ //
10
+ // Ordering note: a keyword with `longer_alt: Identifier` loses to an EARLIER
11
+ // shorter token, so `prompts` must precede `prompt`, and `resource` is lexed
12
+ // after `resources` (MicroserviceTokens, reused by the mcp_server parser).
13
+
14
+ // "prompts"
15
+ export const PromptsToken = createTokenByName("prompts", {
16
+ longer_alt: Identifier,
17
+ categories: [Identifier],
18
+ });
19
+
20
+ // "prompt"
21
+ export const PromptToken = createTokenByName("prompt", {
22
+ longer_alt: Identifier,
23
+ categories: [Identifier],
24
+ });
25
+
26
+ // "resource"
27
+ export const ResourceToken = createTokenByName("resource", {
28
+ longer_alt: Identifier,
29
+ categories: [Identifier],
30
+ });
31
+
32
+ // "title"
33
+ export const TitleToken = createTokenByName("title", {
34
+ longer_alt: Identifier,
35
+ categories: [Identifier],
36
+ });
37
+
38
+ // "annotations"
39
+ export const AnnotationsToken = createTokenByName("annotations", {
40
+ longer_alt: Identifier,
41
+ categories: [Identifier],
42
+ });
43
+
44
+ // "icons"
45
+ export const IconsToken = createTokenByName("icons", {
46
+ longer_alt: Identifier,
47
+ categories: [Identifier],
48
+ });
49
+
50
+ // "uri"
51
+ export const UriToken = createTokenByName("uri", {
52
+ longer_alt: Identifier,
53
+ categories: [Identifier],
54
+ });
55
+
56
+ // "mime_type"
57
+ export const MimeTypeToken = createTokenByName("mime_type", {
58
+ longer_alt: Identifier,
59
+ categories: [Identifier],
60
+ });
61
+
62
+ // "elicit" (mcp.elicit)
63
+ export const ElicitToken = createTokenByName("elicit", {
64
+ longer_alt: Identifier,
65
+ categories: [Identifier],
66
+ });
67
+
68
+ // "progress" (mcp.progress)
69
+ export const ProgressToken = createTokenByName("progress", {
70
+ longer_alt: Identifier,
71
+ categories: [Identifier],
72
+ });
73
+
74
+ export const McpObjectTokens = [
75
+ PromptsToken,
76
+ PromptToken,
77
+ ResourceToken,
78
+ TitleToken,
79
+ AnnotationsToken,
80
+ IconsToken,
81
+ UriToken,
82
+ MimeTypeToken,
83
+ ElicitToken,
84
+ ProgressToken,
85
+ ];
86
+
87
+ /**
88
+ * Maps a token name to a type
89
+ * @param {string} token the token name
90
+ */
91
+ export function mapTokenToType(token) {
92
+ switch (token) {
93
+ case PromptToken.name:
94
+ case ResourceToken.name:
95
+ case PromptsToken.name:
96
+ return "keyword";
97
+ case TitleToken.name:
98
+ case AnnotationsToken.name:
99
+ case IconsToken.name:
100
+ case UriToken.name:
101
+ case MimeTypeToken.name:
102
+ return "variable";
103
+ case ElicitToken.name:
104
+ case ProgressToken.name:
105
+ return "function";
106
+ default:
107
+ return null;
108
+ }
109
+ }
package/lexer/tokens.js CHANGED
@@ -84,6 +84,10 @@ import {
84
84
  mapTokenToType as mapLiteralTokenToType,
85
85
  } from "./literal.js";
86
86
  import { mapTokenToType as mapMathTokenToType, MathTokens } from "./math.js";
87
+ import {
88
+ mapTokenToType as mapMcpObjectTokenToType,
89
+ McpObjectTokens,
90
+ } from "./mcp.js";
87
91
  import {
88
92
  mapTokenToType as mapMcpServerTokenToType,
89
93
  McpServerTokens,
@@ -468,6 +472,9 @@ export const allTokens = uniq([
468
472
  ChannelServerToken,
469
473
  ...AgentTokens, // Must come before ToolTokens because of ToolsToken
470
474
  ...ToolTokens,
475
+ // MCP authoring keywords (prompt/resource kinds, tool metadata, mcp.*);
476
+ // after MicroserviceTokens so `resources` wins over `resource`.
477
+ ...McpObjectTokens,
471
478
  ...AddonTokens,
472
479
  ...ActionTokens,
473
480
  ...BranchTokens,
@@ -503,6 +510,7 @@ const tokenMappers = [
503
510
  mapFunctionTokenToType,
504
511
  mapLiteralTokenToType,
505
512
  mapMathTokenToType,
513
+ mapMcpObjectTokenToType,
506
514
  mapMcpServerTokenToType,
507
515
  mapMcpServerTriggerTokenToType,
508
516
  mapMessageTokenToType,
@@ -473,6 +473,45 @@ Calls an internal Xano AI tool, it allows you to invoke internal tools within yo
473
473
  - `tool.call` takes the name of the tool as an argument.
474
474
  - `input`: The input parameters for the tool.
475
475
 
476
+ # mcp.elicit
477
+
478
+ ```xs
479
+ mcp.elicit {
480
+ key = "confirm"
481
+ message = "Delete order " ~ $input.order_id ~ "?"
482
+ input {
483
+ bool proceed
484
+ text reason?
485
+ }
486
+ } as $answer
487
+ ```
488
+
489
+ Asks the MCP client for input while a tool, prompt or resource runs. `$answer` is `{action, content}`, where `action` is `accept`, `decline` or `cancel`, and `content` holds the `input` fields on `accept`.
490
+
491
+ - `key`: required. Identifies the answer across retries; use a distinct key per elicit.
492
+ - `message`: required. The question shown to the user.
493
+ - `input`: optional. The form fields, same grammar as a tool's `input` block.
494
+
495
+ **Input is only collected from `2026-07-28` clients with form elicitation.** Every other client, and a run outside MCP, gets `{action: "cancel"}`, so always handle the `cancel` branch.
496
+
497
+ The client retries the WHOLE request with each answer, so every statement before the elicit runs again on each round trip. Put writes (`db.add`, `db.edit`, a non-GET `api.request`, `util.send_email`, `function.run`) after the last `mcp.elicit`.
498
+
499
+ # mcp.progress
500
+
501
+ ```xs
502
+ mcp.progress {
503
+ progress = $index
504
+ total = $count
505
+ message = "Processing " ~ $index ~ " of " ~ $count
506
+ }
507
+ ```
508
+
509
+ Sends an MCP `notifications/progress` update to the client while a tool, prompt or resource runs. It does nothing outside an MCP request, or when the client sent no progress token.
510
+
511
+ - `progress`: required. Must increase on every call; a value that does not is dropped.
512
+ - `total`: optional. The expected final value.
513
+ - `message`: optional. A short status line.
514
+
476
515
  # api.lambda
477
516
 
478
517
  ```xs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xano/xanoscript-language-server",
3
- "version": "12.16.6",
3
+ "version": "12.17.0",
4
4
  "description": "Language Server Protocol implementation for XanoScript",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -306,6 +306,8 @@ export class XanoBaseParser extends CstParser {
306
306
  this.warnings = [];
307
307
  this.informations = [];
308
308
  this.hints = [];
309
+ // write-before-elicit lint state (parser/functions/mcp/elicitLint.js)
310
+ this.__mcpElicitLint = { mutations: [], checked: 0 };
309
311
  this.__symbolTable = {
310
312
  input: {},
311
313
  var: {},
@@ -6,7 +6,9 @@ import { InputToken, NewlineToken } from "../../lexer/tokens.js";
6
6
  * @param {import('../base_parser.js').XanoBaseParser} $
7
7
  */
8
8
  export function inputClause($) {
9
- return () => {
9
+ // `fields` collects the top-level fields (not nested object schema fields),
10
+ // keyed by name: { typeToken, nameToken, iterable }
11
+ return (fields = {}) => {
10
12
  $.sectionStack.push("inputClause");
11
13
 
12
14
  let hasStatement = false;
@@ -24,11 +26,18 @@ export function inputClause($) {
24
26
  $.SUBRULE($.optionalCommentBlockFn);
25
27
 
26
28
  $.OR([
27
- { ALT: () => $.SUBRULE($.objectColumnDefinition) },
28
- { ALT: () => $.SUBRULE($.enumColumnDefinition) },
29
29
  {
30
30
  ALT: () =>
31
- $.SUBRULE($.columnDefinition, { ARGS: [{ include_file: true }] }),
31
+ $.SUBRULE($.objectColumnDefinition, { ARGS: [fields] }),
32
+ },
33
+ {
34
+ ALT: () => $.SUBRULE($.enumColumnDefinition, { ARGS: [fields] }),
35
+ },
36
+ {
37
+ ALT: () =>
38
+ $.SUBRULE($.columnDefinition, {
39
+ ARGS: [{ include_file: true }, fields],
40
+ }),
32
41
  },
33
42
  {
34
43
  ALT: () => $.SUBRULE($.dbLinkColumnDefinition),
@@ -0,0 +1,252 @@
1
+ import { EqualToken } from "../../lexer/control.js";
2
+ import {
3
+ SingleQuotedStringLiteral,
4
+ StringLiteral,
5
+ } from "../../lexer/literal.js";
6
+ import { AnnotationsToken, IconsToken, TitleToken } from "../../lexer/mcp.js";
7
+ import { MultiLineStringToken } from "../../lexer/multiline.js";
8
+
9
+ // Shared MCP metadata blocks for `tool`, `prompt` and `resource` (MCP spec
10
+ // 2026-07-28). The rules mirror cloud-client's decode checks so the editor
11
+ // reports what a push would refuse:
12
+ // - `helper/mvp/mcpserver/ToolMetadata.php` (icons, tool annotations)
13
+ // - `helper/script/transform/Resource.php` (resource annotations)
14
+
15
+ export const ICON_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"];
16
+ export const ICON_URL_MAX_LENGTH = 2048;
17
+ export const ICON_DATA_MAX_BYTES = 65536;
18
+ export const RESOURCE_AUDIENCES = ["user", "assistant"];
19
+
20
+ const ICON_SCHEMA = {
21
+ src: "[string]",
22
+ // validated below: cloud-client compares it lowercased and allows ""
23
+ "mime_type?": "[string]",
24
+ "sizes?": ["[string]"],
25
+ "theme?": ["light", "dark"],
26
+ };
27
+
28
+ const ISO_8601 =
29
+ /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
30
+
31
+ /**
32
+ * The value of a token that `[string]` accepts ("...", '...' or a """ block),
33
+ * or null for anything else (an expression, a variable).
34
+ * @param {import('chevrotain').IToken} [token]
35
+ * @returns {string|null}
36
+ */
37
+ function stringValue(token) {
38
+ switch (token?.tokenType) {
39
+ case StringLiteral:
40
+ case SingleQuotedStringLiteral:
41
+ return token.image.slice(1, -1);
42
+ case MultiLineStringToken:
43
+ return token.image.slice(3, -3).trim();
44
+ default:
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Why an icon `src` is refused, or null. Same rules as ToolMetadata::srcProblem:
51
+ * `https:` or a base64 `data:` URI of png/jpeg/webp. SVG and every other scheme
52
+ * (`http:`, `javascript:`, `file:`, ...) are refused.
53
+ * @param {string} src
54
+ * @returns {string|null}
55
+ */
56
+ export function iconSrcProblem(src) {
57
+ const lower = src.toLowerCase();
58
+
59
+ if (lower.startsWith("https://")) {
60
+ if (src.length > ICON_URL_MAX_LENGTH) {
61
+ return `icon src is longer than ${ICON_URL_MAX_LENGTH} characters`;
62
+ }
63
+ let host = "";
64
+ try {
65
+ host = new URL(src).hostname;
66
+ } catch {
67
+ host = "";
68
+ }
69
+ if (!host || /\s/.test(src)) {
70
+ return "icon src is not a valid https URL";
71
+ }
72
+ return null;
73
+ }
74
+
75
+ if (lower.startsWith("data:")) {
76
+ const match = src.match(/^data:([a-z0-9.+/-]+);base64,([A-Za-z0-9+/]*={0,2})$/i);
77
+ if (!match) {
78
+ return "icon data: src must be base64 image data";
79
+ }
80
+ const mime = match[1].toLowerCase();
81
+ if (!ICON_MIME_TYPES.includes(mime)) {
82
+ return `icon data: src must be ${ICON_MIME_TYPES.join(", ")} — got "${mime}"`;
83
+ }
84
+ const payload = match[2];
85
+ if (payload.length === 0) {
86
+ return "icon data: src must be base64 image data";
87
+ }
88
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
89
+ if ((payload.length * 3) / 4 - padding > ICON_DATA_MAX_BYTES) {
90
+ return `icon data: src is larger than ${ICON_DATA_MAX_BYTES} bytes`;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ const scheme = src.includes(":") ? src.split(":")[0] + ":" : src.slice(0, 20);
96
+ return `icon src must be https: or a data: image — got "${scheme}"`;
97
+ }
98
+
99
+ /**
100
+ * title = "Human readable name"
101
+ * @param {import('../base_parser.js').XanoBaseParser} $
102
+ */
103
+ export function mcpTitleClause($) {
104
+ return () => {
105
+ $.sectionStack.push("mcpTitleClause");
106
+ $.CONSUME(TitleToken); // "title"
107
+ $.CONSUME(EqualToken); // "="
108
+ const value = $.CONSUME(StringLiteral); // "..."
109
+ if (value.image === `""`) {
110
+ $.addMissingError(value, "title cannot be empty");
111
+ }
112
+ $.sectionStack.pop();
113
+ };
114
+ }
115
+
116
+ /**
117
+ * icons = [{ src: "https://...", mime_type: "image/png", sizes: ["48x48"], theme: "light" }]
118
+ * @param {import('../base_parser.js').XanoBaseParser} $
119
+ */
120
+ export function mcpIconsClause($) {
121
+ return () => {
122
+ $.sectionStack.push("mcpIconsClause");
123
+ const parent = $.CONSUME(IconsToken); // "icons"
124
+ $.CONSUME(EqualToken); // "="
125
+
126
+ const captured = {};
127
+ $.SUBRULE($.schemaParseArrayFn, { ARGS: [parent, [ICON_SCHEMA], captured] });
128
+
129
+ $.ACTION(() => {
130
+ for (const icon of captured.value || []) {
131
+ const srcToken = icon.src?.value;
132
+ const src = stringValue(srcToken);
133
+ if (src !== null) {
134
+ const problem = iconSrcProblem(src);
135
+ if (problem) {
136
+ $.addInvalidValueError(srcToken, problem);
137
+ }
138
+ }
139
+
140
+ const mimeToken = icon.mime_type?.value;
141
+ const mime = stringValue(mimeToken);
142
+ if (mime !== null) {
143
+ if (mime !== "" && !ICON_MIME_TYPES.includes(mime.toLowerCase())) {
144
+ $.addInvalidValueError(
145
+ mimeToken,
146
+ `icon mime_type must be one of ${ICON_MIME_TYPES.join(", ")} — got "${mime}"`,
147
+ );
148
+ }
149
+ }
150
+
151
+ for (const size of icon.sizes?.value || []) {
152
+ const sizeToken = size?.value;
153
+ const value = stringValue(sizeToken);
154
+ if (value === null) continue;
155
+ if (!/^(\d{1,5}x\d{1,5}|any)$/.test(value)) {
156
+ $.addInvalidValueError(
157
+ sizeToken,
158
+ `icon size must be WxH or "any" — got "${value}"`,
159
+ );
160
+ }
161
+ }
162
+ }
163
+ });
164
+ $.sectionStack.pop();
165
+ };
166
+ }
167
+
168
+ /**
169
+ * annotations = { read_only_hint: true, destructive_hint: false, ... } (tool)
170
+ * @param {import('../base_parser.js').XanoBaseParser} $
171
+ */
172
+ export function mcpToolAnnotationsClause($) {
173
+ return () => {
174
+ $.sectionStack.push("mcpToolAnnotationsClause");
175
+ const parent = $.CONSUME(AnnotationsToken); // "annotations"
176
+ $.CONSUME(EqualToken); // "="
177
+ $.SUBRULE($.schemaParseObjectFn, {
178
+ ARGS: [
179
+ parent,
180
+ {
181
+ "read_only_hint?": "[boolean]",
182
+ "destructive_hint?": "[boolean]",
183
+ "idempotent_hint?": "[boolean]",
184
+ "open_world_hint?": "[boolean]",
185
+ },
186
+ ],
187
+ });
188
+ $.sectionStack.pop();
189
+ };
190
+ }
191
+
192
+ /**
193
+ * annotations = { audience: ["user"], priority: 0.5, last_modified: "2025-01-12T15:00:58Z" } (resource)
194
+ * @param {import('../base_parser.js').XanoBaseParser} $
195
+ */
196
+ export function mcpResourceAnnotationsClause($) {
197
+ return () => {
198
+ $.sectionStack.push("mcpResourceAnnotationsClause");
199
+ const parent = $.CONSUME(AnnotationsToken); // "annotations"
200
+ $.CONSUME(EqualToken); // "="
201
+
202
+ const captured = {};
203
+ $.SUBRULE($.schemaParseObjectFn, {
204
+ ARGS: [
205
+ parent,
206
+ {
207
+ "audience?": ["[string]"],
208
+ "priority?": "[number]",
209
+ "last_modified?": "[string]",
210
+ },
211
+ captured,
212
+ ],
213
+ });
214
+
215
+ $.ACTION(() => {
216
+ for (const role of captured.audience?.value || []) {
217
+ const roleToken = role?.value;
218
+ const value = stringValue(roleToken);
219
+ if (value === null) continue;
220
+ if (!RESOURCE_AUDIENCES.includes(value)) {
221
+ $.addInvalidValueError(
222
+ roleToken,
223
+ `resource annotation audience must be user or assistant — got "${value}"`,
224
+ );
225
+ }
226
+ }
227
+
228
+ const priorityToken = captured.priority?.value;
229
+ if (priorityToken) {
230
+ const priority = Number(priorityToken.image);
231
+ if (Number.isFinite(priority) && (priority < 0 || priority > 1)) {
232
+ $.addInvalidValueError(
233
+ priorityToken,
234
+ `resource annotation priority must be from 0 to 1 — got ${priorityToken.image}`,
235
+ );
236
+ }
237
+ }
238
+
239
+ const modifiedToken = captured.last_modified?.value;
240
+ const value = stringValue(modifiedToken);
241
+ if (value !== null) {
242
+ if (!ISO_8601.test(value) || Number.isNaN(Date.parse(value))) {
243
+ $.addInvalidValueError(
244
+ modifiedToken,
245
+ `resource annotation last_modified must be an ISO 8601 date, e.g. "2025-01-12T15:00:58Z"`,
246
+ );
247
+ }
248
+ }
249
+ });
250
+ $.sectionStack.pop();
251
+ };
252
+ }
@@ -2,6 +2,7 @@ import { ExclamationToken } from "../../lexer/cast.js";
2
2
  import { CommentToken } from "../../lexer/comment.js";
3
3
  import { LCurly, RCurly } from "../../lexer/control.js";
4
4
  import { NewlineToken } from "../../lexer/tokens.js";
5
+ import { recordStatement } from "../functions/mcp/elicitLint.js";
5
6
 
6
7
  export const DEFAULT_OPTIONS = {
7
8
  allowCallStatements: false,
@@ -26,6 +27,10 @@ export function nakedStackFn($) {
26
27
  {
27
28
  ALT: () => {
28
29
  $.OPTION(() => $.CONSUME(ExclamationToken)); // optional "!" to continue on error
30
+ let startIdx;
31
+ $.ACTION(() => {
32
+ startIdx = $.exportLexerState() + 1;
33
+ });
29
34
  $.OR1([
30
35
  { ALT: () => $.SUBRULE($.aiFn) },
31
36
  { ALT: () => $.SUBRULE($.dbFn) },
@@ -37,6 +42,7 @@ export function nakedStackFn($) {
37
42
  { ALT: () => $.SUBRULE($.textFn) },
38
43
  { ALT: () => $.SUBRULE($.utilFn) },
39
44
  { ALT: () => $.SUBRULE($.realtimeFn) },
45
+ { ALT: () => $.SUBRULE($.mcpFn) },
40
46
  { ALT: () => $.SUBRULE($.preconditionFn) },
41
47
  { ALT: () => $.SUBRULE($.zipFn) },
42
48
  { ALT: () => $.SUBRULE($.objectFn) },
@@ -67,6 +73,8 @@ export function nakedStackFn($) {
67
73
  },
68
74
  { ALT: () => $.SUBRULE($.workflowExpectFn) },
69
75
  ]);
76
+ // write-before-elicit lint (see functions/mcp/elicitLint.js)
77
+ $.ACTION(() => recordStatement($, startIdx));
70
78
  },
71
79
  },
72
80
  ]);
@@ -8,6 +8,12 @@ import { flexibleStringClause } from "./flexibleStringClause.js";
8
8
  import { historyClause } from "./historyClause.js";
9
9
  import { indexClause } from "./indexClause.js";
10
10
  import { inputClause } from "./inputClause.js";
11
+ import {
12
+ mcpIconsClause,
13
+ mcpResourceAnnotationsClause,
14
+ mcpTitleClause,
15
+ mcpToolAnnotationsClause,
16
+ } from "./mcpMetadataClauses.js";
11
17
  import { mcpServerClause } from "./mcpServerClause.js";
12
18
  import { middlewareClause } from "./middlewareClause.js";
13
19
  import { nakedStackFn } from "./nakedStackFn.js";
@@ -40,6 +46,16 @@ export const register = ($) => {
40
46
  $.inputClause = $.RULE("inputClause", inputClause($));
41
47
  $.outputClause = $.RULE("outputClause", outputClause($));
42
48
  $.mcpServerClause = $.RULE("mcpServerClause", mcpServerClause($));
49
+ $.mcpTitleClause = $.RULE("mcpTitleClause", mcpTitleClause($));
50
+ $.mcpIconsClause = $.RULE("mcpIconsClause", mcpIconsClause($));
51
+ $.mcpToolAnnotationsClause = $.RULE(
52
+ "mcpToolAnnotationsClause",
53
+ mcpToolAnnotationsClause($)
54
+ );
55
+ $.mcpResourceAnnotationsClause = $.RULE(
56
+ "mcpResourceAnnotationsClause",
57
+ mcpResourceAnnotationsClause($)
58
+ );
43
59
  $.realtimeServerClause = $.RULE(
44
60
  "realtimeServerClause",
45
61
  realtimeServerClause($)
@@ -142,6 +142,7 @@ export function columnDefinition($) {
142
142
  schema[getVarName(nameToken)] = {
143
143
  typeToken: inputTypeToken,
144
144
  nameToken: nameToken,
145
+ iterable,
145
146
  };
146
147
 
147
148
  // add the variable to the registry
@@ -36,6 +36,7 @@ export function enumColumnDefinition($) {
36
36
  schema[getVarName(name)] = {
37
37
  typeToken: parent,
38
38
  nameToken: name,
39
+ iterable,
39
40
  };
40
41
 
41
42
  // ? (is it an optional field)
@@ -41,6 +41,7 @@ export function objectColumnDefinition($) {
41
41
  schema[getVarName(name)] = {
42
42
  typeToken: parent,
43
43
  nameToken: name,
44
+ iterable,
44
45
  };
45
46
 
46
47
  // ? (is it an optional field)
@@ -0,0 +1,100 @@
1
+ import { PipeToken } from "../../../lexer/control.js";
2
+ import { StringLiteral } from "../../../lexer/literal.js";
3
+ import { getVarName } from "../../generic/utils.js";
4
+
5
+ // The write-before-elicit lint (plan KTD9). `mcp.elicit` asks the client for
6
+ // input by ending the request with `input_required`; the client then RETRIES
7
+ // the whole request with the answer, so every statement before the elicit runs
8
+ // once per round trip. A write placed before the first elicit repeats.
9
+ //
10
+ // The mutating set is deliberately conservative: the `db.*` writes, raw SQL
11
+ // (`db.direct_query`, `db.external.<driver>.direct_query`), an `api.request`
12
+ // whose method is not a plain literal GET/HEAD, `util.send_email`, and
13
+ // `function.run` (which may write anything).
14
+
15
+ const DB_WRITES = [
16
+ "add",
17
+ "add_or_edit",
18
+ "edit",
19
+ "patch",
20
+ "del",
21
+ "delete",
22
+ "truncate",
23
+ "increment",
24
+ "bulk",
25
+ // raw SQL may write
26
+ "direct_query",
27
+ "external",
28
+ ];
29
+
30
+ const READ_METHODS = ["GET", "HEAD"];
31
+
32
+ /**
33
+ * The name of the mutating statement spanning `input[start..end]`, or null.
34
+ * Reads tokens in place: this runs for every stack statement on every parse.
35
+ * @param {import('chevrotain').IToken[]} input the parser's token vector
36
+ * @param {number} start index of the statement's first token
37
+ * @param {number} end index of the statement's last token
38
+ * @returns {string|null}
39
+ */
40
+ export function mutatingStatementName(input, start, end) {
41
+ const ns = input[start]?.image;
42
+ const dot = input[start + 1]?.image;
43
+ const name = input[start + 2]?.image;
44
+ if (dot !== "." || !ns || !name) return null;
45
+
46
+ if (ns === "db" && DB_WRITES.includes(name)) return `db.${name}`;
47
+ if (ns === "util" && name === "send_email") return "util.send_email";
48
+ if (ns === "function" && name === "run") return "function.run";
49
+
50
+ if (ns === "api" && name === "request") {
51
+ for (let i = start + 3; i <= end - 2; i++) {
52
+ if (input[i].image !== "method" || input[i + 1].image !== "=") continue;
53
+ const value = input[i + 2];
54
+ // a filtered or compound method ("GET"|replace:...) is not a plain GET
55
+ const isPlain = input[i + 3]?.tokenType !== PipeToken;
56
+ if (
57
+ isPlain &&
58
+ value.tokenType === StringLiteral &&
59
+ READ_METHODS.includes(getVarName(value).toUpperCase())
60
+ ) {
61
+ return null;
62
+ }
63
+ return "api.request";
64
+ }
65
+ // no method: api.request defaults to GET
66
+ return null;
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Record a statement that just finished parsing (called from nakedStackFn).
74
+ * @param {import('../../base_parser.js').XanoBaseParser} $
75
+ * @param {number} startIdx lexer index of the statement's first token
76
+ */
77
+ export function recordStatement($, startIdx) {
78
+ const name = mutatingStatementName($.input, startIdx, $.exportLexerState());
79
+ if (name) {
80
+ $.__mcpElicitLint.mutations.push({ name, token: $.input[startIdx] });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Called when an `mcp.elicit` is parsed: warn when a write ran since the
86
+ * previous elicit (or since the start of the stack, for the first one).
87
+ * @param {import('../../base_parser.js').XanoBaseParser} $
88
+ * @param {import('chevrotain').IToken} elicitToken
89
+ */
90
+ export function checkElicit($, elicitToken) {
91
+ const lint = $.__mcpElicitLint;
92
+ const first = lint.mutations[lint.checked];
93
+ lint.checked = lint.mutations.length;
94
+ if (!first) return;
95
+
96
+ $.addWarning(
97
+ `\`${first.name}\` runs before this mcp.elicit. The client retries the whole request with each answer, so every statement before the elicit runs again on each round trip. Move writes after the last mcp.elicit.`,
98
+ elicitToken,
99
+ );
100
+ }