@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 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.7.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"
@@ -1,7 +1,11 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright 2026 Appstrate
3
3
 
4
- import { MCP_TOOL_NAME_MAX_LENGTH, MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH } from "./mcp-naming.ts";
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${fnv1a64(authKey).slice(0, API_TOOL_AUTH_HASH_HEX_LENGTH)}`;
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;
@@ -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 per spec.
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
- if (!files.has(entryPoint)) {
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
+ }
@@ -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
- * Canonicalise an untrusted upstream tool body before adding our namespace.
28
- * An upstream namespace is stripped so `drive__api-call` becomes `api_call`,
29
- * matching McpHost's outward naming contract.
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
- if (typeof raw !== "string") return "";
33
- let out = trimUnderscores(raw.replace(/[^a-zA-Z0-9_]+/g, "_").toLowerCase());
34
- const separator = out.indexOf("__");
35
- if (separator >= 0 && separator < out.length - 2) {
36
- out = out.slice(separator + 2);
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 out;
80
+ return hash.toString(16).padStart(16, "0");
39
81
  }
40
82
 
41
83
  /** Trim underscore runs in linear time without a backtracking expression. */
@@ -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
- * `@appstrate/core/semver` and `@appstrate/afps-runtime/bundle/semver-resolve`.
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).