diff --git a/mcp-server/tools/index.js b/mcp-server/tools/index.js index 5e471aa..8993960 100644 --- a/mcp-server/tools/index.js +++ b/mcp-server/tools/index.js @@ -8,6 +8,7 @@ import { registerNavigationTools } from './navigation/index.js'; import { registerProjectTools } from './project/index.js'; import { registerFileTools } from './files/index.js'; import { registerHookTools } from './hooks/index.js'; +import { registerLibrariesTools } from './libraries/index.js'; /** * Register all tools on the MCP server @@ -23,4 +24,5 @@ export function registerTools(server) { registerProjectTools(server); registerFileTools(server); registerHookTools(server); + registerLibrariesTools(server); } diff --git a/mcp-server/tools/libraries/add_global_library.js b/mcp-server/tools/libraries/add_global_library.js new file mode 100644 index 0000000..a4abfbe --- /dev/null +++ b/mcp-server/tools/libraries/add_global_library.js @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { withAuth } from "../../auth/index.js"; +import { withAuthParams } from "../helpers/authSchema.js"; +import { handleToolError } from "../helpers/errorHandler.js"; +import { pythonGet, pythonPost } from "../helpers/pythonServerClient.js"; +import { getCurrentProjectInfo } from "../files/helpers.js"; + +// Tool: add_global_library +// Appends a URL to a section (top or bottom) of the project's global libraries. +// Idempotent: if the URL already exists in the target section (trim-compare), +// the list is left untouched and added:false is returned. + +export function registerAddGlobalLibraryTool(server) { + server.tool( + "add_global_library", + `Add a URL to the project's global libraries. section='top' injects in (CSS, fonts, critical JS); section='bottom' injects before (most JS). Idempotent with dedupe — if the URL already exists in that section, returns added:false.`, + withAuthParams({ + section: z.enum(["top", "bottom"]).describe("Where to inject: 'top' = , 'bottom' = before "), + url: z.string().min(1).describe("Absolute URL (https://…) or project-relative path (/js/foo.js)"), + }), + { readOnlyHint: false, destructiveHint: false }, + withAuth(async ({ section, url }, _extra) => { + try { + const { projectSlug } = getCurrentProjectInfo(); + + // 1. Read current state from Python. + const current = await pythonGet("/api/project/libraries", { + project: projectSlug, + }); + if (!current?.success) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: current?.error || "Could not read current libraries", + }), + }], + isError: true, + }; + } + + const sectionList = Array.isArray(current[section]) ? current[section] : []; + const trimmedUrl = String(url).trim(); + + // 2. Dedupe: trim-compare URL against existing entries. + const exists = sectionList.some( + (entry) => String(entry?.url || "").trim() === trimmedUrl + ); + if (exists) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + added: false, + reason: "already present", + section, + entries: sectionList, + }, null, 2), + }], + }; + } + + // 3. Append new entry. Backend normalizes to { num, url }. + const nextList = [...sectionList, { url: trimmedUrl }]; + + // 4. Persist via save endpoint. + const saveResult = await pythonPost("/api/project/libraries/save", { + project: projectSlug, + section, + libraries: nextList, + }); + if (!saveResult?.success) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: saveResult?.error || "Could not save libraries", + }), + }], + isError: true, + }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + added: true, + section, + entries: saveResult.libraries || [], + }, null, 2), + }], + }; + } catch (error) { + return handleToolError(error, "add_global_library", { section, url }); + } + }) + ); +} diff --git a/mcp-server/tools/libraries/index.js b/mcp-server/tools/libraries/index.js new file mode 100644 index 0000000..b1be859 --- /dev/null +++ b/mcp-server/tools/libraries/index.js @@ -0,0 +1,20 @@ +import { canEditCode } from "../helpers/roleCheck.js"; +import { registerListGlobalLibrariesTool } from "./list_global_libraries.js"; +import { registerAddGlobalLibraryTool } from "./add_global_library.js"; +import { registerRemoveGlobalLibraryTool } from "./remove_global_library.js"; +import { registerSetGlobalLibrariesTool } from "./set_global_libraries.js"; + +/** + * Tools to manage the project's global libraries (CSS/JS/fonts) that are + * injected site-wide via layout.json. The list tool is always exposed (read + * only); mutating tools are gated by canEditCode() — same pattern used by + * hooks/ and project/ writers. + */ +export function registerLibrariesTools(server) { + registerListGlobalLibrariesTool(server); + if (canEditCode()) { + registerAddGlobalLibraryTool(server); + registerRemoveGlobalLibraryTool(server); + registerSetGlobalLibrariesTool(server); + } +} diff --git a/mcp-server/tools/libraries/list_global_libraries.js b/mcp-server/tools/libraries/list_global_libraries.js new file mode 100644 index 0000000..74041f0 --- /dev/null +++ b/mcp-server/tools/libraries/list_global_libraries.js @@ -0,0 +1,62 @@ +import { withAuth } from "../../auth/index.js"; +import { withAuthParams } from "../helpers/authSchema.js"; +import { handleToolError } from "../helpers/errorHandler.js"; +import { pythonGet } from "../helpers/pythonServerClient.js"; +import { getCurrentProjectInfo } from "../files/helpers.js"; + +// Tool: list_global_libraries +// Lists the global libraries (CSS/JS/fonts) injected in and before +// for the current Acai project. Use this before add/remove to inspect the current +// state. Read-only — delegates to the Python endpoint which parses layout.json. + +export function registerListGlobalLibrariesTool(server) { + server.tool( + "list_global_libraries", + `List the project's global libraries (CSS/JS/fonts injected site-wide). + +Returns two sections: +- top: entries injected inside (CSS, preloaded fonts, critical JS). +- bottom: entries injected right before (most JS). + +Each entry is { num, url } where num is the internal index used by the CMS. +Also returns layoutExists to signal whether layout.json exists for the project. + +Use this before add_global_library / remove_global_library to verify current state +or to check whether a library is already registered.`, + withAuthParams({}), + { readOnlyHint: true, destructiveHint: false }, + withAuth(async (_args, _extra) => { + try { + const { projectSlug } = getCurrentProjectInfo(); + const result = await pythonGet("/api/project/libraries", { + project: projectSlug, + }); + if (!result?.success) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: result?.error || "Could not read libraries", + }), + }], + isError: true, + }; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + top: result.top || [], + bottom: result.bottom || [], + layoutExists: !!result.layoutExists, + }, null, 2), + }], + }; + } catch (error) { + return handleToolError(error, "list_global_libraries", {}); + } + }) + ); +} diff --git a/mcp-server/tools/libraries/remove_global_library.js b/mcp-server/tools/libraries/remove_global_library.js new file mode 100644 index 0000000..97c2c18 --- /dev/null +++ b/mcp-server/tools/libraries/remove_global_library.js @@ -0,0 +1,102 @@ +import { z } from "zod"; +import { withAuth } from "../../auth/index.js"; +import { withAuthParams } from "../helpers/authSchema.js"; +import { handleToolError } from "../helpers/errorHandler.js"; +import { pythonGet, pythonPost } from "../helpers/pythonServerClient.js"; +import { getCurrentProjectInfo } from "../files/helpers.js"; + +// Tool: remove_global_library +// Removes a URL from a section (top or bottom) of the project's global +// libraries. Idempotent: if the URL isn't present, the list is left untouched +// and removed:false is returned. + +export function registerRemoveGlobalLibraryTool(server) { + server.tool( + "remove_global_library", + `Remove a URL from the project's global libraries. Idempotent — if the URL isn't present, returns removed:false.`, + withAuthParams({ + section: z.enum(["top", "bottom"]).describe("Target section: 'top' = , 'bottom' = before "), + url: z.string().min(1).describe("Absolute URL or project-relative path to remove"), + }), + { readOnlyHint: false, destructiveHint: false }, + withAuth(async ({ section, url }, _extra) => { + try { + const { projectSlug } = getCurrentProjectInfo(); + + // 1. Read current state from Python. + const current = await pythonGet("/api/project/libraries", { + project: projectSlug, + }); + if (!current?.success) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: current?.error || "Could not read current libraries", + }), + }], + isError: true, + }; + } + + const sectionList = Array.isArray(current[section]) ? current[section] : []; + const trimmedUrl = String(url).trim(); + + // 2. Filter out entries matching the URL (trim-compare). + const nextList = sectionList.filter( + (entry) => String(entry?.url || "").trim() !== trimmedUrl + ); + + // 3. If nothing changed → not found, no-op. + if (nextList.length === sectionList.length) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + removed: false, + reason: "not found", + section, + entries: sectionList, + }, null, 2), + }], + }; + } + + // 4. Persist via save endpoint. + const saveResult = await pythonPost("/api/project/libraries/save", { + project: projectSlug, + section, + libraries: nextList, + }); + if (!saveResult?.success) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: saveResult?.error || "Could not save libraries", + }), + }], + isError: true, + }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + removed: true, + section, + entries: saveResult.libraries || [], + }, null, 2), + }], + }; + } catch (error) { + return handleToolError(error, "remove_global_library", { section, url }); + } + }) + ); +} diff --git a/mcp-server/tools/libraries/set_global_libraries.js b/mcp-server/tools/libraries/set_global_libraries.js new file mode 100644 index 0000000..ece6bf9 --- /dev/null +++ b/mcp-server/tools/libraries/set_global_libraries.js @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { withAuth } from "../../auth/index.js"; +import { withAuthParams } from "../helpers/authSchema.js"; +import { handleToolError } from "../helpers/errorHandler.js"; +import { pythonPost } from "../helpers/pythonServerClient.js"; +import { getCurrentProjectInfo } from "../files/helpers.js"; + +// Tool: set_global_libraries +// Replaces the entire list of libraries for a section. Destructive — overwrites +// everything. Prefer add/remove for incremental edits; use this for bulk reorder +// or full replacement. + +export function registerSetGlobalLibrariesTool(server) { + server.tool( + "set_global_libraries", + `Replace the entire list of libraries for a section. Destructive — overwrites all existing entries. Prefer add/remove for incremental edits. Use for bulk reorder/replace.`, + withAuthParams({ + section: z.enum(["top", "bottom"]).describe("Target section: 'top' = , 'bottom' = before "), + libraries: z.array(z.object({ + url: z.string().min(1), + })).describe("Full replacement list. Order is preserved."), + }), + { readOnlyHint: false, destructiveHint: true }, + withAuth(async ({ section, libraries }, _extra) => { + try { + const { projectSlug } = getCurrentProjectInfo(); + + const saveResult = await pythonPost("/api/project/libraries/save", { + project: projectSlug, + section, + libraries, + }); + if (!saveResult?.success) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: saveResult?.error || "Could not save libraries", + }), + }], + isError: true, + }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + section: saveResult.section || section, + entries: saveResult.libraries || [], + }, null, 2), + }], + }; + } catch (error) { + return handleToolError(error, "set_global_libraries", { section, count: Array.isArray(libraries) ? libraries.length : 0 }); + } + }) + ); +}