@appstrate/afps-shared 0.7.0 → 0.9.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/README.md +1 -0
- package/package.json +5 -3
- package/src/api-tool-naming.ts +6 -13
- package/src/companion-files.ts +228 -4
- package/src/jsonpath.ts +161 -0
- package/src/mcp-naming.ts +52 -10
- package/src/semver-resolve.ts +2 -2
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ import each module by subpath.
|
|
|
35
35
|
| `./file-field` | File-field parsing helpers. |
|
|
36
36
|
| `./token-usage` | Token-usage accounting shapes. |
|
|
37
37
|
| `./backoff` | Retry backoff computation. |
|
|
38
|
+
| `./jsonpath` | The single-value RFC 9535 JSONPath subset every integration-manifest path field is read with (`identity_claims`, login-engine selectors). |
|
|
38
39
|
|
|
39
40
|
```ts
|
|
40
41
|
import { guardedFetch } from "@appstrate/afps-shared/guarded-fetch";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -51,12 +51,14 @@
|
|
|
51
51
|
"./unzip-bounded": "./src/unzip-bounded.ts",
|
|
52
52
|
"./archive-prefix": "./src/archive-prefix.ts",
|
|
53
53
|
"./backoff": "./src/backoff.ts",
|
|
54
|
-
"./mime": "./src/mime.ts"
|
|
54
|
+
"./mime": "./src/mime.ts",
|
|
55
|
+
"./jsonpath": "./src/jsonpath.ts"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
58
|
"@types/semver": "^7.8.0",
|
|
58
59
|
"fflate": "^0.8.3",
|
|
59
|
-
"semver": "^7.8.4"
|
|
60
|
+
"semver": "^7.8.4",
|
|
61
|
+
"yaml": "^2.9.0"
|
|
60
62
|
},
|
|
61
63
|
"peerDependencies": {
|
|
62
64
|
"typescript": ">=5 <8"
|
package/src/api-tool-naming.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright 2026 Appstrate
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
fnv1a64Hex,
|
|
6
|
+
MCP_TOOL_NAME_MAX_LENGTH,
|
|
7
|
+
MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH,
|
|
8
|
+
} from "./mcp-naming.ts";
|
|
5
9
|
|
|
6
10
|
/** Canonical unprefixed name of the credential-injecting API tool. */
|
|
7
11
|
export const API_CALL_TOOL_NAME = "api_call";
|
|
@@ -36,7 +40,7 @@ const API_TOOL_AUTH_HASH_HEX_LENGTH = API_TOOL_AUTH_TOKEN_LENGTH - 2;
|
|
|
36
40
|
*/
|
|
37
41
|
export function apiToolAuthToken(authKey: string): string {
|
|
38
42
|
if (authKey.length <= API_TOOL_RAW_AUTH_KEY_MAX_LENGTH) return authKey;
|
|
39
|
-
return `h0${
|
|
43
|
+
return `h0${fnv1a64Hex(authKey).slice(0, API_TOOL_AUTH_HASH_HEX_LENGTH)}`;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/** Throw when two distinct auth keys collapse onto the same bounded token. */
|
|
@@ -54,17 +58,6 @@ export function assertUniqueApiToolAuthTokens(authKeys: readonly string[]): void
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
function fnv1a64(value: string): string {
|
|
58
|
-
let hash = 0xcbf29ce484222325n;
|
|
59
|
-
const prime = 0x100000001b3n;
|
|
60
|
-
const mask = 0xffffffffffffffffn;
|
|
61
|
-
for (const byte of new TextEncoder().encode(value)) {
|
|
62
|
-
hash ^= BigInt(byte);
|
|
63
|
-
hash = (hash * prime) & mask;
|
|
64
|
-
}
|
|
65
|
-
return hash.toString(16).padStart(16, "0");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
61
|
/** Derive the unprefixed api_call name for one auth surface. */
|
|
69
62
|
export function apiCallToolNameForAuth(authKey: string, multiAuth: boolean): string {
|
|
70
63
|
return multiAuth ? `${API_CALL_TOOL_NAME}__${apiToolAuthToken(authKey)}` : API_CALL_TOOL_NAME;
|
package/src/companion-files.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* `bundle/validate-bundle.ts`; it is not a package subpath either.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { parse as parseYaml } from "yaml";
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* Stable, machine-readable companion-file violation reasons.
|
|
22
24
|
*/
|
|
@@ -24,7 +26,11 @@ export type CompanionViolationReason =
|
|
|
24
26
|
| "AGENT_MISSING_PROMPT"
|
|
25
27
|
| "AGENT_EMPTY_PROMPT"
|
|
26
28
|
| "SKILL_MISSING_SKILL_MD"
|
|
29
|
+
| "SKILL_INVALID_FRONTMATTER"
|
|
27
30
|
| "SKILL_MISSING_FRONTMATTER_NAME"
|
|
31
|
+
| "SKILL_INVALID_FRONTMATTER_NAME"
|
|
32
|
+
| "SKILL_MISSING_FRONTMATTER_DESCRIPTION"
|
|
33
|
+
| "SKILL_INVALID_FRONTMATTER_DESCRIPTION"
|
|
28
34
|
| "MCP_SERVER_MISSING_ENTRY_POINT";
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -70,6 +76,27 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
|
|
|
70
76
|
};
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Candidate spellings for a manifest-declared archive path, in order.
|
|
81
|
+
*
|
|
82
|
+
* A manifest may write `./server.js` or `server.js`; zip entries are stored
|
|
83
|
+
* without the `./` prefix. Only the leading `./` is normalised — `..`
|
|
84
|
+
* segments and absolute paths are NOT resolved, so this cannot widen the
|
|
85
|
+
* lookup beyond the archive root.
|
|
86
|
+
*/
|
|
87
|
+
function archivePathCandidates(declared: string): string[] {
|
|
88
|
+
const stripped = declared.startsWith("./") ? declared.slice(2) : declared;
|
|
89
|
+
return stripped === declared ? [declared] : [declared, stripped];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** First candidate spelling of `declared` present in `files`, else null. */
|
|
93
|
+
function resolveArchivePath(files: CompanionFileSource, declared: string): string | null {
|
|
94
|
+
for (const candidate of archivePathCandidates(declared)) {
|
|
95
|
+
if (files.has(candidate)) return candidate;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
73
100
|
/**
|
|
74
101
|
* Validate companion-file presence per AFPS §3.3 / §3.4 for the given
|
|
75
102
|
* package type. Returns the first violation encountered, or `null` when
|
|
@@ -77,8 +104,9 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
|
|
|
77
104
|
*
|
|
78
105
|
* The check is intentionally minimal and presence-focused:
|
|
79
106
|
* - `agent` → `prompt.md` present at root, non-empty bytes (§3.2).
|
|
80
|
-
* - `skill` → `SKILL.md` present at root, with YAML frontmatter `name`
|
|
81
|
-
* (§3.3). Missing `description` is tolerated
|
|
107
|
+
* - `skill` → `SKILL.md` present at root, with a YAML frontmatter `name`
|
|
108
|
+
* (§3.3). Missing `description` is tolerated — this is the LOADER gate;
|
|
109
|
+
* the producer rule is {@link checkSkillMarkdown}.
|
|
82
110
|
* - `mcp-server` → file at `manifest.server.entry_point` present in the
|
|
83
111
|
* archive (§3.4 "self-contained — every runtime dep bundled").
|
|
84
112
|
* - `integration` → no required companion (§3.5).
|
|
@@ -144,7 +172,14 @@ export function checkCompanionFiles(
|
|
|
144
172
|
message: "mcp-server manifest must declare server.entry_point",
|
|
145
173
|
};
|
|
146
174
|
}
|
|
147
|
-
|
|
175
|
+
// MCPB manifests conventionally write the entry point relative-explicit
|
|
176
|
+
// (`./server.js`), while archive entries are stored flat (`server.js`).
|
|
177
|
+
// Both spellings name the same file, so resolve either — an exact
|
|
178
|
+
// `has(entryPoint)` rejected every package in this repo's own
|
|
179
|
+
// `system-packages/` tree when imported through `POST /api/packages/import`,
|
|
180
|
+
// while the on-disk system-package loader accepted them. One archive, two
|
|
181
|
+
// verdicts, depending on which door it came through.
|
|
182
|
+
if (!resolveArchivePath(files, entryPoint)) {
|
|
148
183
|
return {
|
|
149
184
|
reason: "MCP_SERVER_MISSING_ENTRY_POINT",
|
|
150
185
|
message: `mcp-server archive missing server.entry_point payload: ${entryPoint}`,
|
|
@@ -169,6 +204,12 @@ function isEffectivelyEmpty(bytes: Uint8Array): boolean {
|
|
|
169
204
|
return true;
|
|
170
205
|
}
|
|
171
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Deliberately NOT {@link parseSkillFrontmatter}: published artifacts exist
|
|
209
|
+
* whose frontmatter `yaml` cannot parse at all (17 in production at the time of
|
|
210
|
+
* writing, each an unquoted `description: … : …`) and the run launcher has to
|
|
211
|
+
* keep serving them, so this probe's acceptance set may never shrink.
|
|
212
|
+
*/
|
|
172
213
|
function hasFrontmatterName(content: string): boolean {
|
|
173
214
|
const fmMatch = content.match(/^---[^\S\n]*\n([\s\S]*?)\n---/);
|
|
174
215
|
if (!fmMatch) return false;
|
|
@@ -177,8 +218,191 @@ function hasFrontmatterName(content: string): boolean {
|
|
|
177
218
|
if (!nameMatch) return false;
|
|
178
219
|
const raw = (nameMatch[1] ?? "").trim();
|
|
179
220
|
if (raw.length === 0) return false;
|
|
180
|
-
// Strip surrounding quotes to mirror extractSkillMeta's stripQuotes.
|
|
181
221
|
const unquoted = /^(['"])(.*)\1$/.exec(raw);
|
|
182
222
|
const value = unquoted ? unquoted[2] : raw;
|
|
183
223
|
return (value ?? "").trim().length > 0;
|
|
184
224
|
}
|
|
225
|
+
|
|
226
|
+
/** Agent Skills bounds, in code points. */
|
|
227
|
+
export const SKILL_NAME_MAX_LENGTH = 64;
|
|
228
|
+
export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
|
|
229
|
+
|
|
230
|
+
/** The bare slug an agent runtime addresses — NOT a `@scope/name` package id. */
|
|
231
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
232
|
+
|
|
233
|
+
function codePointLength(value: string): number {
|
|
234
|
+
return [...value].length;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function isValidSkillName(name: string): boolean {
|
|
238
|
+
return codePointLength(name) <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Parsed `SKILL.md` frontmatter. Never throws; a parse failure is `error`. */
|
|
242
|
+
export interface SkillFrontmatter {
|
|
243
|
+
found: boolean;
|
|
244
|
+
/** Opens with `---` but never closes the block. */
|
|
245
|
+
unterminated: boolean;
|
|
246
|
+
error: string | null;
|
|
247
|
+
name: string;
|
|
248
|
+
description: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Parses the way the skill runtime does, so the gate cannot accept what it fails to read. */
|
|
252
|
+
export function parseSkillFrontmatter(content: string): SkillFrontmatter {
|
|
253
|
+
const empty = { found: false, unterminated: false, error: null, name: "", description: "" };
|
|
254
|
+
|
|
255
|
+
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
256
|
+
if (!normalized.startsWith("---")) return empty;
|
|
257
|
+
const endIndex = normalized.indexOf("\n---", 3);
|
|
258
|
+
if (endIndex === -1) return { ...empty, unterminated: true };
|
|
259
|
+
|
|
260
|
+
const block = normalized.slice(4, endIndex);
|
|
261
|
+
|
|
262
|
+
let parsed: unknown;
|
|
263
|
+
try {
|
|
264
|
+
parsed = parseYaml(block, { uniqueKeys: true, strict: true });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return {
|
|
267
|
+
...empty,
|
|
268
|
+
found: true,
|
|
269
|
+
error: `frontmatter is not valid YAML: ${firstLine(err)}`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const mapping = parsed ?? {};
|
|
274
|
+
if (typeof mapping !== "object" || Array.isArray(mapping)) {
|
|
275
|
+
return {
|
|
276
|
+
...empty,
|
|
277
|
+
found: true,
|
|
278
|
+
error: "frontmatter is not valid YAML: expected a mapping of keys to values",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const record = mapping as Record<string, unknown>;
|
|
283
|
+
const name = readStringField(record, "name");
|
|
284
|
+
if (typeof name !== "string") return { ...empty, found: true, error: name.error };
|
|
285
|
+
const description = readStringField(record, "description");
|
|
286
|
+
if (typeof description !== "string") return { ...empty, found: true, error: description.error };
|
|
287
|
+
|
|
288
|
+
return { found: true, unterminated: false, error: null, name, description };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** An absent key and an empty scalar both mean "not provided" and yield `""`. */
|
|
292
|
+
function readStringField(record: Record<string, unknown>, key: string): string | { error: string } {
|
|
293
|
+
const value = record[key];
|
|
294
|
+
if (value === undefined || value === null) return "";
|
|
295
|
+
if (typeof value !== "string") {
|
|
296
|
+
return {
|
|
297
|
+
error: `frontmatter is not valid YAML: '${key}' must be a string, got ${describeType(value)}`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return value.trim();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function describeType(value: unknown): string {
|
|
304
|
+
if (Array.isArray(value)) return "a list";
|
|
305
|
+
if (typeof value === "object") return "a mapping";
|
|
306
|
+
return `a ${typeof value}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function startsWithBom(content: string): boolean {
|
|
310
|
+
return content.charCodeAt(0) === 0xfeff;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `ignoreBOM: true` reads backwards: it means "do not CONSUME the BOM". */
|
|
314
|
+
export function decodeSkillMarkdown(bytes: Uint8Array): string {
|
|
315
|
+
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function firstLine(err: unknown): string {
|
|
319
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
320
|
+
return message.split("\n")[0]!.trim();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The producer-side AFPS §3.3 gate: an Agent Skills `name`
|
|
325
|
+
* (https://agentskills.io/specification) plus a non-empty bounded `description`.
|
|
326
|
+
*/
|
|
327
|
+
export function checkSkillMarkdown(content: string): CompanionFileViolation | null {
|
|
328
|
+
// Rejected rather than stripped, and it stays rejected now that Pi >= 0.85
|
|
329
|
+
// strips a BOM itself: a minted version is immutable and has to load on every
|
|
330
|
+
// runtime image the platform ships, including the 0.84.x ones that test
|
|
331
|
+
// startsWith("---"), read no frontmatter behind a BOM and drop the skill.
|
|
332
|
+
// The platform's own loader eats it either way, so without this check the
|
|
333
|
+
// version would be minted and simply never load on those images.
|
|
334
|
+
if (startsWithBom(content)) {
|
|
335
|
+
return {
|
|
336
|
+
reason: "SKILL_INVALID_FRONTMATTER",
|
|
337
|
+
message:
|
|
338
|
+
"skill SKILL.md starts with a byte-order mark (U+FEFF); remove it — " +
|
|
339
|
+
"older runtimes read no frontmatter behind a BOM and drop the skill",
|
|
340
|
+
path: "SKILL.md",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const { unterminated, error, name, description } = parseSkillFrontmatter(content);
|
|
345
|
+
|
|
346
|
+
if (unterminated) {
|
|
347
|
+
return {
|
|
348
|
+
reason: "SKILL_MISSING_FRONTMATTER_NAME",
|
|
349
|
+
message: "skill SKILL.md frontmatter block is not closed (expected a second '---' line)",
|
|
350
|
+
path: "SKILL.md",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (error) {
|
|
354
|
+
return {
|
|
355
|
+
reason: "SKILL_INVALID_FRONTMATTER",
|
|
356
|
+
message: `skill SKILL.md ${error}`,
|
|
357
|
+
path: "SKILL.md",
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (!name) {
|
|
361
|
+
return {
|
|
362
|
+
reason: "SKILL_MISSING_FRONTMATTER_NAME",
|
|
363
|
+
message: "skill SKILL.md must declare a 'name' in YAML frontmatter",
|
|
364
|
+
path: "SKILL.md",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (!isValidSkillName(name)) {
|
|
368
|
+
return {
|
|
369
|
+
reason: "SKILL_INVALID_FRONTMATTER_NAME",
|
|
370
|
+
message:
|
|
371
|
+
`skill SKILL.md 'name' must be 1-${SKILL_NAME_MAX_LENGTH} characters of lowercase ` +
|
|
372
|
+
`a-z, 0-9 and '-', with no leading or trailing hyphen and no consecutive hyphens ` +
|
|
373
|
+
`(got '${name}')`,
|
|
374
|
+
path: "SKILL.md",
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (!description) {
|
|
378
|
+
return {
|
|
379
|
+
reason: "SKILL_MISSING_FRONTMATTER_DESCRIPTION",
|
|
380
|
+
message: "skill SKILL.md must declare a non-empty 'description' in YAML frontmatter",
|
|
381
|
+
path: "SKILL.md",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const descriptionLength = codePointLength(description);
|
|
385
|
+
if (descriptionLength > SKILL_DESCRIPTION_MAX_LENGTH) {
|
|
386
|
+
return {
|
|
387
|
+
reason: "SKILL_INVALID_FRONTMATTER_DESCRIPTION",
|
|
388
|
+
message:
|
|
389
|
+
`skill SKILL.md 'description' must be at most ${SKILL_DESCRIPTION_MAX_LENGTH} ` +
|
|
390
|
+
`characters (got ${descriptionLength})`,
|
|
391
|
+
path: "SKILL.md",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Containment: `name:\n triage` is valid YAML the loader's probe cannot
|
|
396
|
+
// read, so accepting it would mint a version the run launcher cannot load.
|
|
397
|
+
if (!hasFrontmatterName(content)) {
|
|
398
|
+
return {
|
|
399
|
+
reason: "SKILL_INVALID_FRONTMATTER_NAME",
|
|
400
|
+
message:
|
|
401
|
+
`skill SKILL.md 'name' must be written inline on one line, e.g. "name: my-skill" ` +
|
|
402
|
+
`(a name on a following line, or a space before the colon, makes the platform's ` +
|
|
403
|
+
`package loader unable to read it)`,
|
|
404
|
+
path: "SKILL.md",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
package/src/jsonpath.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The JSONPath dialect of AFPS integration manifests — a single-value RFC 9535
|
|
5
|
+
* subset: `$`, `.name` (member-name-shorthand), `['name']` / `["name"]` (RFC
|
|
6
|
+
* 9535 string literals and escapes), `[0]` / `[-1]` (RFC 9535 `int`). Anything
|
|
7
|
+
* else throws {@link JsonPathSyntaxError} rather than silently missing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class JsonPathSyntaxError extends Error {
|
|
11
|
+
override readonly name = "JsonPathSyntaxError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type JsonPathSegment = string | number;
|
|
15
|
+
|
|
16
|
+
const SIMPLE_ESCAPES: Record<string, string> = {
|
|
17
|
+
b: "\b",
|
|
18
|
+
f: "\f",
|
|
19
|
+
n: "\n",
|
|
20
|
+
r: "\r",
|
|
21
|
+
t: "\t",
|
|
22
|
+
"/": "/",
|
|
23
|
+
"\\": "\\",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const isDigit = (ch: string) => ch >= "0" && ch <= "9";
|
|
27
|
+
const isNameFirst = (ch: string) =>
|
|
28
|
+
(ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_" || ch.charCodeAt(0) >= 0x80;
|
|
29
|
+
const isBlank = (ch: string | undefined) => ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
|
|
30
|
+
|
|
31
|
+
/** Tokenize `path` into member names and array indices. Throws on anything outside the subset. */
|
|
32
|
+
export function parseJsonPath(path: string): JsonPathSegment[] {
|
|
33
|
+
const fail = (at: number, what: string): never => {
|
|
34
|
+
throw new JsonPathSyntaxError(`jsonpath '${path}' at offset ${at}: ${what}`);
|
|
35
|
+
};
|
|
36
|
+
if (path[0] !== "$") fail(0, "must start with '$'");
|
|
37
|
+
|
|
38
|
+
const segments: JsonPathSegment[] = [];
|
|
39
|
+
let i = 1;
|
|
40
|
+
const skipBlank = () => {
|
|
41
|
+
while (isBlank(path[i])) i++;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const readString = (quote: string): string => {
|
|
45
|
+
const start = i;
|
|
46
|
+
i++;
|
|
47
|
+
let out = "";
|
|
48
|
+
while (i < path.length) {
|
|
49
|
+
const ch = path[i]!;
|
|
50
|
+
if (ch === quote) {
|
|
51
|
+
i++;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
if (ch.charCodeAt(0) < 0x20) fail(i, "unescaped control character in string");
|
|
55
|
+
if (ch !== "\\") {
|
|
56
|
+
out += ch;
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const esc = path[i + 1];
|
|
61
|
+
if (esc === quote) out += quote;
|
|
62
|
+
else if (esc !== undefined && esc in SIMPLE_ESCAPES) out += SIMPLE_ESCAPES[esc];
|
|
63
|
+
else if (esc === "u") {
|
|
64
|
+
const unit = readHex4(i + 2);
|
|
65
|
+
if (unit >= 0xdc00 && unit <= 0xdfff) fail(i, "lone low surrogate escape");
|
|
66
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
67
|
+
const low = path.startsWith("\\u", i + 6) ? readHex4(i + 8) : -1;
|
|
68
|
+
if (low < 0xdc00 || low > 0xdfff)
|
|
69
|
+
fail(i, "high surrogate escape without a low surrogate");
|
|
70
|
+
out += String.fromCharCode(unit, low);
|
|
71
|
+
i += 12;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
out += String.fromCharCode(unit);
|
|
75
|
+
i += 6;
|
|
76
|
+
continue;
|
|
77
|
+
} else fail(i, `invalid escape '\\${esc ?? ""}'`);
|
|
78
|
+
i += 2;
|
|
79
|
+
}
|
|
80
|
+
return fail(start, "unterminated string");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const readHex4 = (at: number): number => {
|
|
84
|
+
const hex = path.slice(at, at + 4);
|
|
85
|
+
if (!/^[0-9A-Fa-f]{4}$/.test(hex)) fail(at, "\\u must be followed by 4 hex digits");
|
|
86
|
+
return parseInt(hex, 16);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const readInt = (): number => {
|
|
90
|
+
const start = i;
|
|
91
|
+
if (path[i] === "-") i++;
|
|
92
|
+
const digitsAt = i;
|
|
93
|
+
while (isDigit(path[i] ?? "")) i++;
|
|
94
|
+
const text = path.slice(start, i);
|
|
95
|
+
if (i === digitsAt) fail(start, "expected an index");
|
|
96
|
+
if (text === "-0" || (path[digitsAt] === "0" && i - digitsAt > 1)) {
|
|
97
|
+
fail(start, `index '${text}' is not an RFC 9535 int (no leading zeros, no -0)`);
|
|
98
|
+
}
|
|
99
|
+
const n = Number(text);
|
|
100
|
+
if (!Number.isSafeInteger(n)) fail(start, `index '${text}' is out of range`);
|
|
101
|
+
return n;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
while (i < path.length) {
|
|
105
|
+
const ch = path[i]!;
|
|
106
|
+
if (ch === ".") {
|
|
107
|
+
i++;
|
|
108
|
+
const start = i;
|
|
109
|
+
const first = path[i];
|
|
110
|
+
if (first === ".") fail(start, "recursive descent is not supported");
|
|
111
|
+
if (first === "*") fail(start, "a wildcard is not supported");
|
|
112
|
+
if (first !== undefined && isDigit(first)) {
|
|
113
|
+
fail(
|
|
114
|
+
start,
|
|
115
|
+
"a member name cannot start with a digit — write an index as [0] or a member as ['0']",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (first === undefined || !isNameFirst(first)) fail(start, "expected a member name");
|
|
119
|
+
while (i < path.length && (isNameFirst(path[i]!) || isDigit(path[i]!))) i++;
|
|
120
|
+
segments.push(path.slice(start, i));
|
|
121
|
+
} else if (ch === "[") {
|
|
122
|
+
i++;
|
|
123
|
+
skipBlank();
|
|
124
|
+
const sel = path[i];
|
|
125
|
+
if (sel === "'" || sel === '"') segments.push(readString(sel));
|
|
126
|
+
else if (sel === "-" || (sel !== undefined && isDigit(sel))) segments.push(readInt());
|
|
127
|
+
else if (sel === undefined) fail(i, "unterminated '['");
|
|
128
|
+
else
|
|
129
|
+
fail(i, `unsupported selector '${sel}' (wildcards, filters and slices are not supported)`);
|
|
130
|
+
skipBlank();
|
|
131
|
+
if (path[i] === ",") fail(i, "a union of selectors is not supported");
|
|
132
|
+
if (path[i] === ":") fail(i, "a slice is not supported");
|
|
133
|
+
if (path[i] !== "]") fail(i, path[i] === undefined ? "unterminated '['" : "expected ']'");
|
|
134
|
+
i++;
|
|
135
|
+
} else {
|
|
136
|
+
fail(i, `unexpected character '${ch}'`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return segments;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Evaluate `path` against `root`. Returns `undefined` when the path is valid
|
|
144
|
+
* but selects nothing; throws {@link JsonPathSyntaxError} when it is invalid.
|
|
145
|
+
* Members are own properties only, so `$.constructor` cannot reach a prototype.
|
|
146
|
+
*/
|
|
147
|
+
export function evaluateJsonPath(root: unknown, path: string): unknown {
|
|
148
|
+
let cur: unknown = root;
|
|
149
|
+
for (const seg of parseJsonPath(path)) {
|
|
150
|
+
if (cur === null || typeof cur !== "object") return undefined;
|
|
151
|
+
if (typeof seg === "number") {
|
|
152
|
+
if (!Array.isArray(cur)) return undefined;
|
|
153
|
+
cur = cur[seg < 0 ? cur.length + seg : seg];
|
|
154
|
+
} else {
|
|
155
|
+
// Not `Object.hasOwn`: the web app compiles this leaf against ES2020.
|
|
156
|
+
if (Array.isArray(cur) || !Object.prototype.hasOwnProperty.call(cur, seg)) return undefined;
|
|
157
|
+
cur = (cur as Record<string, unknown>)[seg];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return cur;
|
|
161
|
+
}
|
package/src/mcp-naming.ts
CHANGED
|
@@ -13,7 +13,6 @@ export const MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH = 20;
|
|
|
13
13
|
* snake-case namespace capped before collision suffixing.
|
|
14
14
|
*/
|
|
15
15
|
export function normaliseMcpToolNamespace(raw: string): string {
|
|
16
|
-
if (typeof raw !== "string") return "";
|
|
17
16
|
const out = trimUnderscores(
|
|
18
17
|
raw
|
|
19
18
|
.replace(/^@/, "")
|
|
@@ -24,18 +23,61 @@ export function normaliseMcpToolNamespace(raw: string): string {
|
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
|
|
26
|
+
* Exposed tool-name grammar: `{snake_namespace}__{body}`, the body in the LLM
|
|
27
|
+
* providers' `^[a-zA-Z0-9_-]{1,64}$` alphabet (56-char ceiling: re-prefix headroom).
|
|
28
|
+
*/
|
|
29
|
+
const MCP_TOOL_NAME_PATTERN = /^[a-z0-9][a-z0-9_]*__[A-Za-z0-9_-]+$/;
|
|
30
|
+
|
|
31
|
+
export function isValidMcpToolName(name: string): boolean {
|
|
32
|
+
return name.length <= MCP_TOOL_NAME_MAX_LENGTH && MCP_TOOL_NAME_PATTERN.test(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map an untrusted upstream tool name onto the body alphabet: each rejected
|
|
37
|
+
* code point becomes `_`; nothing else changes.
|
|
30
38
|
*/
|
|
31
39
|
export function normaliseMcpToolBody(raw: string): string {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
return raw.replace(/[^A-Za-z0-9_-]/gu, "_");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MCP_TOOL_HASH_LENGTH = 8;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Exposed name for an untrusted upstream tool: `{namespace}__{body}` when it
|
|
47
|
+
* fits and is free, else the body cut to fit plus a hash of the ORIGINAL
|
|
48
|
+
* upstream name, so two names that normalise to the same body (`a.b`, `a_b`)
|
|
49
|
+
* still get distinct names. Which of them keeps the plain form depends on
|
|
50
|
+
* registration order: the first one registered. One salted re-hash on
|
|
51
|
+
* collision, then it throws.
|
|
52
|
+
*/
|
|
53
|
+
export function allocateMcpToolName(
|
|
54
|
+
namespace: string,
|
|
55
|
+
upstreamName: string,
|
|
56
|
+
taken: (name: string) => boolean,
|
|
57
|
+
): string {
|
|
58
|
+
const body = normaliseMcpToolBody(upstreamName);
|
|
59
|
+
const plain = `${namespace}__${body}`;
|
|
60
|
+
if (isValidMcpToolName(plain) && !taken(plain)) return plain;
|
|
61
|
+
const budget = MCP_TOOL_NAME_MAX_LENGTH - namespace.length - 2 - MCP_TOOL_HASH_LENGTH - 1;
|
|
62
|
+
const head = body.slice(0, Math.max(0, budget));
|
|
63
|
+
for (const seed of [upstreamName, `${upstreamName}\0`]) {
|
|
64
|
+
const hash = fnv1a64Hex(seed).slice(0, MCP_TOOL_HASH_LENGTH);
|
|
65
|
+
const candidate = head ? `${namespace}__${head}_${hash}` : `${namespace}__${hash}`;
|
|
66
|
+
if (!taken(candidate)) return candidate;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`MCP tool name ${JSON.stringify(upstreamName)} collides after re-hashing`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 64-bit FNV-1a over the UTF-8 bytes, as 16 lowercase hex digits. */
|
|
72
|
+
export function fnv1a64Hex(value: string): string {
|
|
73
|
+
let hash = 0xcbf29ce484222325n;
|
|
74
|
+
const prime = 0x100000001b3n;
|
|
75
|
+
const mask = 0xffffffffffffffffn;
|
|
76
|
+
for (const byte of new TextEncoder().encode(value)) {
|
|
77
|
+
hash ^= BigInt(byte);
|
|
78
|
+
hash = (hash * prime) & mask;
|
|
37
79
|
}
|
|
38
|
-
return
|
|
80
|
+
return hash.toString(16).padStart(16, "0");
|
|
39
81
|
}
|
|
40
82
|
|
|
41
83
|
/** Trim underscore runs in linear time without a backtracking expression. */
|
package/src/semver-resolve.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* string, and for applying yank policy by pre-filtering the
|
|
10
10
|
* `rangeVersions` and `distTags` inputs accordingly.
|
|
11
11
|
*
|
|
12
|
-
* This is the SINGLE source of truth, re-exported by
|
|
13
|
-
*
|
|
12
|
+
* This is the SINGLE source of truth, re-exported by `@appstrate/core/semver`
|
|
13
|
+
* and imported directly by `@appstrate/afps-runtime`.
|
|
14
14
|
*
|
|
15
15
|
* Conventional yank policy (matches npm/crates.io):
|
|
16
16
|
* - `exactVersions`: include yanked (exact pins always resolve).
|