@xano/xanoscript-language-server 12.16.6 → 12.18.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/cache/documentCache.js +12 -5
- package/language-server-worker.js +6 -0
- package/lexer/mcp.js +109 -0
- package/lexer/policy.js +30 -0
- package/lexer/tokens.js +14 -0
- package/onCompletion/contentAssist.js +6 -0
- package/onCompletion/onCompletion.js +33 -3
- package/onCompletion/policyCompletion.js +339 -0
- package/onHover/PolicyMessageProvider.js +142 -0
- package/onHover/functions.md +39 -0
- package/onHover/onHover.js +2 -0
- package/package.json +3 -1
- package/parser/base_parser.js +2 -0
- package/parser/clauses/inputClause.js +13 -4
- package/parser/clauses/mcpMetadataClauses.js +252 -0
- package/parser/clauses/nakedStackFn.js +8 -0
- package/parser/clauses/register.js +16 -0
- package/parser/definitions/columnDefinition.js +1 -0
- package/parser/definitions/enumColumnDefinition.js +1 -0
- package/parser/definitions/objectColumnDefinition.js +1 -0
- package/parser/functions/mcp/elicitLint.js +100 -0
- package/parser/functions/mcp/mcpElicitFn.js +103 -0
- package/parser/functions/mcp/mcpProgressFn.js +34 -0
- package/parser/functions/mcp/register.js +11 -0
- package/parser/functions/mcpFn.js +19 -0
- package/parser/functions/register.js +4 -0
- package/parser/mcp_primitive_parser.js +343 -0
- package/parser/mcp_server_parser.js +53 -0
- package/parser/multidoc.js +45 -13
- package/parser/parser.js +58 -0
- package/parser/policy/catalogue.js +100 -0
- package/parser/policy/catalogueSnapshot.js +7075 -0
- package/parser/policy/policyDocs.js +76 -0
- package/parser/policy/policyLiteral.js +227 -0
- package/parser/policy/validatePolicyRule.js +323 -0
- package/parser/policy_parser.js +463 -0
- package/parser/register.js +21 -0
- package/parser/tool_parser.js +32 -0
- package/server.js +6 -0
- package/utils.js +6 -2
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { CommentToken } from "../lexer/comment.js";
|
|
2
|
+
import {
|
|
3
|
+
ColonToken,
|
|
4
|
+
EqualToken,
|
|
5
|
+
LCurly,
|
|
6
|
+
LSquare,
|
|
7
|
+
RCurly,
|
|
8
|
+
RSquare,
|
|
9
|
+
} from "../lexer/control.js";
|
|
10
|
+
import { SingleQuotedStringLiteral, StringLiteral } from "../lexer/literal.js";
|
|
11
|
+
import { PolicyToken } from "../lexer/policy.js";
|
|
12
|
+
import { Identifier, NewlineToken } from "../lexer/tokens.js";
|
|
13
|
+
import { getPolicyCatalogue } from "../parser/policy/catalogue.js";
|
|
14
|
+
import { checkMarkdown, paramMarkdown } from "../parser/policy/policyDocs.js";
|
|
15
|
+
import { isA, own, policyStringValue } from "../parser/policy/policyLiteral.js";
|
|
16
|
+
import { HoverMessageProvider } from "./HoverMessageProvider.js";
|
|
17
|
+
|
|
18
|
+
const isString = (token) => isA(token, StringLiteral) || isA(token, SingleQuotedStringLiteral);
|
|
19
|
+
|
|
20
|
+
const unquote = policyStringValue;
|
|
21
|
+
|
|
22
|
+
/** The key a value at `k` belongs to: the word before the `=` or `:` just before it. */
|
|
23
|
+
function keyBefore(tokens, k) {
|
|
24
|
+
const sep = tokens[k - 1];
|
|
25
|
+
if ((isA(sep, EqualToken) || isA(sep, ColonToken)) && isA(tokens[k - 2], Identifier)) {
|
|
26
|
+
return tokens[k - 2].image;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The first word on the line holding token `k`. */
|
|
32
|
+
function lineHead(tokens, k) {
|
|
33
|
+
if (tokens[k - 1]?.image === "rule" && tokens[k - 2]?.image !== "policy") return "rule";
|
|
34
|
+
let j = k;
|
|
35
|
+
while (j > 0 && !isA(tokens[j - 1], NewlineToken)) j--;
|
|
36
|
+
return isA(tokens[j], Identifier) ? tokens[j].image : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The blocks open at `index`, outermost first, each with the check its rule names (read from the
|
|
41
|
+
* whole rule, so a `check` written after `params` still counts).
|
|
42
|
+
*/
|
|
43
|
+
function framesAt(tokens, index) {
|
|
44
|
+
const stack = [];
|
|
45
|
+
let at = null;
|
|
46
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
47
|
+
if (k === index) at = [...stack];
|
|
48
|
+
const token = tokens[k];
|
|
49
|
+
if (isA(token, LCurly) || isA(token, LSquare)) {
|
|
50
|
+
stack.push({
|
|
51
|
+
owner: keyBefore(tokens, k) ?? (isA(token, LCurly) ? lineHead(tokens, k) : null),
|
|
52
|
+
check: null,
|
|
53
|
+
});
|
|
54
|
+
} else if (isA(token, RCurly) || isA(token, RSquare)) {
|
|
55
|
+
stack.pop();
|
|
56
|
+
// Past the block that holds `index`: nothing later can change what it names.
|
|
57
|
+
if (at && stack.length < 2) break;
|
|
58
|
+
} else if (isString(token) && keyBefore(tokens, k) === "check" && stack.length) {
|
|
59
|
+
stack[stack.length - 1].check = unquote(token.image).trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return at ?? [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Markdown for a policy or rule field, from the catalogue's `document`. */
|
|
66
|
+
function fieldMarkdown(name, spec) {
|
|
67
|
+
const lines = [`**${name}** (${spec.type ?? "string"}${spec.required ? ", required" : ""})`];
|
|
68
|
+
if (spec.description) lines.push("", spec.description);
|
|
69
|
+
if (spec.values?.length) lines.push("", `One of: ${spec.values.map((v) => "`" + v + "`").join(", ")}.`);
|
|
70
|
+
if (spec.default !== undefined) lines.push("", `Default: \`${spec.default}\``);
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Hover inside a `policy` document: a policy or rule field shows what it is for; a rule's check id
|
|
76
|
+
* shows what the check does and its params; a param (or an object param's field) shows its type,
|
|
77
|
+
* values, default and description. All of it is rendered from the check catalogue the host loaded.
|
|
78
|
+
*/
|
|
79
|
+
export class PolicyMessageProvider extends HoverMessageProvider {
|
|
80
|
+
/**
|
|
81
|
+
* @returns {{kind: "field", name: string, spec: Object} | {kind: "check", check: Object}
|
|
82
|
+
* | {kind: "param", check: Object, path: string, spec: Object} | null}
|
|
83
|
+
*/
|
|
84
|
+
resolve(index, tokens) {
|
|
85
|
+
const head = tokens.find((t) => !isA(t, NewlineToken) && !isA(t, CommentToken));
|
|
86
|
+
if (!isA(head, PolicyToken)) return null;
|
|
87
|
+
const catalogue = getPolicyCatalogue();
|
|
88
|
+
if (!catalogue.source) return null;
|
|
89
|
+
|
|
90
|
+
const token = tokens[index];
|
|
91
|
+
const frames = framesAt(tokens, index);
|
|
92
|
+
const [policy, rule, params, nested] = frames;
|
|
93
|
+
if (policy?.owner !== "policy") return null;
|
|
94
|
+
|
|
95
|
+
// A field name: `name =` in the policy or in a rule.
|
|
96
|
+
if (isA(token, Identifier) && isA(tokens[index + 1], EqualToken)) {
|
|
97
|
+
const fields =
|
|
98
|
+
frames.length === 1
|
|
99
|
+
? catalogue.document?.fields
|
|
100
|
+
: frames.length === 2 && rule?.owner === "rule"
|
|
101
|
+
? catalogue.document?.rule
|
|
102
|
+
: null;
|
|
103
|
+
const spec = own(fields, token.image);
|
|
104
|
+
return spec ? { kind: "field", name: token.image, spec } : null;
|
|
105
|
+
}
|
|
106
|
+
if (rule?.owner !== "rule") return null;
|
|
107
|
+
|
|
108
|
+
if (frames.length === 2 && isString(token) && keyBefore(tokens, index) === "check") {
|
|
109
|
+
const check = catalogue.checks.get(unquote(token.image).trim());
|
|
110
|
+
return check ? { kind: "check", check } : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const check = catalogue.checks.get(rule.check ?? "");
|
|
114
|
+
if (!check || params?.owner !== "params" || !isA(token, Identifier) || !isA(tokens[index + 1], ColonToken)) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (frames.length === 3) {
|
|
118
|
+
const spec = own(check.params, token.image);
|
|
119
|
+
return spec ? { kind: "param", check, path: token.image, spec } : null;
|
|
120
|
+
}
|
|
121
|
+
if (frames.length === 4) {
|
|
122
|
+
const spec = own(own(check.params, nested.owner)?.properties, token.image);
|
|
123
|
+
return spec
|
|
124
|
+
? { kind: "param", check, path: `${nested.owner}.${token.image}`, spec }
|
|
125
|
+
: null;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
isMatch(index, tokens) {
|
|
131
|
+
return this.resolve(index, tokens) !== null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render(index, tokens) {
|
|
135
|
+
const found = this.resolve(index, tokens);
|
|
136
|
+
if (!found) return "";
|
|
137
|
+
if (found.kind === "field") return fieldMarkdown(found.name, found.spec);
|
|
138
|
+
return found.kind === "check"
|
|
139
|
+
? checkMarkdown(found.check, getPolicyCatalogue())
|
|
140
|
+
: paramMarkdown(found.check.id, found.path, found.spec);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/onHover/functions.md
CHANGED
|
@@ -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/onHover/onHover.js
CHANGED
|
@@ -7,10 +7,12 @@ import { InputFilterMessageProvider } from "./InputFilterMessageProvider.js";
|
|
|
7
7
|
import inputFiltersMd from "./inputFilters.md";
|
|
8
8
|
import { InputVariableMessageProvider } from "./InputVariableMessageProvider.js";
|
|
9
9
|
import { onHoverDocument } from "./onHoverDocument.js";
|
|
10
|
+
import { PolicyMessageProvider } from "./PolicyMessageProvider.js";
|
|
10
11
|
import { QueryFilterMessageProvider } from "./queryFilterMessageProvider.js";
|
|
11
12
|
import queryFiltersMd from "./queryFilters.md";
|
|
12
13
|
|
|
13
14
|
const hoverProviders = [
|
|
15
|
+
new PolicyMessageProvider(), // Check ids and params in a policy, from the check catalogue
|
|
14
16
|
new InputVariableMessageProvider(),
|
|
15
17
|
new FunctionMessageProvider(functionsMd),
|
|
16
18
|
new InputFilterMessageProvider(inputFiltersMd), // Input filters (columnDefinition context)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xano/xanoscript-language-server",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.18.0",
|
|
4
4
|
"description": "Language Server Protocol implementation for XanoScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"onCheckError/",
|
|
19
19
|
"onCompletion/contentAssist.js",
|
|
20
20
|
"onCompletion/onCompletion.js",
|
|
21
|
+
"onCompletion/policyCompletion.js",
|
|
21
22
|
"onDefinition/onDefinition.js",
|
|
22
23
|
"onDidChangeContent/",
|
|
23
24
|
"onHover/",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"lint": "eslint . --ext .js",
|
|
48
49
|
"lint:fix": "eslint . --ext .js --fix",
|
|
49
50
|
"tm": "node ./toTmLanguage.js",
|
|
51
|
+
"gen:policy-catalogue": "node scripts/gen-policy-catalogue.mjs",
|
|
50
52
|
"prepare": "husky"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
package/parser/base_parser.js
CHANGED
|
@@ -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
|
-
|
|
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($.
|
|
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($)
|