From ee94fed557b56b7af54f17ec606f7925049df50c Mon Sep 17 00:00:00 2001 From: jottakka Date: Fri, 17 Apr 2026 00:29:42 -0300 Subject: [PATCH 1/6] fix: resolve rc precedence and metadata fallback drift Ensure version coherence prefers stable releases over matching pre-release tags, and make fetchAll toolkit metadata resolution use the same providerId fallback path as single-toolkit fetches. Made-with: Cursor --- .../src/sources/toolkit-data-source.ts | 44 ++++++--- .../src/utils/version-coherence.ts | 90 +++++++++++++++---- .../tests/sources/toolkit-data-source.test.ts | 31 +++++++ .../tests/utils/version-coherence.test.ts | 18 ++++ 4 files changed, 152 insertions(+), 31 deletions(-) diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index 52e85c386..9e25e5288 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -108,19 +108,17 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { this.metadataSource = config.metadataSource; } - async fetchToolkitData( + private async resolveMetadata( toolkitId: string, - version?: string - ): Promise { - // Fetch tools and metadata in parallel - const [tools, fetchedMetadata] = await Promise.all([ - this.toolSource.fetchToolsByToolkit(toolkitId), - this.metadataSource.getToolkitMetadata(toolkitId), - ]); + tools: readonly ToolDefinition[], + directMetadata: ToolkitMetadata | null + ): Promise { + let metadata = + directMetadata ?? + (await this.metadataSource.getToolkitMetadata(toolkitId)); // If the toolkit isn't in the Design System under its exact ID, try to match - // based on the auth provider for "*Api" toolkits (e.g. MailchimpMarketingApi -> MailchimpApi). - let metadata = fetchedMetadata; + // by auth provider for "*Api" toolkits (e.g. MailchimpMarketingApi -> MailchimpApi). if (!metadata && normalizeId(toolkitId).endsWith("api")) { const providerId = tools.find((t) => t.auth?.providerId)?.auth ?.providerId; @@ -131,6 +129,24 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { } } + return metadata; + } + + async fetchToolkitData( + toolkitId: string, + version?: string + ): Promise { + // Fetch tools and metadata in parallel + const [tools, directMetadata] = await Promise.all([ + this.toolSource.fetchToolsByToolkit(toolkitId), + this.metadataSource.getToolkitMetadata(toolkitId), + ]); + const metadata = await this.resolveMetadata( + toolkitId, + tools, + directMetadata + ); + // Filter tools by version if specified, otherwise keep only the highest // version to drop stale tools from older releases that Engine still serves. const filteredTools = version @@ -185,9 +201,11 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { const result = new Map(); for (const [toolkitId, tools] of toolkitGroups) { const directMetadata = metadataMap.get(toolkitId) ?? null; - const metadata = - directMetadata ?? - (await this.metadataSource.getToolkitMetadata(toolkitId)); + const metadata = await this.resolveMetadata( + toolkitId, + tools, + directMetadata + ); result.set(toolkitId, { tools, metadata }); } diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts index 47ffedc82..5f2f23b7a 100644 --- a/toolkit-docs-generator/src/utils/version-coherence.ts +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -1,38 +1,92 @@ import type { ToolDefinition } from "../types/index.js"; import { extractVersion } from "./fp.js"; +interface ParsedSemver { + readonly core: readonly number[]; + readonly prerelease: readonly (number | string)[] | null; +} + /** - * Parse the numeric MAJOR.MINOR.PATCH tuple from a semver string, - * stripping pre-release (`-alpha.1`) and build metadata (`+build.456`). - * - * Handles formats produced by arcade-mcp's normalize_version(): - * "3.1.3", "1.2.3-beta.1", "1.2.3+build.456", "1.2.3-rc.1+build.789" + * Parse a semver-ish string into numeric core + optional pre-release parts. + * Build metadata (+...) is ignored for ordering. */ -const parseNumericVersion = (version: string): number[] => { - // Strip build metadata (after +) then pre-release (after -) - const core = version.split("+")[0]?.split("-")[0] ?? version; - return core.split(".").map((s) => { - const n = Number(s); +const parseSemver = (version: string): ParsedSemver => { + const withoutBuild = version.split("+")[0] ?? version; + const prereleaseIndex = withoutBuild.indexOf("-"); + const coreText = + prereleaseIndex >= 0 + ? withoutBuild.slice(0, prereleaseIndex) + : withoutBuild; + const prereleaseText = + prereleaseIndex >= 0 ? withoutBuild.slice(prereleaseIndex + 1) : ""; + + const core = coreText.split(".").map((segment) => { + const n = Number(segment); return Number.isNaN(n) ? 0 : n; }); + + const prerelease = + prereleaseText.length > 0 + ? prereleaseText.split(".").map((identifier) => { + const n = Number(identifier); + return Number.isNaN(n) ? identifier : n; + }) + : null; + + return { core, prerelease }; }; /** - * Compare two semver version strings numerically by MAJOR.MINOR.PATCH. - * Pre-release and build metadata are ignored for ordering purposes - * (they are unlikely to appear in Engine API responses, but we handle - * them defensively since arcade-mcp's semver allows them). + * Compare two semver version strings using semver precedence rules: + * - Compare MAJOR.MINOR.PATCH numerically + * - For equal core versions, stable release > pre-release + * - Numeric pre-release identifiers compare numerically + * - String pre-release identifiers compare lexicographically + * - Build metadata is ignored * * Returns a positive number if a > b, negative if a < b, 0 if equal. */ const compareVersions = (a: string, b: string): number => { - const aParts = parseNumericVersion(a); - const bParts = parseNumericVersion(b); - const len = Math.max(aParts.length, bParts.length); + const aSemver = parseSemver(a); + const bSemver = parseSemver(b); + const len = Math.max(aSemver.core.length, bSemver.core.length); for (let i = 0; i < len; i++) { - const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0); + const diff = (aSemver.core[i] ?? 0) - (bSemver.core[i] ?? 0); if (diff !== 0) return diff; } + + const aPrerelease = aSemver.prerelease; + const bPrerelease = bSemver.prerelease; + if (!(aPrerelease || bPrerelease)) return 0; + if (!aPrerelease && bPrerelease) return 1; + if (aPrerelease && !bPrerelease) return -1; + + const prereleaseLen = Math.max( + aPrerelease?.length ?? 0, + bPrerelease?.length ?? 0 + ); + for (let i = 0; i < prereleaseLen; i++) { + const aPart = aPrerelease?.[i]; + const bPart = bPrerelease?.[i]; + + if (aPart === undefined && bPart !== undefined) return -1; + if (aPart !== undefined && bPart === undefined) return 1; + if (aPart === undefined && bPart === undefined) return 0; + + const aIsNumber = typeof aPart === "number"; + const bIsNumber = typeof bPart === "number"; + if (aIsNumber && bIsNumber) { + const diff = (aPart as number) - (bPart as number); + if (diff !== 0) return diff; + continue; + } + if (aIsNumber && !bIsNumber) return -1; + if (!aIsNumber && bIsNumber) return 1; + + const diff = String(aPart).localeCompare(String(bPart), "en"); + if (diff !== 0) return diff; + } + return 0; }; diff --git a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts index 5b014cba6..1968e21be 100644 --- a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts +++ b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts @@ -277,4 +277,35 @@ describe("CombinedToolkitDataSource", () => { const result = await dataSource.fetchToolkitData("MailchimpMarketingApi"); expect(result.metadata?.label).toBe("Mailchimp API"); }); + + it("should use providerId fallback consistently in fetchAllToolkitsData", async () => { + const toolSource = new InMemoryToolDataSource([ + createTool({ + qualifiedName: "MailchimpMarketingApi.CreateCampaign", + fullyQualifiedName: "MailchimpMarketingApi.CreateCampaign@1.0.0", + auth: { providerId: "mailchimp", providerType: "oauth2", scopes: [] }, + }), + ]); + + const metadataSource = new InMemoryMetadataSource([ + createMetadata({ + id: "MailchimpApi", + label: "Mailchimp API", + category: "productivity", + type: "arcade_starter", + }), + ]); + + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + const single = await dataSource.fetchToolkitData("MailchimpMarketingApi"); + const all = await dataSource.fetchAllToolkitsData(); + const listed = all.get("MailchimpMarketingApi"); + + expect(single.metadata?.label).toBe("Mailchimp API"); + expect(listed?.metadata?.label).toBe("Mailchimp API"); + }); }); diff --git a/toolkit-docs-generator/tests/utils/version-coherence.test.ts b/toolkit-docs-generator/tests/utils/version-coherence.test.ts index e399c859b..35f97fd8c 100644 --- a/toolkit-docs-generator/tests/utils/version-coherence.test.ts +++ b/toolkit-docs-generator/tests/utils/version-coherence.test.ts @@ -110,6 +110,14 @@ describe("getHighestVersion", () => { ]; expect(getHighestVersion(tools)).toBe("4.0.0-beta.1+build.123"); }); + + it("prefers stable release over pre-release with same core version", () => { + const tools = [ + createTool("Github.CreateIssue@1.23-rc.1"), + createTool("Github.SetStarred@1.23"), + ]; + expect(getHighestVersion(tools)).toBe("1.23"); + }); }); describe("filterToolsByHighestVersion", () => { @@ -187,4 +195,14 @@ describe("filterToolsByHighestVersion", () => { true ); }); + + it("drops matching pre-release tools when stable release exists", () => { + const tools = [ + createTool("Github.CreateIssue@1.23-rc.1"), + createTool("Github.SetStarred@1.23"), + ]; + const result = filterToolsByHighestVersion(tools); + expect(result).toHaveLength(1); + expect(result[0]?.fullyQualifiedName).toBe("Github.SetStarred@1.23"); + }); }); From ccf12a11fb6a7149f976bb8f3faca590438801cd Mon Sep 17 00:00:00 2001 From: jottakka Date: Fri, 17 Apr 2026 00:30:19 -0300 Subject: [PATCH 2/6] refactor: split semver comparison into helpers Reduce comparator complexity while preserving semver precedence behavior for stable and pre-release versions. Made-with: Cursor --- .../src/utils/version-coherence.ts | 95 +++++++++++-------- 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts index 5f2f23b7a..38ba6b3ab 100644 --- a/toolkit-docs-generator/src/utils/version-coherence.ts +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -6,6 +6,59 @@ interface ParsedSemver { readonly prerelease: readonly (number | string)[] | null; } +const compareCoreVersions = ( + aCore: readonly number[], + bCore: readonly number[] +): number => { + const len = Math.max(aCore.length, bCore.length); + for (let i = 0; i < len; i++) { + const diff = (aCore[i] ?? 0) - (bCore[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +}; + +const comparePrereleaseIdentifier = ( + aPart: number | string, + bPart: number | string +): number => { + const aIsNumber = typeof aPart === "number"; + const bIsNumber = typeof bPart === "number"; + if (aIsNumber && bIsNumber) { + return aPart - bPart; + } + if (aIsNumber && !bIsNumber) return -1; + if (!aIsNumber && bIsNumber) return 1; + return String(aPart).localeCompare(String(bPart), "en"); +}; + +const comparePrerelease = ( + aPrerelease: readonly (number | string)[] | null, + bPrerelease: readonly (number | string)[] | null +): number => { + if (!(aPrerelease || bPrerelease)) return 0; + if (!aPrerelease && bPrerelease) return 1; + if (aPrerelease && !bPrerelease) return -1; + + const prereleaseLen = Math.max( + aPrerelease?.length ?? 0, + bPrerelease?.length ?? 0 + ); + for (let i = 0; i < prereleaseLen; i++) { + const aPart = aPrerelease?.[i]; + const bPart = bPrerelease?.[i]; + + if (aPart === undefined && bPart !== undefined) return -1; + if (aPart !== undefined && bPart === undefined) return 1; + if (aPart === undefined && bPart === undefined) return 0; + + const diff = comparePrereleaseIdentifier(aPart, bPart); + if (diff !== 0) return diff; + } + + return 0; +}; + /** * Parse a semver-ish string into numeric core + optional pre-release parts. * Build metadata (+...) is ignored for ordering. @@ -49,45 +102,9 @@ const parseSemver = (version: string): ParsedSemver => { const compareVersions = (a: string, b: string): number => { const aSemver = parseSemver(a); const bSemver = parseSemver(b); - const len = Math.max(aSemver.core.length, bSemver.core.length); - for (let i = 0; i < len; i++) { - const diff = (aSemver.core[i] ?? 0) - (bSemver.core[i] ?? 0); - if (diff !== 0) return diff; - } - - const aPrerelease = aSemver.prerelease; - const bPrerelease = bSemver.prerelease; - if (!(aPrerelease || bPrerelease)) return 0; - if (!aPrerelease && bPrerelease) return 1; - if (aPrerelease && !bPrerelease) return -1; - - const prereleaseLen = Math.max( - aPrerelease?.length ?? 0, - bPrerelease?.length ?? 0 - ); - for (let i = 0; i < prereleaseLen; i++) { - const aPart = aPrerelease?.[i]; - const bPart = bPrerelease?.[i]; - - if (aPart === undefined && bPart !== undefined) return -1; - if (aPart !== undefined && bPart === undefined) return 1; - if (aPart === undefined && bPart === undefined) return 0; - - const aIsNumber = typeof aPart === "number"; - const bIsNumber = typeof bPart === "number"; - if (aIsNumber && bIsNumber) { - const diff = (aPart as number) - (bPart as number); - if (diff !== 0) return diff; - continue; - } - if (aIsNumber && !bIsNumber) return -1; - if (!aIsNumber && bIsNumber) return 1; - - const diff = String(aPart).localeCompare(String(bPart), "en"); - if (diff !== 0) return diff; - } - - return 0; + const coreDiff = compareCoreVersions(aSemver.core, bSemver.core); + if (coreDiff !== 0) return coreDiff; + return comparePrerelease(aSemver.prerelease, bSemver.prerelease); }; /** From b0b14853e1fa03c25ae78f8ae437f43abf80d908 Mon Sep 17 00:00:00 2001 From: jottakka Date: Fri, 17 Apr 2026 00:30:36 -0300 Subject: [PATCH 3/6] refactor: simplify prerelease comparator branching Extract optional prerelease part comparison so the semver helper stays readable and below complexity warning thresholds. Made-with: Cursor --- .../src/utils/version-coherence.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts index 38ba6b3ab..a3db487d7 100644 --- a/toolkit-docs-generator/src/utils/version-coherence.ts +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -32,6 +32,16 @@ const comparePrereleaseIdentifier = ( return String(aPart).localeCompare(String(bPart), "en"); }; +const compareOptionalPrereleasePart = ( + aPart: number | string | undefined, + bPart: number | string | undefined +): number => { + if (aPart === undefined && bPart === undefined) return 0; + if (aPart === undefined) return -1; + if (bPart === undefined) return 1; + return comparePrereleaseIdentifier(aPart, bPart); +}; + const comparePrerelease = ( aPrerelease: readonly (number | string)[] | null, bPrerelease: readonly (number | string)[] | null @@ -45,14 +55,10 @@ const comparePrerelease = ( bPrerelease?.length ?? 0 ); for (let i = 0; i < prereleaseLen; i++) { - const aPart = aPrerelease?.[i]; - const bPart = bPrerelease?.[i]; - - if (aPart === undefined && bPart !== undefined) return -1; - if (aPart !== undefined && bPart === undefined) return 1; - if (aPart === undefined && bPart === undefined) return 0; - - const diff = comparePrereleaseIdentifier(aPart, bPart); + const diff = compareOptionalPrereleasePart( + aPrerelease?.[i], + bPrerelease?.[i] + ); if (diff !== 0) return diff; } From e8dfc9f89020bdef0b8de47665fe191d0e0c2147 Mon Sep 17 00:00:00 2001 From: jottakka Date: Fri, 17 Apr 2026 13:22:19 -0300 Subject: [PATCH 4/6] test: expand version and metadata fallback coverage Fix redundant metadata lookups in fetchToolkitData and add regression tests for semver prerelease ordering plus metadata lookup behavior across single and bulk toolkit paths. Made-with: Cursor --- .../src/sources/toolkit-data-source.ts | 16 +++-- .../tests/sources/toolkit-data-source.test.ts | 68 +++++++++++++++++++ .../tests/utils/version-coherence.test.ts | 35 ++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index 9e25e5288..3b25fe471 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -111,11 +111,13 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { private async resolveMetadata( toolkitId: string, tools: readonly ToolDefinition[], - directMetadata: ToolkitMetadata | null + directMetadata: ToolkitMetadata | null, + options?: { readonly allowToolkitIdLookup?: boolean } ): Promise { - let metadata = - directMetadata ?? - (await this.metadataSource.getToolkitMetadata(toolkitId)); + let metadata = directMetadata; + if (!metadata && options?.allowToolkitIdLookup) { + metadata = await this.metadataSource.getToolkitMetadata(toolkitId); + } // If the toolkit isn't in the Design System under its exact ID, try to match // by auth provider for "*Api" toolkits (e.g. MailchimpMarketingApi -> MailchimpApi). @@ -144,7 +146,8 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { const metadata = await this.resolveMetadata( toolkitId, tools, - directMetadata + directMetadata, + { allowToolkitIdLookup: false } ); // Filter tools by version if specified, otherwise keep only the highest @@ -204,7 +207,8 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { const metadata = await this.resolveMetadata( toolkitId, tools, - directMetadata + directMetadata, + { allowToolkitIdLookup: true } ); result.set(toolkitId, { tools, metadata }); } diff --git a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts index 1968e21be..e3a14a852 100644 --- a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts +++ b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts @@ -308,4 +308,72 @@ describe("CombinedToolkitDataSource", () => { expect(single.metadata?.label).toBe("Mailchimp API"); expect(listed?.metadata?.label).toBe("Mailchimp API"); }); + + it("fetchToolkitData does not re-fetch metadata when direct lookup is already null", async () => { + const toolSource = new InMemoryToolDataSource([ + createTool({ + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@1.0.0", + }), + ]); + + let metadataLookupCalls = 0; + const metadataSource = { + async getToolkitMetadata( + _toolkitId: string + ): Promise { + metadataLookupCalls += 1; + return null; + }, + async getAllToolkitsMetadata(): Promise { + return []; + }, + async listToolkitIds(): Promise { + return []; + }, + }; + + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + await dataSource.fetchToolkitData("Github"); + expect(metadataLookupCalls).toBe(1); + }); + + it("fetchAllToolkitsData performs toolkit-id lookup when metadata map misses", async () => { + const toolSource = new InMemoryToolDataSource([ + createTool({ + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@1.0.0", + }), + ]); + + const lookedUpIds: string[] = []; + const metadataSource = { + async getToolkitMetadata( + toolkitId: string + ): Promise { + lookedUpIds.push(toolkitId); + return null; + }, + async getAllToolkitsMetadata(): Promise { + return [ + createMetadata({ id: "Slack", label: "Slack", category: "social" }), + ]; + }, + async listToolkitIds(): Promise { + return ["Slack"]; + }, + }; + + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + await dataSource.fetchAllToolkitsData(); + expect(lookedUpIds).toEqual(["Github"]); + }); }); diff --git a/toolkit-docs-generator/tests/utils/version-coherence.test.ts b/toolkit-docs-generator/tests/utils/version-coherence.test.ts index 35f97fd8c..52b0b9555 100644 --- a/toolkit-docs-generator/tests/utils/version-coherence.test.ts +++ b/toolkit-docs-generator/tests/utils/version-coherence.test.ts @@ -118,6 +118,30 @@ describe("getHighestVersion", () => { ]; expect(getHighestVersion(tools)).toBe("1.23"); }); + + it("orders numeric pre-release identifiers numerically", () => { + const tools = [ + createTool("Github.CreateIssue@1.23-rc.2"), + createTool("Github.SetStarred@1.23-rc.10"), + ]; + expect(getHighestVersion(tools)).toBe("1.23-rc.10"); + }); + + it("treats non-numeric pre-release identifiers as higher than numeric ones", () => { + const tools = [ + createTool("Github.CreateIssue@1.23-rc.1"), + createTool("Github.SetStarred@1.23-rc.beta"), + ]; + expect(getHighestVersion(tools)).toBe("1.23-rc.beta"); + }); + + it("treats longer pre-release identifier lists as newer when prefix matches", () => { + const tools = [ + createTool("Github.CreateIssue@1.23-rc"), + createTool("Github.SetStarred@1.23-rc.1"), + ]; + expect(getHighestVersion(tools)).toBe("1.23-rc.1"); + }); }); describe("filterToolsByHighestVersion", () => { @@ -205,4 +229,15 @@ describe("filterToolsByHighestVersion", () => { expect(result).toHaveLength(1); expect(result[0]?.fullyQualifiedName).toBe("Github.SetStarred@1.23"); }); + + it("keeps only the highest numeric pre-release", () => { + const tools = [ + createTool("Github.CreateIssue@1.23-rc.2"), + createTool("Github.SetStarred@1.23-rc.10"), + createTool("Github.ListPullRequests@1.23-rc.1"), + ]; + const result = filterToolsByHighestVersion(tools); + expect(result).toHaveLength(1); + expect(result[0]?.fullyQualifiedName).toBe("Github.SetStarred@1.23-rc.10"); + }); }); From 5528dcd947cbc9938eab04dfff5fa261d15b93ec Mon Sep 17 00:00:00 2001 From: jottakka Date: Fri, 17 Apr 2026 14:49:48 -0300 Subject: [PATCH 5/6] refactor: harden semver filter and simplify metadata resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings from six parallel reviewers: - Use compareVersions for filter equality so semver-equal tools (e.g. @1.0.0 vs @1.0.0+build.1) no longer get silently dropped. - Replace localeCompare("en") with ASCII byte ordering per SemVer 2.0.0 §11.4.2 so mixed-case prerelease tags rank correctly. - Replace ambiguous best === "" sentinel with a null sentinel in getHighestVersion. - Drop the allowToolkitIdLookup flag and lift the toolkit-id retry into the fetchAllToolkitsData call site; shared helper now only owns the *Api provider-id fallback. - Inline single-use compareOptionalPrereleasePart and simplify comparePrereleaseIdentifier with typeof narrowing. - Add regression tests: direct-map-hit no-relookup, patch-only version diff, ASCII prerelease ordering, string-vs-string prerelease, all-unversioned tools, semver-equal tools with differing build metadata. Made-with: Cursor --- .../src/sources/toolkit-data-source.ts | 49 +++--- .../src/utils/version-coherence.ts | 159 +++++++++--------- .../tests/sources/toolkit-data-source.test.ts | 53 ++++-- .../tests/utils/version-coherence.test.ts | 59 +++++++ 4 files changed, 202 insertions(+), 118 deletions(-) diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index 3b25fe471..3ee4e1fcf 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -108,30 +108,29 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { this.metadataSource = config.metadataSource; } - private async resolveMetadata( + /** + * Apply the "*Api" provider-id fallback when the direct metadata lookup + * missed. Used by both `fetchToolkitData` and `fetchAllToolkitsData` so + * they resolve metadata the same way. + */ + private async resolveProviderMetadata( toolkitId: string, tools: readonly ToolDefinition[], - directMetadata: ToolkitMetadata | null, - options?: { readonly allowToolkitIdLookup?: boolean } + directMetadata: ToolkitMetadata | null ): Promise { - let metadata = directMetadata; - if (!metadata && options?.allowToolkitIdLookup) { - metadata = await this.metadataSource.getToolkitMetadata(toolkitId); + if (directMetadata || !normalizeId(toolkitId).endsWith("api")) { + return directMetadata; } - // If the toolkit isn't in the Design System under its exact ID, try to match - // by auth provider for "*Api" toolkits (e.g. MailchimpMarketingApi -> MailchimpApi). - if (!metadata && normalizeId(toolkitId).endsWith("api")) { - const providerId = tools.find((t) => t.auth?.providerId)?.auth - ?.providerId; - if (providerId) { - metadata = - (await this.metadataSource.getToolkitMetadata(`${providerId}Api`)) ?? - (await this.metadataSource.getToolkitMetadata(providerId)); - } + const providerId = tools.find((t) => t.auth?.providerId)?.auth?.providerId; + if (!providerId) { + return null; } - return metadata; + return ( + (await this.metadataSource.getToolkitMetadata(`${providerId}Api`)) ?? + (await this.metadataSource.getToolkitMetadata(providerId)) + ); } async fetchToolkitData( @@ -143,11 +142,10 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { this.toolSource.fetchToolsByToolkit(toolkitId), this.metadataSource.getToolkitMetadata(toolkitId), ]); - const metadata = await this.resolveMetadata( + const metadata = await this.resolveProviderMetadata( toolkitId, tools, - directMetadata, - { allowToolkitIdLookup: false } + directMetadata ); // Filter tools by version if specified, otherwise keep only the highest @@ -203,12 +201,15 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { // matching the behaviour of fetchToolkitData. const result = new Map(); for (const [toolkitId, tools] of toolkitGroups) { - const directMetadata = metadataMap.get(toolkitId) ?? null; - const metadata = await this.resolveMetadata( + // Prefer the batch lookup; only fall back to per-toolkit lookup + // (and then the provider-id fallback) when the map misses. + const directMetadata = + metadataMap.get(toolkitId) ?? + (await this.metadataSource.getToolkitMetadata(toolkitId)); + const metadata = await this.resolveProviderMetadata( toolkitId, tools, - directMetadata, - { allowToolkitIdLookup: true } + directMetadata ); result.set(toolkitId, { tools, metadata }); } diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts index a3db487d7..458ed0273 100644 --- a/toolkit-docs-generator/src/utils/version-coherence.ts +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -6,6 +6,36 @@ interface ParsedSemver { readonly prerelease: readonly (number | string)[] | null; } +/** + * Parse a semver-ish string into numeric core + optional pre-release parts. + * Build metadata (+...) is ignored for ordering. + */ +const parseSemver = (version: string): ParsedSemver => { + const withoutBuild = version.split("+")[0] ?? version; + const prereleaseIndex = withoutBuild.indexOf("-"); + const coreText = + prereleaseIndex >= 0 + ? withoutBuild.slice(0, prereleaseIndex) + : withoutBuild; + const prereleaseText = + prereleaseIndex >= 0 ? withoutBuild.slice(prereleaseIndex + 1) : ""; + + const core = coreText.split(".").map((segment) => { + const n = Number(segment); + return Number.isNaN(n) ? 0 : n; + }); + + const prerelease = + prereleaseText.length > 0 + ? prereleaseText.split(".").map((identifier) => { + const n = Number(identifier); + return Number.isNaN(n) ? identifier : n; + }) + : null; + + return { core, prerelease }; +}; + const compareCoreVersions = ( aCore: readonly number[], bCore: readonly number[] @@ -18,47 +48,46 @@ const compareCoreVersions = ( return 0; }; +/** + * Compare two prerelease identifiers following SemVer 2.0.0 §11.4.2: + * numeric identifiers always rank lower than alphanumeric identifiers, + * numeric identifiers compare numerically, and alphanumeric identifiers + * compare by ASCII byte order (not locale). + */ const comparePrereleaseIdentifier = ( aPart: number | string, bPart: number | string ): number => { - const aIsNumber = typeof aPart === "number"; - const bIsNumber = typeof bPart === "number"; - if (aIsNumber && bIsNumber) { + if (typeof aPart === "number" && typeof bPart === "number") { return aPart - bPart; } - if (aIsNumber && !bIsNumber) return -1; - if (!aIsNumber && bIsNumber) return 1; - return String(aPart).localeCompare(String(bPart), "en"); -}; - -const compareOptionalPrereleasePart = ( - aPart: number | string | undefined, - bPart: number | string | undefined -): number => { - if (aPart === undefined && bPart === undefined) return 0; - if (aPart === undefined) return -1; - if (bPart === undefined) return 1; - return comparePrereleaseIdentifier(aPart, bPart); + if (typeof aPart === "number") return -1; + if (typeof bPart === "number") return 1; + if (aPart < bPart) return -1; + if (aPart > bPart) return 1; + return 0; }; +/** + * Compare prerelease identifier arrays following SemVer 2.0.0 §11.4: + * a prerelease version has lower precedence than the associated normal + * version, and longer identifier lists with matching prefix rank higher. + */ const comparePrerelease = ( aPrerelease: readonly (number | string)[] | null, bPrerelease: readonly (number | string)[] | null ): number => { if (!(aPrerelease || bPrerelease)) return 0; - if (!aPrerelease && bPrerelease) return 1; - if (aPrerelease && !bPrerelease) return -1; + if (!aPrerelease) return 1; + if (!bPrerelease) return -1; - const prereleaseLen = Math.max( - aPrerelease?.length ?? 0, - bPrerelease?.length ?? 0 - ); - for (let i = 0; i < prereleaseLen; i++) { - const diff = compareOptionalPrereleasePart( - aPrerelease?.[i], - bPrerelease?.[i] - ); + const len = Math.max(aPrerelease.length, bPrerelease.length); + for (let i = 0; i < len; i++) { + const aPart = aPrerelease[i]; + const bPart = bPrerelease[i]; + if (aPart === undefined) return -1; + if (bPart === undefined) return 1; + const diff = comparePrereleaseIdentifier(aPart, bPart); if (diff !== 0) return diff; } @@ -66,46 +95,16 @@ const comparePrerelease = ( }; /** - * Parse a semver-ish string into numeric core + optional pre-release parts. - * Build metadata (+...) is ignored for ordering. - */ -const parseSemver = (version: string): ParsedSemver => { - const withoutBuild = version.split("+")[0] ?? version; - const prereleaseIndex = withoutBuild.indexOf("-"); - const coreText = - prereleaseIndex >= 0 - ? withoutBuild.slice(0, prereleaseIndex) - : withoutBuild; - const prereleaseText = - prereleaseIndex >= 0 ? withoutBuild.slice(prereleaseIndex + 1) : ""; - - const core = coreText.split(".").map((segment) => { - const n = Number(segment); - return Number.isNaN(n) ? 0 : n; - }); - - const prerelease = - prereleaseText.length > 0 - ? prereleaseText.split(".").map((identifier) => { - const n = Number(identifier); - return Number.isNaN(n) ? identifier : n; - }) - : null; - - return { core, prerelease }; -}; - -/** - * Compare two semver version strings using semver precedence rules: - * - Compare MAJOR.MINOR.PATCH numerically - * - For equal core versions, stable release > pre-release - * - Numeric pre-release identifiers compare numerically - * - String pre-release identifiers compare lexicographically - * - Build metadata is ignored + * Compare two semver version strings using SemVer 2.0.0 precedence: + * - MAJOR.MINOR.PATCH compared numerically + * - Stable release > prerelease when core is equal + * - Numeric prerelease identifiers compare numerically + * - Alphanumeric prerelease identifiers compare by ASCII byte order + * - Build metadata is ignored for precedence * * Returns a positive number if a > b, negative if a < b, 0 if equal. */ -const compareVersions = (a: string, b: string): number => { +export const compareVersions = (a: string, b: string): number => { const aSemver = parseSemver(a); const bSemver = parseSemver(b); const coreDiff = compareCoreVersions(aSemver.core, bSemver.core); @@ -115,34 +114,29 @@ const compareVersions = (a: string, b: string): number => { /** * Find the highest version among all tools in a toolkit. - * This is the version we keep — stale tools from older releases are dropped. + * Returns null when no tool carries a usable version. */ export const getHighestVersion = ( tools: readonly ToolDefinition[] ): string | null => { - if (tools.length === 0) { - return null; - } - - let best = ""; + let best: string | null = null; for (const tool of tools) { const version = extractVersion(tool.fullyQualifiedName); - if (best === "" || compareVersions(version, best) > 0) { + if (best === null || compareVersions(version, best) > 0) { best = version; } } - - return best || null; + return best; }; /** - * Keep only tools whose @version matches the highest version for - * their toolkit. If all tools share the same version (the common - * case), returns the original array unchanged. + * Keep only tools whose @version is semver-equivalent to the highest + * version for their toolkit. If all tools share the highest version + * (the common case), returns the original array unchanged. * - * This drops stale tools from older releases that Engine still serves, - * while always preserving the newest version — even if it has fewer tools - * (e.g. tools were removed/consolidated in the new release). + * Tools are compared via `compareVersions`, so build metadata is + * ignored when deciding equivalence — a tool at `@1.0.0+build.1` + * survives alongside `@1.0.0` instead of being silently dropped. */ export const filterToolsByHighestVersion = ( tools: readonly ToolDefinition[] @@ -152,13 +146,14 @@ export const filterToolsByHighestVersion = ( return tools; } - // Fast path: if every tool is already at the highest version, skip filtering const allSame = tools.every( - (t) => extractVersion(t.fullyQualifiedName) === highest + (t) => compareVersions(extractVersion(t.fullyQualifiedName), highest) === 0 ); if (allSame) { return tools; } - return tools.filter((t) => extractVersion(t.fullyQualifiedName) === highest); + return tools.filter( + (t) => compareVersions(extractVersion(t.fullyQualifiedName), highest) === 0 + ); }; diff --git a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts index e3a14a852..6e75e049d 100644 --- a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts +++ b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts @@ -10,6 +10,7 @@ import { InMemoryMetadataSource, InMemoryToolDataSource, } from "../../src/sources/in-memory.js"; +import type { IMetadataSource } from "../../src/sources/internal.js"; import { createCombinedToolkitDataSource } from "../../src/sources/toolkit-data-source.js"; import type { ToolDefinition, ToolkitMetadata } from "../../src/types/index.js"; @@ -319,19 +320,17 @@ describe("CombinedToolkitDataSource", () => { let metadataLookupCalls = 0; const metadataSource = { - async getToolkitMetadata( - _toolkitId: string - ): Promise { + async getToolkitMetadata(_toolkitId) { metadataLookupCalls += 1; return null; }, - async getAllToolkitsMetadata(): Promise { + async getAllToolkitsMetadata() { return []; }, - async listToolkitIds(): Promise { + async listToolkitIds() { return []; }, - }; + } satisfies IMetadataSource; const dataSource = createCombinedToolkitDataSource({ toolSource, @@ -352,21 +351,19 @@ describe("CombinedToolkitDataSource", () => { const lookedUpIds: string[] = []; const metadataSource = { - async getToolkitMetadata( - toolkitId: string - ): Promise { + async getToolkitMetadata(toolkitId) { lookedUpIds.push(toolkitId); return null; }, - async getAllToolkitsMetadata(): Promise { + async getAllToolkitsMetadata() { return [ createMetadata({ id: "Slack", label: "Slack", category: "social" }), ]; }, - async listToolkitIds(): Promise { + async listToolkitIds() { return ["Slack"]; }, - }; + } satisfies IMetadataSource; const dataSource = createCombinedToolkitDataSource({ toolSource, @@ -376,4 +373,36 @@ describe("CombinedToolkitDataSource", () => { await dataSource.fetchAllToolkitsData(); expect(lookedUpIds).toEqual(["Github"]); }); + + it("fetchAllToolkitsData does not re-lookup metadata when the map already has a direct hit", async () => { + const toolSource = new InMemoryToolDataSource([ + createTool({ + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@1.0.0", + }), + ]); + + const lookedUpIds: string[] = []; + const metadataSource = { + async getToolkitMetadata(toolkitId) { + lookedUpIds.push(toolkitId); + return null; + }, + async getAllToolkitsMetadata() { + return [createMetadata()]; + }, + async listToolkitIds() { + return ["Github"]; + }, + } satisfies IMetadataSource; + + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + const result = await dataSource.fetchAllToolkitsData(); + expect(result.get("Github")?.metadata?.label).toBe("GitHub"); + expect(lookedUpIds).toEqual([]); + }); }); diff --git a/toolkit-docs-generator/tests/utils/version-coherence.test.ts b/toolkit-docs-generator/tests/utils/version-coherence.test.ts index 52b0b9555..dfcf9a762 100644 --- a/toolkit-docs-generator/tests/utils/version-coherence.test.ts +++ b/toolkit-docs-generator/tests/utils/version-coherence.test.ts @@ -142,6 +142,49 @@ describe("getHighestVersion", () => { ]; expect(getHighestVersion(tools)).toBe("1.23-rc.1"); }); + + it("distinguishes versions that differ only in the patch component", () => { + const tools = [ + createTool("Github.CreateIssue@1.2.3"), + createTool("Github.SetStarred@1.2.4"), + ]; + expect(getHighestVersion(tools)).toBe("1.2.4"); + }); + + it("orders alphanumeric pre-release identifiers by ASCII byte order", () => { + // SemVer §11.4.2: alphanumeric identifiers compare by ASCII, not locale. + // 'B' (66) < 'a' (97), so "1.0.0-Beta" < "1.0.0-alpha". + const tools = [ + createTool("Github.CreateIssue@1.0.0-Beta"), + createTool("Github.SetStarred@1.0.0-alpha"), + ]; + expect(getHighestVersion(tools)).toBe("1.0.0-alpha"); + }); + + it("orders alphanumeric pre-release identifiers when both are non-numeric", () => { + const tools = [ + createTool("Github.CreateIssue@1.0.0-alpha"), + createTool("Github.SetStarred@1.0.0-beta"), + ]; + expect(getHighestVersion(tools)).toBe("1.0.0-beta"); + }); + + it("returns null when every tool lacks an @version", () => { + const tools = [ + { + ...createTool("X.A@0.0.0"), + fullyQualifiedName: "X.A", + }, + { + ...createTool("X.B@0.0.0"), + fullyQualifiedName: "X.B", + }, + ]; + // extractVersion defaults missing "@" to "0.0.0", so "highest" is + // well-defined here. This test pins that contract so future changes + // to extractVersion surface in the coherence layer. + expect(getHighestVersion(tools)).toBe("0.0.0"); + }); }); describe("filterToolsByHighestVersion", () => { @@ -240,4 +283,20 @@ describe("filterToolsByHighestVersion", () => { expect(result).toHaveLength(1); expect(result[0]?.fullyQualifiedName).toBe("Github.SetStarred@1.23-rc.10"); }); + + it("keeps tools with equivalent core versions even when build metadata differs", () => { + // Build metadata is ignored by semver precedence, so two tools tagged + // `@1.0.0` and `@1.0.0+build.1` are the same release and both survive. + const tools = [ + createTool("Github.CreateIssue@1.0.0"), + createTool("Github.SetStarred@1.0.0+build.1"), + createTool("Github.ListPullRequests@0.9.0"), + ]; + const result = filterToolsByHighestVersion(tools); + expect(result).toHaveLength(2); + expect(result.map((t) => t.fullyQualifiedName)).toEqual([ + "Github.CreateIssue@1.0.0", + "Github.SetStarred@1.0.0+build.1", + ]); + }); }); From abd5c646c0f140ee3f5ec0f0322115e03418fd9f Mon Sep 17 00:00:00 2001 From: jottakka <203343514+jottakka@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:26:34 +0000 Subject: [PATCH 6/6] [AUTO] Adding MCP Servers docs update --- .../data/toolkits/github.json | 171 +--------------- .../data/toolkits/index.json | 8 +- .../data/toolkits/jira.json | 184 +----------------- .../data/toolkits/linear.json | 84 ++++---- 4 files changed, 48 insertions(+), 399 deletions(-) diff --git a/toolkit-docs-generator/data/toolkits/github.json b/toolkit-docs-generator/data/toolkits/github.json index 04454499f..0ec6e4885 100644 --- a/toolkit-docs-generator/data/toolkits/github.json +++ b/toolkit-docs-generator/data/toolkits/github.json @@ -1652,45 +1652,6 @@ "extras": null } }, - { - "name": "GetNotificationSummary", - "qualifiedName": "Github.GetNotificationSummary", - "fullyQualifiedName": "Github.GetNotificationSummary@2.0.1", - "description": "Get a summary of GitHub notifications.\n\nReturns counts grouped by reason, repository, and type without full notification details.", - "parameters": [], - "auth": { - "providerId": "github", - "providerType": "oauth2", - "scopes": [] - }, - "secrets": [ - "GITHUB_SERVER_URL", - "GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN" - ], - "secretsInfo": [ - { - "name": "GITHUB_SERVER_URL", - "type": "unknown" - }, - { - "name": "GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN", - "type": "token" - } - ], - "output": { - "type": "json", - "description": "Summary of user notifications" - }, - "documentationChunks": [], - "codeExample": { - "toolName": "Github.GetNotificationSummary", - "parameters": {}, - "requiresAuth": true, - "authProvider": "github", - "tabLabel": "Call the Tool with User Authorization" - }, - "metadata": null - }, { "name": "GetPullRequest", "qualifiedName": "Github.GetPullRequest", @@ -2259,135 +2220,6 @@ "extras": null } }, - { - "name": "ListNotifications", - "qualifiedName": "Github.ListNotifications", - "fullyQualifiedName": "Github.ListNotifications@2.0.1", - "description": "List GitHub notifications with pagination.\n\nReturns notifications sorted chronologically by most recent first.", - "parameters": [ - { - "name": "page", - "type": "integer", - "required": false, - "description": "Page number to fetch. Default is 1.", - "enum": null, - "inferrable": true - }, - { - "name": "per_page", - "type": "integer", - "required": false, - "description": "Number of notifications per page (max 100). Default is 30.", - "enum": null, - "inferrable": true - }, - { - "name": "all_notifications", - "type": "boolean", - "required": false, - "description": "Include read notifications. Default is False.", - "enum": null, - "inferrable": true - }, - { - "name": "participating", - "type": "boolean", - "required": false, - "description": "Only show notifications user is participating in. Default is False.", - "enum": null, - "inferrable": true - }, - { - "name": "repository_full_name", - "type": "string", - "required": false, - "description": "Filter notifications to owner/name repository. Default is None.", - "enum": null, - "inferrable": true - }, - { - "name": "subject_types", - "type": "array", - "innerType": "string", - "required": false, - "description": "List of notification subject types to include. Default is None (all types).", - "enum": [ - "Issue", - "PullRequest", - "Release", - "Commit", - "Discussion" - ], - "inferrable": true - } - ], - "auth": { - "providerId": "github", - "providerType": "oauth2", - "scopes": [] - }, - "secrets": [ - "GITHUB_SERVER_URL", - "GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN" - ], - "secretsInfo": [ - { - "name": "GITHUB_SERVER_URL", - "type": "unknown" - }, - { - "name": "GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN", - "type": "token" - } - ], - "output": { - "type": "json", - "description": "Paginated list of notifications" - }, - "documentationChunks": [], - "codeExample": { - "toolName": "Github.ListNotifications", - "parameters": { - "page": { - "value": 2, - "type": "integer", - "required": false - }, - "per_page": { - "value": 50, - "type": "integer", - "required": false - }, - "all_notifications": { - "value": true, - "type": "boolean", - "required": false - }, - "participating": { - "value": false, - "type": "boolean", - "required": false - }, - "repository_full_name": { - "value": "octocat/Hello-World", - "type": "string", - "required": false - }, - "subject_types": { - "value": [ - "Issue", - "PullRequest" - ], - "type": "array", - "required": false - } - }, - "requiresAuth": true, - "authProvider": "github", - "tabLabel": "Call the Tool with User Authorization" - }, - "metadata": null - }, { "name": "ListOrgRepositories", "qualifiedName": "Github.ListOrgRepositories", @@ -5934,6 +5766,5 @@ "import { Callout, Tabs } from \"nextra/components\";" ], "subPages": [], - "generatedAt": "2026-03-24T11:25:03.361Z", - "summary": "Arcade.dev's GitHub toolkit provides authenticated access to GitHub resources, enabling automated repository, issue, PR, review, project, and notification workflows. It exposes high-level actions to create, modify, query, and manage code and project artifacts while handling pagination, fuzzy matching, and diff-aware operations.\n\n**Capabilities**\n- Automate repository and branch operations, file and content edits, and PR lifecycle management.\n- Manage issues, comments, labels, reviewers, and review threads with contextual controls.\n- Query activity, notifications, stargazers, collaborators, projects, and user-centric views with pagination and filters.\n\n**OAuth**\nProvider: github\nScopes: None\n\n**Secrets**\nTypes: unknown, token. Examples: GITHUB_SERVER_URL, GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN" + "generatedAt": "2026-04-17T23:26:32.708Z" } \ No newline at end of file diff --git a/toolkit-docs-generator/data/toolkits/index.json b/toolkit-docs-generator/data/toolkits/index.json index 77809a1f2..87cfdcb37 100644 --- a/toolkit-docs-generator/data/toolkits/index.json +++ b/toolkit-docs-generator/data/toolkits/index.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-04-16T11:32:31.519Z", + "generatedAt": "2026-04-17T23:26:33.110Z", "version": "1.0.0", "toolkits": [ { @@ -242,7 +242,7 @@ "version": "3.1.3", "category": "development", "type": "arcade", - "toolCount": 44, + "toolCount": 42, "authType": "oauth2" }, { @@ -503,13 +503,13 @@ "version": "3.1.2", "category": "productivity", "type": "auth", - "toolCount": 45, + "toolCount": 43, "authType": "oauth2" }, { "id": "Linear", "label": "Linear", - "version": "3.3.2", + "version": "3.3.3", "category": "productivity", "type": "arcade", "toolCount": 39, diff --git a/toolkit-docs-generator/data/toolkits/jira.json b/toolkit-docs-generator/data/toolkits/jira.json index 1c99f4e79..2a1b2a06a 100644 --- a/toolkit-docs-generator/data/toolkits/jira.json +++ b/toolkit-docs-generator/data/toolkits/jira.json @@ -2746,90 +2746,6 @@ "extras": null } }, - { - "name": "ListPrioritiesAssociatedWithAPriorityScheme", - "qualifiedName": "Jira.ListPrioritiesAssociatedWithAPriorityScheme", - "fullyQualifiedName": "Jira.ListPrioritiesAssociatedWithAPriorityScheme@2.5.0", - "description": "Browse the priorities associated with a priority scheme.", - "parameters": [ - { - "name": "scheme_id", - "type": "string", - "required": true, - "description": "The ID of the priority scheme to retrieve priorities for.", - "enum": null, - "inferrable": true - }, - { - "name": "limit", - "type": "integer", - "required": false, - "description": "The maximum number of priority schemes to return. Min of 1, max of 50. Defaults to 50.", - "enum": null, - "inferrable": true - }, - { - "name": "offset", - "type": "integer", - "required": false, - "description": "The number of priority schemes to skip. Defaults to 0 (start from the first scheme).", - "enum": null, - "inferrable": true - }, - { - "name": "atlassian_cloud_id", - "type": "string", - "required": false, - "description": "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - "enum": null, - "inferrable": true - } - ], - "auth": { - "providerId": "atlassian", - "providerType": "oauth2", - "scopes": [ - "manage:jira-configuration", - "read:jira-user" - ] - }, - "secrets": [], - "secretsInfo": [], - "output": { - "type": "json", - "description": "The priorities associated with the priority scheme" - }, - "documentationChunks": [], - "codeExample": { - "toolName": "Jira.ListPrioritiesAssociatedWithAPriorityScheme", - "parameters": { - "scheme_id": { - "value": "10001", - "type": "string", - "required": true - }, - "limit": { - "value": 25, - "type": "integer", - "required": false - }, - "offset": { - "value": 0, - "type": "integer", - "required": false - }, - "atlassian_cloud_id": { - "value": "abcd1234-cloud-id", - "type": "string", - "required": false - } - }, - "requiresAuth": true, - "authProvider": "atlassian", - "tabLabel": "Call the Tool with User Authorization" - }, - "metadata": null - }, { "name": "ListPrioritiesAvailableToAnIssue", "qualifiedName": "Jira.ListPrioritiesAvailableToAnIssue", @@ -3281,103 +3197,6 @@ "extras": null } }, - { - "name": "ListProjectsAssociatedWithAPriorityScheme", - "qualifiedName": "Jira.ListProjectsAssociatedWithAPriorityScheme", - "fullyQualifiedName": "Jira.ListProjectsAssociatedWithAPriorityScheme@2.5.0", - "description": "Browse the projects associated with a priority scheme.", - "parameters": [ - { - "name": "scheme_id", - "type": "string", - "required": true, - "description": "The ID of the priority scheme to retrieve projects for.", - "enum": null, - "inferrable": true - }, - { - "name": "project", - "type": "string", - "required": false, - "description": "Filter by project ID, key or name. Defaults to None (returns all projects).", - "enum": null, - "inferrable": true - }, - { - "name": "limit", - "type": "integer", - "required": false, - "description": "The maximum number of projects to return. Min of 1, max of 50. Defaults to 50.", - "enum": null, - "inferrable": true - }, - { - "name": "offset", - "type": "integer", - "required": false, - "description": "The number of projects to skip. Defaults to 0 (start from the first project).", - "enum": null, - "inferrable": true - }, - { - "name": "atlassian_cloud_id", - "type": "string", - "required": false, - "description": "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - "enum": null, - "inferrable": true - } - ], - "auth": { - "providerId": "atlassian", - "providerType": "oauth2", - "scopes": [ - "manage:jira-configuration", - "read:jira-user" - ] - }, - "secrets": [], - "secretsInfo": [], - "output": { - "type": "json", - "description": "The projects associated with the priority scheme" - }, - "documentationChunks": [], - "codeExample": { - "toolName": "Jira.ListProjectsAssociatedWithAPriorityScheme", - "parameters": { - "scheme_id": { - "value": "10001", - "type": "string", - "required": true - }, - "project": { - "value": "PROJKEY", - "type": "string", - "required": false - }, - "limit": { - "value": 25, - "type": "integer", - "required": false - }, - "offset": { - "value": 0, - "type": "integer", - "required": false - }, - "atlassian_cloud_id": { - "value": "abcd1234-cloudid", - "type": "string", - "required": false - } - }, - "requiresAuth": true, - "authProvider": "atlassian", - "tabLabel": "Call the Tool with User Authorization" - }, - "metadata": null - }, { "name": "ListProjectsByScheme", "qualifiedName": "Jira.ListProjectsByScheme", @@ -4756,6 +4575,5 @@ "relativePath": "environment-variables/page.mdx" } ], - "generatedAt": "2026-03-24T11:24:59.816Z", - "summary": "Arcade.dev's Jira toolkit enables LLM-driven interactions with Atlassian Jira, letting agents create and update issues, manage sprints and boards, handle attachments and comments, and query projects and users. It's designed for programmatic issue lifecycle and Agile workflows.\n\n**Capabilities**\n- Manage issues and workflows: create/update issues, transitions, labels, priorities, and comments.\n- Sprint and board operations: list sprints/boards, add/remove issues from sprints, backlog management with batching and date filters.\n- File and attachment handling: attach, download, and list metadata for files.\n- Scalable search & browsing: parameterized searches, project/user/priorities listing with pagination and single-call batching.\n\n**OAuth**\nProvider: atlassian\nScopes: manage:jira-configuration, read:board-scope.admin:jira-software, read:board-scope:jira-software, read:issue-details:jira, read:jira-user, read:jira-work, read:jql:jira, read:project:jira, read:sprint:jira-software, write:board-scope:jira-software, write:jira-work, write:sprint:jira-software" + "generatedAt": "2026-04-17T23:26:32.708Z" } \ No newline at end of file diff --git a/toolkit-docs-generator/data/toolkits/linear.json b/toolkit-docs-generator/data/toolkits/linear.json index bfcdc7ec5..579b9211e 100644 --- a/toolkit-docs-generator/data/toolkits/linear.json +++ b/toolkit-docs-generator/data/toolkits/linear.json @@ -1,7 +1,7 @@ { "id": "Linear", "label": "Linear", - "version": "3.3.2", + "version": "3.3.3", "description": "Arcade tools designed for LLMs to interact with Linear", "metadata": { "category": "productivity", @@ -26,7 +26,7 @@ { "name": "AddComment", "qualifiedName": "Linear.AddComment", - "fullyQualifiedName": "Linear.AddComment@3.3.2", + "fullyQualifiedName": "Linear.AddComment@3.3.3", "description": "Add a comment to an issue.", "parameters": [ { @@ -99,7 +99,7 @@ { "name": "AddProjectComment", "qualifiedName": "Linear.AddProjectComment", - "fullyQualifiedName": "Linear.AddProjectComment@3.3.2", + "fullyQualifiedName": "Linear.AddProjectComment@3.3.3", "description": "Add a comment to a project's document content.\n\nIMPORTANT: Due to Linear API limitations, comments created via the API will NOT\nappear visually anchored inline in the document (no yellow highlight on text).\nThe comment will be stored and can be retrieved via list_project_comments, but\nit will appear in the comments panel rather than inline in the document.\n\nFor true inline comments that are visually anchored to text, users should create\nthem directly in the Linear UI by selecting text and adding a comment.\n\nThe quoted_text parameter stores metadata about what text the comment references,\nwhich is useful for context even though the comment won't be visually anchored.", "parameters": [ { @@ -198,7 +198,7 @@ { "name": "AddProjectToInitiative", "qualifiedName": "Linear.AddProjectToInitiative", - "fullyQualifiedName": "Linear.AddProjectToInitiative@3.3.2", + "fullyQualifiedName": "Linear.AddProjectToInitiative@3.3.3", "description": "Link a project to an initiative.\n\nBoth initiative and project can be specified by ID or name.\nIf a name is provided, fuzzy matching is used to resolve it.", "parameters": [ { @@ -284,7 +284,7 @@ { "name": "ArchiveInitiative", "qualifiedName": "Linear.ArchiveInitiative", - "fullyQualifiedName": "Linear.ArchiveInitiative@3.3.2", + "fullyQualifiedName": "Linear.ArchiveInitiative@3.3.3", "description": "Archive an initiative.\n\nArchived initiatives are hidden from default views but can be restored.", "parameters": [ { @@ -357,7 +357,7 @@ { "name": "ArchiveIssue", "qualifiedName": "Linear.ArchiveIssue", - "fullyQualifiedName": "Linear.ArchiveIssue@3.3.2", + "fullyQualifiedName": "Linear.ArchiveIssue@3.3.3", "description": "Archive an issue.\n\nArchived issues are hidden from default views but can be restored.", "parameters": [ { @@ -417,7 +417,7 @@ { "name": "ArchiveProject", "qualifiedName": "Linear.ArchiveProject", - "fullyQualifiedName": "Linear.ArchiveProject@3.3.2", + "fullyQualifiedName": "Linear.ArchiveProject@3.3.3", "description": "Archive a project.\n\nArchived projects are hidden from default views but can be restored.", "parameters": [ { @@ -490,7 +490,7 @@ { "name": "CreateInitiative", "qualifiedName": "Linear.CreateInitiative", - "fullyQualifiedName": "Linear.CreateInitiative@3.3.2", + "fullyQualifiedName": "Linear.CreateInitiative@3.3.3", "description": "Create a new Linear initiative.\n\nInitiatives are high-level strategic goals that group related projects.", "parameters": [ { @@ -596,7 +596,7 @@ { "name": "CreateIssue", "qualifiedName": "Linear.CreateIssue", - "fullyQualifiedName": "Linear.CreateIssue@3.3.2", + "fullyQualifiedName": "Linear.CreateIssue@3.3.3", "description": "Create a new Linear issue with validation.\n\nWhen assignee is None or '@me', the issue is assigned to the authenticated user.\nAll entity references (team, assignee, labels, state, project, cycle, parent)\nare validated before creation. If an entity is not found, suggestions are\nreturned to help correct the input.", "parameters": [ { @@ -762,7 +762,7 @@ { "name": "CreateIssueRelation", "qualifiedName": "Linear.CreateIssueRelation", - "fullyQualifiedName": "Linear.CreateIssueRelation@3.3.2", + "fullyQualifiedName": "Linear.CreateIssueRelation@3.3.3", "description": "Create a relation between two issues.\n\nRelation types define the relationship from the source issue's perspective:\n- blocks: Source issue blocks the related issue\n- blockedBy: Source issue is blocked by the related issue\n- duplicate: Source issue is a duplicate of the related issue\n- related: Issues are related (bidirectional)", "parameters": [ { @@ -853,7 +853,7 @@ { "name": "CreateProject", "qualifiedName": "Linear.CreateProject", - "fullyQualifiedName": "Linear.CreateProject@3.3.2", + "fullyQualifiedName": "Linear.CreateProject@3.3.3", "description": "Create a new Linear project.\n\nTeam is validated before creation. If team is not found, suggestions are\nreturned to help correct the input. Lead is validated if provided.", "parameters": [ { @@ -1024,7 +1024,7 @@ { "name": "CreateProjectUpdate", "qualifiedName": "Linear.CreateProjectUpdate", - "fullyQualifiedName": "Linear.CreateProjectUpdate@3.3.2", + "fullyQualifiedName": "Linear.CreateProjectUpdate@3.3.3", "description": "Create a project status update.\n\nProject updates are posts that communicate progress, blockers, or status\nchanges to stakeholders. They appear in the project's Updates tab and\ncan include a health status indicator.", "parameters": [ { @@ -1114,7 +1114,7 @@ { "name": "GetCycle", "qualifiedName": "Linear.GetCycle", - "fullyQualifiedName": "Linear.GetCycle@3.3.2", + "fullyQualifiedName": "Linear.GetCycle@3.3.3", "description": "Get detailed information about a specific Linear cycle.", "parameters": [ { @@ -1174,7 +1174,7 @@ { "name": "GetInitiative", "qualifiedName": "Linear.GetInitiative", - "fullyQualifiedName": "Linear.GetInitiative@3.3.2", + "fullyQualifiedName": "Linear.GetInitiative@3.3.3", "description": "Get detailed information about a specific Linear initiative.\n\nSupports lookup by ID or name (with fuzzy matching for name).", "parameters": [ { @@ -1276,7 +1276,7 @@ { "name": "GetInitiativeDescription", "qualifiedName": "Linear.GetInitiativeDescription", - "fullyQualifiedName": "Linear.GetInitiativeDescription@3.3.2", + "fullyQualifiedName": "Linear.GetInitiativeDescription@3.3.3", "description": "Get an initiative's full description with pagination support.\n\nUse this tool when you need the complete description of an initiative that\nwas truncated in the get_initiative response. Supports chunked reading for\nvery large descriptions.", "parameters": [ { @@ -1362,7 +1362,7 @@ { "name": "GetIssue", "qualifiedName": "Linear.GetIssue", - "fullyQualifiedName": "Linear.GetIssue@3.3.2", + "fullyQualifiedName": "Linear.GetIssue@3.3.3", "description": "Get detailed information about a specific Linear issue.\n\nAccepts either the issue UUID or the human-readable identifier (like TOO-123).", "parameters": [ { @@ -1474,7 +1474,7 @@ { "name": "GetNotifications", "qualifiedName": "Linear.GetNotifications", - "fullyQualifiedName": "Linear.GetNotifications@3.3.2", + "fullyQualifiedName": "Linear.GetNotifications@3.3.3", "description": "Get the authenticated user's notifications.\n\nReturns notifications including issue mentions, comments, assignments,\nand state changes.", "parameters": [ { @@ -1560,7 +1560,7 @@ { "name": "GetProject", "qualifiedName": "Linear.GetProject", - "fullyQualifiedName": "Linear.GetProject@3.3.2", + "fullyQualifiedName": "Linear.GetProject@3.3.3", "description": "Get detailed information about a specific Linear project.\n\nSupports lookup by ID, slug_id, or name (with fuzzy matching for name).", "parameters": [ { @@ -1676,7 +1676,7 @@ { "name": "GetProjectDescription", "qualifiedName": "Linear.GetProjectDescription", - "fullyQualifiedName": "Linear.GetProjectDescription@3.3.2", + "fullyQualifiedName": "Linear.GetProjectDescription@3.3.3", "description": "Get a project's full description with pagination support.\n\nUse this tool when you need the complete description of a project that\nwas truncated in the get_project response. Supports chunked reading for\nvery large descriptions.", "parameters": [ { @@ -1762,7 +1762,7 @@ { "name": "GetRecentActivity", "qualifiedName": "Linear.GetRecentActivity", - "fullyQualifiedName": "Linear.GetRecentActivity@3.3.2", + "fullyQualifiedName": "Linear.GetRecentActivity@3.3.3", "description": "Get the authenticated user's recent issue activity.\n\nReturns issues the user has recently created or been assigned to\nwithin the specified time period.", "parameters": [ { @@ -1835,7 +1835,7 @@ { "name": "GetTeam", "qualifiedName": "Linear.GetTeam", - "fullyQualifiedName": "Linear.GetTeam@3.3.2", + "fullyQualifiedName": "Linear.GetTeam@3.3.3", "description": "Get detailed information about a specific Linear team.\n\nSupports lookup by ID, key (like TOO, ENG), or name (with fuzzy matching).", "parameters": [ { @@ -1925,7 +1925,7 @@ { "name": "LinkGithubToIssue", "qualifiedName": "Linear.LinkGithubToIssue", - "fullyQualifiedName": "Linear.LinkGithubToIssue@3.3.2", + "fullyQualifiedName": "Linear.LinkGithubToIssue@3.3.3", "description": "Link a GitHub PR, commit, or issue to a Linear issue.\n\nAutomatically detects the artifact type from the URL and generates\nan appropriate title if not provided.", "parameters": [ { @@ -2011,7 +2011,7 @@ { "name": "ListComments", "qualifiedName": "Linear.ListComments", - "fullyQualifiedName": "Linear.ListComments@3.3.2", + "fullyQualifiedName": "Linear.ListComments@3.3.3", "description": "List comments on an issue.\n\nReturns comments with user info, timestamps, and reply threading info.", "parameters": [ { @@ -2097,7 +2097,7 @@ { "name": "ListCycles", "qualifiedName": "Linear.ListCycles", - "fullyQualifiedName": "Linear.ListCycles@3.3.2", + "fullyQualifiedName": "Linear.ListCycles@3.3.3", "description": "List Linear cycles, optionally filtered by team and status.\n\nCycles are time-boxed iterations (like sprints) for organizing work.", "parameters": [ { @@ -2209,7 +2209,7 @@ { "name": "ListInitiatives", "qualifiedName": "Linear.ListInitiatives", - "fullyQualifiedName": "Linear.ListInitiatives@3.3.2", + "fullyQualifiedName": "Linear.ListInitiatives@3.3.3", "description": "List Linear initiatives, optionally filtered by keywords and other criteria.\n\nReturns all initiatives when no filters provided, or filtered results when\nkeywords or other filters are specified.", "parameters": [ { @@ -2315,7 +2315,7 @@ { "name": "ListIssues", "qualifiedName": "Linear.ListIssues", - "fullyQualifiedName": "Linear.ListIssues@3.3.2", + "fullyQualifiedName": "Linear.ListIssues@3.3.3", "description": "List Linear issues, optionally filtered by keywords and other criteria.\n\nReturns all issues when no filters provided, or filtered results when\nkeywords or other filters are specified.", "parameters": [ { @@ -2498,7 +2498,7 @@ { "name": "ListLabels", "qualifiedName": "Linear.ListLabels", - "fullyQualifiedName": "Linear.ListLabels@3.3.2", + "fullyQualifiedName": "Linear.ListLabels@3.3.3", "description": "List available issue labels in the workspace.\n\nReturns labels that can be applied to issues. Use label IDs or names\nwhen creating or updating issues.", "parameters": [ { @@ -2558,7 +2558,7 @@ { "name": "ListProjectComments", "qualifiedName": "Linear.ListProjectComments", - "fullyQualifiedName": "Linear.ListProjectComments@3.3.2", + "fullyQualifiedName": "Linear.ListProjectComments@3.3.3", "description": "List comments on a project's document content.\n\nReturns comments with user info, timestamps, quoted text for inline comments,\nand reply threading info. Replies are nested under their parent comments.\n\nUse comment_filter to control which comments are returned:\n- only_quoted (default): Only comments attached to a quote in the text\n- only_unquoted: Only comments not attached to a particular quote\n- all: All comments regardless of being attached to a quote or not", "parameters": [ { @@ -2687,7 +2687,7 @@ { "name": "ListProjects", "qualifiedName": "Linear.ListProjects", - "fullyQualifiedName": "Linear.ListProjects@3.3.2", + "fullyQualifiedName": "Linear.ListProjects@3.3.3", "description": "List Linear projects, optionally filtered by keywords and other criteria.\n\nReturns all projects when no filters provided, or filtered results when\nkeywords or other filters are specified.", "parameters": [ { @@ -2812,7 +2812,7 @@ { "name": "ListTeams", "qualifiedName": "Linear.ListTeams", - "fullyQualifiedName": "Linear.ListTeams@3.3.2", + "fullyQualifiedName": "Linear.ListTeams@3.3.3", "description": "List Linear teams, optionally filtered by keywords and other criteria.\n\nReturns all teams when no filters provided, or filtered results when\nkeywords or other filters are specified.", "parameters": [ { @@ -2924,7 +2924,7 @@ { "name": "ListWorkflowStates", "qualifiedName": "Linear.ListWorkflowStates", - "fullyQualifiedName": "Linear.ListWorkflowStates@3.3.2", + "fullyQualifiedName": "Linear.ListWorkflowStates@3.3.3", "description": "List available workflow states in the workspace.\n\nReturns workflow states that can be used for issue transitions.\nStates are team-specific and have different types.", "parameters": [ { @@ -3017,7 +3017,7 @@ { "name": "ManageIssueSubscription", "qualifiedName": "Linear.ManageIssueSubscription", - "fullyQualifiedName": "Linear.ManageIssueSubscription@3.3.2", + "fullyQualifiedName": "Linear.ManageIssueSubscription@3.3.3", "description": "Subscribe to or unsubscribe from an issue's notifications.", "parameters": [ { @@ -3090,7 +3090,7 @@ { "name": "ReplyToComment", "qualifiedName": "Linear.ReplyToComment", - "fullyQualifiedName": "Linear.ReplyToComment@3.3.2", + "fullyQualifiedName": "Linear.ReplyToComment@3.3.3", "description": "Reply to an existing comment on an issue.\n\nCreates a threaded reply to the specified parent comment.", "parameters": [ { @@ -3176,7 +3176,7 @@ { "name": "ReplyToProjectComment", "qualifiedName": "Linear.ReplyToProjectComment", - "fullyQualifiedName": "Linear.ReplyToProjectComment@3.3.2", + "fullyQualifiedName": "Linear.ReplyToProjectComment@3.3.3", "description": "Reply to an existing comment on a project document.\n\nCreates a threaded reply to the specified parent comment.", "parameters": [ { @@ -3275,7 +3275,7 @@ { "name": "TransitionIssueState", "qualifiedName": "Linear.TransitionIssueState", - "fullyQualifiedName": "Linear.TransitionIssueState@3.3.2", + "fullyQualifiedName": "Linear.TransitionIssueState@3.3.3", "description": "Transition a Linear issue to a new workflow state.\n\nThe target state is validated against the team's available states.", "parameters": [ { @@ -3361,7 +3361,7 @@ { "name": "UpdateComment", "qualifiedName": "Linear.UpdateComment", - "fullyQualifiedName": "Linear.UpdateComment@3.3.2", + "fullyQualifiedName": "Linear.UpdateComment@3.3.3", "description": "Update an existing comment.", "parameters": [ { @@ -3434,7 +3434,7 @@ { "name": "UpdateInitiative", "qualifiedName": "Linear.UpdateInitiative", - "fullyQualifiedName": "Linear.UpdateInitiative@3.3.2", + "fullyQualifiedName": "Linear.UpdateInitiative@3.3.3", "description": "Update a Linear initiative with partial updates.\n\nOnly fields that are explicitly provided will be updated.", "parameters": [ { @@ -3553,7 +3553,7 @@ { "name": "UpdateIssue", "qualifiedName": "Linear.UpdateIssue", - "fullyQualifiedName": "Linear.UpdateIssue@3.3.2", + "fullyQualifiedName": "Linear.UpdateIssue@3.3.3", "description": "Update a Linear issue with partial updates.\n\nOnly fields that are explicitly provided will be updated. All entity\nreferences are validated before update.", "parameters": [ { @@ -3584,7 +3584,7 @@ "name": "assignee", "type": "string", "required": false, - "description": "New assignee name or email. Use '@me' for current user. Must be a team member. Only updated if provided.", + "description": "New assignee name or email. Use '@me' for current user. Use one of the following strings to clear assignee: '', 'none', 'null', or 'unassigned'. Only updated if provided.", "enum": null, "inferrable": true }, @@ -3808,7 +3808,7 @@ { "name": "UpdateProject", "qualifiedName": "Linear.UpdateProject", - "fullyQualifiedName": "Linear.UpdateProject@3.3.2", + "fullyQualifiedName": "Linear.UpdateProject@3.3.3", "description": "Update a Linear project with partial updates.\n\nOnly fields that are explicitly provided will be updated. All entity\nreferences are validated before update.\n\nIMPORTANT: Updating the 'content' field will break any existing inline\ncomment anchoring. The comments will still exist and be retrievable via\nlist_project_comments, but they will no longer appear visually anchored\nto text in the Linear UI. The 'description' field can be safely updated\nwithout affecting inline comments.", "parameters": [ { @@ -4012,7 +4012,7 @@ { "name": "WhoAmI", "qualifiedName": "Linear.WhoAmI", - "fullyQualifiedName": "Linear.WhoAmI@3.3.2", + "fullyQualifiedName": "Linear.WhoAmI@3.3.3", "description": "Get the authenticated user's profile and team memberships.\n\nReturns the current user's information including their name, email,\norganization, and the teams they belong to.", "parameters": [], "auth": { @@ -4066,5 +4066,5 @@ ], "customImports": [], "subPages": [], - "generatedAt": "2026-04-10T11:26:08.882Z" + "generatedAt": "2026-04-17T23:26:32.818Z" } \ No newline at end of file