diff --git a/docker-compose.mcps.yml b/docker-compose.mcps.yml index 7af53a8..f2c598a 100644 --- a/docker-compose.mcps.yml +++ b/docker-compose.mcps.yml @@ -15,6 +15,8 @@ services: build: context: ./packages/gitea-mcp container_name: gitea-mcp + env_file: + - .env ports: - "3001:3001" restart: unless-stopped @@ -25,6 +27,8 @@ services: build: context: ./packages/memory-mcp container_name: memory-mcp + env_file: + - .env ports: - "3002:3002" depends_on: @@ -37,6 +41,8 @@ services: build: context: ./packages/umami-mcp container_name: umami-mcp + env_file: + - .env ports: - "3003:3003" restart: unless-stopped @@ -47,6 +53,8 @@ services: build: context: ./packages/serpbear-mcp container_name: serpbear-mcp + env_file: + - .env ports: - "3004:3004" restart: unless-stopped @@ -57,6 +65,8 @@ services: build: context: ./packages/glitchtip-mcp container_name: glitchtip-mcp + env_file: + - .env ports: - "3005:3005" restart: unless-stopped @@ -67,6 +77,8 @@ services: build: context: ./packages/klz-payload-mcp container_name: klz-payload-mcp + env_file: + - .env ports: - "3006:3006" restart: unless-stopped diff --git a/eslint-errors-2.txt b/eslint-errors-2.txt new file mode 100644 index 0000000..43df664 --- /dev/null +++ b/eslint-errors-2.txt @@ -0,0 +1,69 @@ + +/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts + 11:0 error Parsing error: Identifier expected + +/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts + 124:19 warning 'res' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts + 39:18 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts + 7:52 warning 'text' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts + 7:47 warning 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 7:62 warning 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx + 11:13 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx + 20:21 warning 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 21:13 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx + 21:13 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx + 5:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts + 44:15 warning 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts + 89:31 warning 'query' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts + 3:40 warning 'User' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts + 1:15 warning 'Plugin' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx + 4:18 warning 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 5:10 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx + 4:18 warning 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 5:10 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 54:11 warning 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx + 5:13 warning 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 12:5 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx + 528:5 warning 'bankData' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx + 4:52 warning 'PDFText' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 5:26 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts + 5:3 warning 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 6:3 warning 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 7:3 warning 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +✖ 28 problems (1 error, 27 warnings) + \ No newline at end of file diff --git a/eslint-errors.txt b/eslint-errors.txt new file mode 100644 index 0000000..19d2b53 --- /dev/null +++ b/eslint-errors.txt @@ -0,0 +1,97 @@ + +/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts + 12:5 warning 'Resource' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 14:10 warning 'z' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 427:30 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars + 745:50 error Unnecessary escape character: \/ no-useless-escape + 745:60 error Unnecessary escape character: \/ no-useless-escape + 799:54 error Unnecessary escape character: \/ no-useless-escape + 799:64 error Unnecessary escape character: \/ no-useless-escape + +/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts + 124:19 warning 'res' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts + 39:18 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts + 7:52 warning 'text' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts + 7:47 warning 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 7:62 warning 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/chatPlugin.ts + 1:15 warning 'Config' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 10:17 error 'config' is never reassigned. Use 'const' instead prefer-const + 48:37 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/ChatWindow/index.tsx + 43:5 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 44:63 warning 'setMessages' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx + 11:13 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx + 20:21 warning 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 21:13 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx + 21:13 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx + 5:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/endpoints/chatEndpoint.ts + 96:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 100:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts + 44:15 warning 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 53:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts + 50:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 88:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 89:31 warning 'query' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts + 3:40 warning 'User' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 25:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 45:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 61:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 78:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 95:13 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + +/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts + 1:15 warning 'Plugin' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx + 4:18 warning 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 5:10 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx + 4:18 warning 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 5:10 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 54:11 warning 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx + 5:13 warning 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 12:5 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx + 528:5 warning 'bankData' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx + 4:52 warning 'PDFText' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 5:26 warning 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts + 5:3 warning 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 6:3 warning 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + 7:3 warning 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars + +✖ 49 problems (16 errors, 33 warnings) + 1 error and 0 warnings potentially fixable with the `--fix` option. + \ No newline at end of file diff --git a/packages/gitea-mcp/src/index.ts b/packages/gitea-mcp/src/index.ts index 7ddc73d..e9a39d2 100644 --- a/packages/gitea-mcp/src/index.ts +++ b/packages/gitea-mcp/src/index.ts @@ -1,346 +1,434 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import express from 'express'; +import express from "express"; import { - CallToolRequestSchema, - ListToolsRequestSchema, - ListResourcesRequestSchema, - ReadResourceRequestSchema, - SubscribeRequestSchema, - UnsubscribeRequestSchema, - Tool, - Resource, + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, + Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; import axios from "axios"; const GITEA_HOST = process.env.GITEA_HOST || "https://git.infra.mintel.me"; -const GITEA_ACCESS_TOKEN = process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_TOKEN; +const GITEA_ACCESS_TOKEN = + process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_TOKEN; if (!GITEA_ACCESS_TOKEN) { - console.error("Warning: Neither GITEA_ACCESS_TOKEN nor GITEA_TOKEN environment variable is set. Pipeline tools will return unauthorized errors."); + console.error( + "Warning: Neither GITEA_ACCESS_TOKEN nor GITEA_TOKEN environment variable is set. Pipeline tools will return unauthorized errors.", + ); } const giteaClient = axios.create({ - baseURL: `${GITEA_HOST.replace(/\/$/, '')}/api/v1`, - headers: { - Authorization: `token ${GITEA_ACCESS_TOKEN}`, - }, + baseURL: `${GITEA_HOST.replace(/\/$/, "")}/api/v1`, + headers: { + Authorization: `token ${GITEA_ACCESS_TOKEN}`, + }, }); const LIST_PIPELINES_TOOL: Tool = { - name: "gitea_list_pipelines", - description: "List recent action runs (pipelines) for a specific repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner (e.g., 'mmintel')" }, - repo: { type: "string", description: "Repository name (e.g., 'at-mintel')" }, - limit: { type: "number", description: "Number of runs to fetch (default: 5)" }, - branch: { type: "string", description: "Optional: Filter by branch name (e.g., 'main')" }, - event: { type: "string", description: "Optional: Filter by trigger event (e.g., 'push', 'pull_request')" }, - }, - required: ["owner", "repo"], + name: "gitea_list_pipelines", + description: "List recent action runs (pipelines) for a specific repository", + inputSchema: { + type: "object", + properties: { + owner: { + type: "string", + description: "Repository owner (e.g., 'mmintel')", + }, + repo: { + type: "string", + description: "Repository name (e.g., 'at-mintel')", + }, + limit: { + type: "number", + description: "Number of runs to fetch (default: 5)", + }, + branch: { + type: "string", + description: "Optional: Filter by branch name (e.g., 'main')", + }, + event: { + type: "string", + description: + "Optional: Filter by trigger event (e.g., 'push', 'pull_request')", + }, }, + required: ["owner", "repo"], + }, }; const GET_PIPELINE_LOGS_TOOL: Tool = { - name: "gitea_get_pipeline_logs", - description: "Get detailed logs for a specific pipeline run or job", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - run_id: { type: "number", description: "ID of the action run" }, - }, - required: ["owner", "repo", "run_id"], + name: "gitea_get_pipeline_logs", + description: "Get detailed logs for a specific pipeline run or job", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + run_id: { type: "number", description: "ID of the action run" }, }, + required: ["owner", "repo", "run_id"], + }, }; const WAIT_PIPELINE_COMPLETION_TOOL: Tool = { - name: "gitea_wait_pipeline_completion", - description: "BLOCKS and waits until a pipeline run completes, fails, or is cancelled. Use this instead of polling manually to save tokens.", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - run_id: { type: "number", description: "ID of the action run" }, - timeout_minutes: { type: "number", description: "Maximum time to wait before aborting (default: 10)" }, - }, - required: ["owner", "repo", "run_id"], + name: "gitea_wait_pipeline_completion", + description: + "BLOCKS and waits until a pipeline run completes, fails, or is cancelled. Use this instead of polling manually to save tokens.", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + run_id: { type: "number", description: "ID of the action run" }, + timeout_minutes: { + type: "number", + description: "Maximum time to wait before aborting (default: 10)", + }, }, + required: ["owner", "repo", "run_id"], + }, }; const LIST_ISSUES_TOOL: Tool = { - name: "gitea_list_issues", - description: "List issues for a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - state: { type: "string", description: "Filter by state: open, closed, or all (default: open)" }, - limit: { type: "number", description: "Number of issues to fetch (default: 10)" }, - }, - required: ["owner", "repo"], + name: "gitea_list_issues", + description: "List issues for a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + state: { + type: "string", + description: "Filter by state: open, closed, or all (default: open)", + }, + limit: { + type: "number", + description: "Number of issues to fetch (default: 10)", + }, }, + required: ["owner", "repo"], + }, }; const CREATE_ISSUE_TOOL: Tool = { - name: "gitea_create_issue", - description: "Create a new issue in a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - title: { type: "string", description: "Issue title" }, - body: { type: "string", description: "Issue description/body" }, - }, - required: ["owner", "repo", "title"], + name: "gitea_create_issue", + description: "Create a new issue in a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + title: { type: "string", description: "Issue title" }, + body: { type: "string", description: "Issue description/body" }, }, + required: ["owner", "repo", "title"], + }, }; const GET_FILE_CONTENT_TOOL: Tool = { - name: "gitea_get_file_content", - description: "Get the raw content of a file from a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - filepath: { type: "string", description: "Path to the file in the repository" }, - ref: { type: "string", description: "The name of the commit/branch/tag (default: main)" }, - }, - required: ["owner", "repo", "filepath"], + name: "gitea_get_file_content", + description: "Get the raw content of a file from a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + filepath: { + type: "string", + description: "Path to the file in the repository", + }, + ref: { + type: "string", + description: "The name of the commit/branch/tag (default: main)", + }, }, + required: ["owner", "repo", "filepath"], + }, }; const UPDATE_ISSUE_TOOL: Tool = { - name: "gitea_update_issue", - description: "Update an existing issue (e.g. change state, title, or body)", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - index: { type: "number", description: "Issue index/number" }, - state: { type: "string", description: "Optional: 'open' or 'closed'" }, - title: { type: "string", description: "Optional: New title" }, - body: { type: "string", description: "Optional: New body text" }, - }, - required: ["owner", "repo", "index"], + name: "gitea_update_issue", + description: "Update an existing issue (e.g. change state, title, or body)", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + index: { type: "number", description: "Issue index/number" }, + state: { type: "string", description: "Optional: 'open' or 'closed'" }, + title: { type: "string", description: "Optional: New title" }, + body: { type: "string", description: "Optional: New body text" }, }, + required: ["owner", "repo", "index"], + }, }; const CREATE_ISSUE_COMMENT_TOOL: Tool = { - name: "gitea_create_issue_comment", - description: "Add a comment to an existing issue or pull request", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - index: { type: "number", description: "Issue or PR index/number" }, - body: { type: "string", description: "Comment body text" }, - }, - required: ["owner", "repo", "index", "body"], + name: "gitea_create_issue_comment", + description: "Add a comment to an existing issue or pull request", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + index: { type: "number", description: "Issue or PR index/number" }, + body: { type: "string", description: "Comment body text" }, }, + required: ["owner", "repo", "index", "body"], + }, }; const CREATE_PULL_REQUEST_TOOL: Tool = { - name: "gitea_create_pull_request", - description: "Create a new Pull Request", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - head: { type: "string", description: "The branch you want to merge (e.g., 'feature/my-changes')" }, - base: { type: "string", description: "The branch to merge into (e.g., 'main')" }, - title: { type: "string", description: "PR title" }, - body: { type: "string", description: "Optional: PR description" }, - }, - required: ["owner", "repo", "head", "base", "title"], + name: "gitea_create_pull_request", + description: "Create a new Pull Request", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + head: { + type: "string", + description: + "The branch you want to merge (e.g., 'feature/my-changes')", + }, + base: { + type: "string", + description: "The branch to merge into (e.g., 'main')", + }, + title: { type: "string", description: "PR title" }, + body: { type: "string", description: "Optional: PR description" }, }, + required: ["owner", "repo", "head", "base", "title"], + }, }; const SEARCH_REPOS_TOOL: Tool = { - name: "gitea_search_repos", - description: "Search for repositories accessible to the authenticated user", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search term" }, - limit: { type: "number", description: "Maximum number of results (default: 10)" }, - }, - required: ["query"], + name: "gitea_search_repos", + description: "Search for repositories accessible to the authenticated user", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search term" }, + limit: { + type: "number", + description: "Maximum number of results (default: 10)", + }, }, + required: ["query"], + }, }; // --- Wiki --- const LIST_WIKI_PAGES_TOOL: Tool = { - name: "gitea_list_wiki_pages", - description: "List all wiki pages of a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - }, - required: ["owner", "repo"], + name: "gitea_list_wiki_pages", + description: "List all wiki pages of a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, }, + required: ["owner", "repo"], + }, }; const GET_WIKI_PAGE_TOOL: Tool = { - name: "gitea_get_wiki_page", - description: "Get the content of a specific wiki page", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - page_name: { type: "string", description: "Name/slug of the wiki page (e.g., 'Home')" }, - }, - required: ["owner", "repo", "page_name"], + name: "gitea_get_wiki_page", + description: "Get the content of a specific wiki page", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + page_name: { + type: "string", + description: "Name/slug of the wiki page (e.g., 'Home')", + }, }, + required: ["owner", "repo", "page_name"], + }, }; const CREATE_WIKI_PAGE_TOOL: Tool = { - name: "gitea_create_wiki_page", - description: "Create a new wiki page in a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - title: { type: "string", description: "Page title" }, - content: { type: "string", description: "Page content in Markdown (base64 encoded internally)" }, - message: { type: "string", description: "Optional commit message" }, - }, - required: ["owner", "repo", "title", "content"], + name: "gitea_create_wiki_page", + description: "Create a new wiki page in a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + title: { type: "string", description: "Page title" }, + content: { + type: "string", + description: "Page content in Markdown (base64 encoded internally)", + }, + message: { type: "string", description: "Optional commit message" }, }, + required: ["owner", "repo", "title", "content"], + }, }; const EDIT_WIKI_PAGE_TOOL: Tool = { - name: "gitea_edit_wiki_page", - description: "Edit an existing wiki page", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - page_name: { type: "string", description: "Current name/slug of the wiki page" }, - title: { type: "string", description: "Optional: new title" }, - content: { type: "string", description: "New content in Markdown" }, - message: { type: "string", description: "Optional commit message" }, - }, - required: ["owner", "repo", "page_name", "content"], + name: "gitea_edit_wiki_page", + description: "Edit an existing wiki page", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + page_name: { + type: "string", + description: "Current name/slug of the wiki page", + }, + title: { type: "string", description: "Optional: new title" }, + content: { type: "string", description: "New content in Markdown" }, + message: { type: "string", description: "Optional commit message" }, }, + required: ["owner", "repo", "page_name", "content"], + }, }; // --- Packages --- const LIST_PACKAGES_TOOL: Tool = { - name: "gitea_list_packages", - description: "List packages published to the Gitea package registry for a user or org", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "User or organization name" }, - type: { type: "string", description: "Optional: Package type filter (e.g., 'npm', 'docker', 'generic')" }, - limit: { type: "number", description: "Number of packages to return (default: 10)" }, - }, - required: ["owner"], + name: "gitea_list_packages", + description: + "List packages published to the Gitea package registry for a user or org", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "User or organization name" }, + type: { + type: "string", + description: + "Optional: Package type filter (e.g., 'npm', 'docker', 'generic')", + }, + limit: { + type: "number", + description: "Number of packages to return (default: 10)", + }, }, + required: ["owner"], + }, }; const LIST_PACKAGE_VERSIONS_TOOL: Tool = { - name: "gitea_list_package_versions", - description: "List all published versions of a specific package", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "User or organization name" }, - type: { type: "string", description: "Package type (e.g., 'npm', 'docker')" }, - name: { type: "string", description: "Package name" }, - }, - required: ["owner", "type", "name"], + name: "gitea_list_package_versions", + description: "List all published versions of a specific package", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "User or organization name" }, + type: { + type: "string", + description: "Package type (e.g., 'npm', 'docker')", + }, + name: { type: "string", description: "Package name" }, }, + required: ["owner", "type", "name"], + }, }; // --- Releases --- const LIST_RELEASES_TOOL: Tool = { - name: "gitea_list_releases", - description: "List releases for a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - limit: { type: "number", description: "Number of releases to fetch (default: 10)" }, - }, - required: ["owner", "repo"], + name: "gitea_list_releases", + description: "List releases for a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + limit: { + type: "number", + description: "Number of releases to fetch (default: 10)", + }, }, + required: ["owner", "repo"], + }, }; const GET_LATEST_RELEASE_TOOL: Tool = { - name: "gitea_get_latest_release", - description: "Get the latest release for a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - }, - required: ["owner", "repo"], + name: "gitea_get_latest_release", + description: "Get the latest release for a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, }, + required: ["owner", "repo"], + }, }; const CREATE_RELEASE_TOOL: Tool = { - name: "gitea_create_release", - description: "Create a new release for a repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "Repository owner" }, - repo: { type: "string", description: "Repository name" }, - tag_name: { type: "string", description: "Git tag to build the release from (e.g., 'v1.2.3')" }, - name: { type: "string", description: "Release title" }, - body: { type: "string", description: "Optional: Release notes/description in Markdown" }, - draft: { type: "boolean", description: "Optional: Create as draft (default: false)" }, - prerelease: { type: "boolean", description: "Optional: Mark as prerelease (default: false)" }, - }, - required: ["owner", "repo", "tag_name", "name"], + name: "gitea_create_release", + description: "Create a new release for a repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" }, + tag_name: { + type: "string", + description: "Git tag to build the release from (e.g., 'v1.2.3')", + }, + name: { type: "string", description: "Release title" }, + body: { + type: "string", + description: "Optional: Release notes/description in Markdown", + }, + draft: { + type: "boolean", + description: "Optional: Create as draft (default: false)", + }, + prerelease: { + type: "boolean", + description: "Optional: Mark as prerelease (default: false)", + }, }, + required: ["owner", "repo", "tag_name", "name"], + }, }; // --- Projects --- const LIST_PROJECTS_TOOL: Tool = { - name: "gitea_list_projects", - description: "List projects (kanban boards) for a user, organization, or repository", - inputSchema: { - type: "object", - properties: { - owner: { type: "string", description: "User or organization name" }, - repo: { type: "string", description: "Optional: Repository name (for repo-level projects)" }, - type: { type: "string", description: "Optional: 'individual' or 'repository' or 'organization'" }, - }, - required: ["owner"], + name: "gitea_list_projects", + description: + "List projects (kanban boards) for a user, organization, or repository", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "User or organization name" }, + repo: { + type: "string", + description: "Optional: Repository name (for repo-level projects)", + }, + type: { + type: "string", + description: "Optional: 'individual' or 'repository' or 'organization'", + }, }, + required: ["owner"], + }, }; const GET_PROJECT_COLUMNS_TOOL: Tool = { - name: "gitea_get_project_columns", - description: "Get the columns (board columns) of a specific project", - inputSchema: { - type: "object", - properties: { - project_id: { type: "number", description: "Numeric project ID from gitea_list_projects" }, - }, - required: ["project_id"], + name: "gitea_get_project_columns", + description: "Get the columns (board columns) of a specific project", + inputSchema: { + type: "object", + properties: { + project_id: { + type: "number", + description: "Numeric project ID from gitea_list_projects", + }, }, + required: ["project_id"], + }, }; // Subscription State @@ -348,524 +436,829 @@ const subscriptions = new Set(); const runStatusCache = new Map(); // uri -> status const server = new Server( - { - name: "gitea-mcp-native", - version: "1.0.0", + { + name: "gitea-mcp-native", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + resources: { subscribe: true }, // Enable subscriptions }, - { - capabilities: { - tools: {}, - resources: { subscribe: true }, // Enable subscriptions - }, - } + }, ); // --- Tools --- server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - LIST_PIPELINES_TOOL, - GET_PIPELINE_LOGS_TOOL, - WAIT_PIPELINE_COMPLETION_TOOL, - LIST_ISSUES_TOOL, - CREATE_ISSUE_TOOL, - GET_FILE_CONTENT_TOOL, - UPDATE_ISSUE_TOOL, - CREATE_ISSUE_COMMENT_TOOL, - CREATE_PULL_REQUEST_TOOL, - SEARCH_REPOS_TOOL, - // Wiki - LIST_WIKI_PAGES_TOOL, - GET_WIKI_PAGE_TOOL, - CREATE_WIKI_PAGE_TOOL, - EDIT_WIKI_PAGE_TOOL, - // Packages - LIST_PACKAGES_TOOL, - LIST_PACKAGE_VERSIONS_TOOL, - // Releases - LIST_RELEASES_TOOL, - GET_LATEST_RELEASE_TOOL, - CREATE_RELEASE_TOOL, - // Projects - LIST_PROJECTS_TOOL, - GET_PROJECT_COLUMNS_TOOL, - ], - }; + return { + tools: [ + LIST_PIPELINES_TOOL, + GET_PIPELINE_LOGS_TOOL, + WAIT_PIPELINE_COMPLETION_TOOL, + LIST_ISSUES_TOOL, + CREATE_ISSUE_TOOL, + GET_FILE_CONTENT_TOOL, + UPDATE_ISSUE_TOOL, + CREATE_ISSUE_COMMENT_TOOL, + CREATE_PULL_REQUEST_TOOL, + SEARCH_REPOS_TOOL, + // Wiki + LIST_WIKI_PAGES_TOOL, + GET_WIKI_PAGE_TOOL, + CREATE_WIKI_PAGE_TOOL, + EDIT_WIKI_PAGE_TOOL, + // Packages + LIST_PACKAGES_TOOL, + LIST_PACKAGE_VERSIONS_TOOL, + // Releases + LIST_RELEASES_TOOL, + GET_LATEST_RELEASE_TOOL, + CREATE_RELEASE_TOOL, + // Projects + LIST_PROJECTS_TOOL, + GET_PROJECT_COLUMNS_TOOL, + ], + }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === "gitea_list_pipelines") { - const { owner, repo, limit = 5, branch, event } = request.params.arguments as any; + if (request.params.name === "gitea_list_pipelines") { + const { + owner, + repo, + limit = 5, + branch, + event, + } = request.params.arguments as any; - try { - const apiParams: Record = { limit }; - if (branch) apiParams.branch = branch; - if (event) apiParams.event = event; + try { + const apiParams: Record = { limit }; + if (branch) apiParams.branch = branch; + if (event) apiParams.event = event; - const runsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, { - params: apiParams, - }); + const runsResponse = await giteaClient.get( + `/repos/${owner}/${repo}/actions/runs`, + { + params: apiParams, + }, + ); - const runs = (runsResponse.data.workflow_runs || []) as any[]; - const enhancedRuns = await Promise.all( - runs.map(async (run: any) => { - try { - const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run.id}/jobs`); - const jobs = (jobsResponse.data.jobs || []) as any[]; - return { - id: run.id, - name: run.name, - status: run.status, - created_at: run.created_at, - jobs: jobs.map((job: any) => ({ - id: job.id, - name: job.name, - status: job.status, - conclusion: job.conclusion - })) - }; - } catch (e) { - return { id: run.id, name: run.name, status: run.status, created_at: run.created_at, jobs: [] }; - } - }) + const runs = (runsResponse.data.workflow_runs || []) as any[]; + const enhancedRuns = await Promise.all( + runs.map(async (run: any) => { + try { + const jobsResponse = await giteaClient.get( + `/repos/${owner}/${repo}/actions/runs/${run.id}/jobs`, ); - - return { - content: [{ type: "text", text: JSON.stringify(enhancedRuns, null, 2) }], - }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error fetching pipelines: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_get_pipeline_logs") { - const { owner, repo, run_id } = request.params.arguments as any; - try { - const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`); const jobs = (jobsResponse.data.jobs || []) as any[]; - const logs = jobs.map((job: any) => ({ - job_id: job.id, - job_name: job.name, + return { + id: run.id, + name: run.name, + status: run.status, + created_at: run.created_at, + jobs: jobs.map((job: any) => ({ + id: job.id, + name: job.name, status: job.status, conclusion: job.conclusion, - steps: (job.steps || []).map((step: any) => ({ - name: step.name, - status: step.status, - conclusion: step.conclusion - })) - })); - - return { content: [{ type: "text", text: JSON.stringify(logs, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_wait_pipeline_completion") { - const { owner, repo, run_id, timeout_minutes = 10 } = request.params.arguments as any; - const startTime = Date.now(); - const timeoutMs = timeout_minutes * 60 * 1000; - - try { - while (true) { - if (Date.now() - startTime > timeoutMs) { - return { content: [{ type: "text", text: `Wait timed out after ${timeout_minutes} minutes.` }] }; - } - - const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`); - const status = response.data.status; - const conclusion = response.data.conclusion; - - if (status !== "running" && status !== "waiting") { - return { - content: [{ - type: "text", - text: `Pipeline finished! Final Status: ${status}, Conclusion: ${conclusion}` - }] - }; - } - - // Wait 5 seconds before polling again - await new Promise(resolve => setTimeout(resolve, 5000)); - } - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error checking pipeline status: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_list_issues") { - const { owner, repo, state = "open", limit = 10 } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/repos/${owner}/${repo}/issues`, { - params: { state, limit } - }); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_create_issue") { - const { owner, repo, title, body } = request.params.arguments as any; - try { - const response = await giteaClient.post(`/repos/${owner}/${repo}/issues`, { - title, - body - }); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_get_file_content") { - const { owner, repo, filepath, ref = "main" } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/repos/${owner}/${repo}/contents/${filepath}`, { - params: { ref } - }); - // Gitea returns base64 encoded content for files - if (response.data.type === 'file' && response.data.content) { - const decodedContent = Buffer.from(response.data.content, 'base64').toString('utf-8'); - return { content: [{ type: "text", text: decodedContent }] }; - } - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_update_issue") { - const { owner, repo, index, state, title, body } = request.params.arguments as any; - try { - const updateData: Record = {}; - if (state) updateData.state = state; - if (title) updateData.title = title; - if (body) updateData.body = body; - - // Send PATCH request to /repos/{owner}/{repo}/issues/{index} - const response = await giteaClient.patch(`/repos/${owner}/${repo}/issues/${index}`, updateData); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error updating issue: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_create_issue_comment") { - const { owner, repo, index, body } = request.params.arguments as any; - try { - const response = await giteaClient.post(`/repos/${owner}/${repo}/issues/${index}/comments`, { - body - }); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error creating comment: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_create_pull_request") { - const { owner, repo, head, base, title, body } = request.params.arguments as any; - try { - const prData: Record = { head, base, title }; - if (body) prData.body = body; - - const response = await giteaClient.post(`/repos/${owner}/${repo}/pulls`, prData); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error creating Pull Request: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_search_repos") { - const { query, limit = 10 } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/repos/search`, { - params: { q: query, limit } - }); - return { content: [{ type: "text", text: JSON.stringify(response.data.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] }; - } - } - - // --- Wiki Handlers --- - if (request.params.name === "gitea_list_wiki_pages") { - const { owner, repo } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/repos/${owner}/${repo}/wiki/pages`); - const pages = (response.data || []).map((p: any) => ({ title: p.title, last_commit: p.last_commit?.message })); - return { content: [{ type: "text", text: JSON.stringify(pages, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error listing wiki pages: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_get_wiki_page") { - const { owner, repo, page_name } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/repos/${owner}/${repo}/wiki/page/${encodeURIComponent(page_name)}`); - const content = Buffer.from(response.data.content_base64 || '', 'base64').toString('utf-8'); - return { content: [{ type: "text", text: `# ${response.data.title}\n\n${content}` }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error fetching wiki page: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_create_wiki_page") { - const { owner, repo, title, content, message } = request.params.arguments as any; - try { - const response = await giteaClient.post(`/repos/${owner}/${repo}/wiki/pages`, { - title, - content_base64: Buffer.from(content).toString('base64'), - message: message || `Create wiki page: ${title}`, - }); - return { content: [{ type: "text", text: `Wiki page '${response.data.title}' created.` }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error creating wiki page: ${error.message}` }] }; - } - } - - if (request.params.name === "gitea_edit_wiki_page") { - const { owner, repo, page_name, title, content, message } = request.params.arguments as any; - try { - const updateData: Record = { - content_base64: Buffer.from(content).toString('base64'), - message: message || `Update wiki page: ${page_name}`, + })), }; - if (title) updateData.title = title; - const response = await giteaClient.patch(`/repos/${owner}/${repo}/wiki/pages/${encodeURIComponent(page_name)}`, updateData); - return { content: [{ type: "text", text: `Wiki page '${response.data.title}' updated.` }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error updating wiki page: ${error.message}` }] }; - } - } + } catch { + return { + id: run.id, + name: run.name, + status: run.status, + created_at: run.created_at, + jobs: [], + }; + } + }), + ); - // --- Package Handlers --- - if (request.params.name === "gitea_list_packages") { - const { owner, type, limit = 10 } = request.params.arguments as any; - try { - const params: Record = { limit }; - if (type) params.type = type; - const response = await giteaClient.get(`/packages/${owner}`, { params }); - const packages = (response.data || []).map((p: any) => ({ - name: p.name, type: p.type, version: p.version, created: p.created_at - })); - return { content: [{ type: "text", text: JSON.stringify(packages, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error listing packages: ${error.message}` }] }; - } + return { + content: [ + { type: "text", text: JSON.stringify(enhancedRuns, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error fetching pipelines: ${error.message}` }, + ], + }; } + } - if (request.params.name === "gitea_list_package_versions") { - const { owner, type, name } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/packages/${owner}/${type}/${encodeURIComponent(name)}`); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error listing package versions: ${error.message}` }] }; - } + if (request.params.name === "gitea_get_pipeline_logs") { + const { owner, repo, run_id } = request.params.arguments as any; + try { + const jobsResponse = await giteaClient.get( + `/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`, + ); + const jobs = (jobsResponse.data.jobs || []) as any[]; + const logs = jobs.map((job: any) => ({ + job_id: job.id, + job_name: job.name, + status: job.status, + conclusion: job.conclusion, + steps: (job.steps || []).map((step: any) => ({ + name: step.name, + status: step.status, + conclusion: step.conclusion, + })), + })); + + return { + content: [{ type: "text", text: JSON.stringify(logs, null, 2) }], + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error: ${error.message}` }], + }; } + } - // --- Release Handlers --- - if (request.params.name === "gitea_list_releases") { - const { owner, repo, limit = 10 } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/repos/${owner}/${repo}/releases`, { params: { limit } }); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error listing releases: ${error.message}` }] }; + if (request.params.name === "gitea_wait_pipeline_completion") { + const { + owner, + repo, + run_id, + timeout_minutes = 10, + } = request.params.arguments as any; + const startTime = Date.now(); + const timeoutMs = timeout_minutes * 60 * 1000; + + try { + while (true) { + if (Date.now() - startTime > timeoutMs) { + return { + content: [ + { + type: "text", + text: `Wait timed out after ${timeout_minutes} minutes.`, + }, + ], + }; } - } - if (request.params.name === "gitea_get_latest_release") { - const { owner, repo } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/repos/${owner}/${repo}/releases/latest`); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error fetching latest release: ${error.message}` }] }; + const response = await giteaClient.get( + `/repos/${owner}/${repo}/actions/runs/${run_id}`, + ); + const status = response.data.status; + const conclusion = response.data.conclusion; + + if (status !== "running" && status !== "waiting") { + return { + content: [ + { + type: "text", + text: `Pipeline finished! Final Status: ${status}, Conclusion: ${conclusion}`, + }, + ], + }; } - } - if (request.params.name === "gitea_create_release") { - const { owner, repo, tag_name, name, body, draft = false, prerelease = false } = request.params.arguments as any; - try { - const response = await giteaClient.post(`/repos/${owner}/${repo}/releases`, { - tag_name, name, body, draft, prerelease - }); - return { content: [{ type: "text", text: `Release '${response.data.name}' created: ${response.data.html_url}` }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error creating release: ${error.message}` }] }; - } + // Wait 5 seconds before polling again + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } catch (error: any) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error checking pipeline status: ${error.message}`, + }, + ], + }; } + } - // --- Project Handlers --- - if (request.params.name === "gitea_list_projects") { - const { owner, repo } = request.params.arguments as any; - try { - // Gitea API: repo-level projects or user projects - const url = repo ? `/repos/${owner}/${repo}/projects` : `/users/${owner}/projects`; - const response = await giteaClient.get(url); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error listing projects: ${error.message}` }] }; - } + if (request.params.name === "gitea_list_issues") { + const { + owner, + repo, + state = "open", + limit = 10, + } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/issues`, { + params: { state, limit }, + }); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error: ${error.message}` }], + }; } + } - if (request.params.name === "gitea_get_project_columns") { - const { project_id } = request.params.arguments as any; - try { - const response = await giteaClient.get(`/projects/${project_id}/columns`); - return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; - } catch (error: any) { - return { isError: true, content: [{ type: "text", text: `Error fetching project columns: ${error.message}` }] }; - } + if (request.params.name === "gitea_create_issue") { + const { owner, repo, title, body } = request.params.arguments as any; + try { + const response = await giteaClient.post( + `/repos/${owner}/${repo}/issues`, + { + title, + body, + }, + ); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error: ${error.message}` }], + }; } + } - throw new Error(`Unknown tool: ${request.params.name}`); + if (request.params.name === "gitea_get_file_content") { + const { + owner, + repo, + filepath, + ref = "main", + } = request.params.arguments as any; + try { + const response = await giteaClient.get( + `/repos/${owner}/${repo}/contents/${filepath}`, + { + params: { ref }, + }, + ); + // Gitea returns base64 encoded content for files + if (response.data.type === "file" && response.data.content) { + const decodedContent = Buffer.from( + response.data.content, + "base64", + ).toString("utf-8"); + return { content: [{ type: "text", text: decodedContent }] }; + } + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error: ${error.message}` }], + }; + } + } + + if (request.params.name === "gitea_update_issue") { + const { owner, repo, index, state, title, body } = request.params + .arguments as any; + try { + const updateData: Record = {}; + if (state) updateData.state = state; + if (title) updateData.title = title; + if (body) updateData.body = body; + + // Send PATCH request to /repos/{owner}/{repo}/issues/{index} + const response = await giteaClient.patch( + `/repos/${owner}/${repo}/issues/${index}`, + updateData, + ); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error updating issue: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_create_issue_comment") { + const { owner, repo, index, body } = request.params.arguments as any; + try { + const response = await giteaClient.post( + `/repos/${owner}/${repo}/issues/${index}/comments`, + { + body, + }, + ); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error creating comment: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_create_pull_request") { + const { owner, repo, head, base, title, body } = request.params + .arguments as any; + try { + const prData: Record = { head, base, title }; + if (body) prData.body = body; + + const response = await giteaClient.post( + `/repos/${owner}/${repo}/pulls`, + prData, + ); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error creating Pull Request: ${error.message}`, + }, + ], + }; + } + } + + if (request.params.name === "gitea_search_repos") { + const { query, limit = 10 } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/repos/search`, { + params: { q: query, limit }, + }); + return { + content: [ + { type: "text", text: JSON.stringify(response.data.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error: ${error.message}` }], + }; + } + } + + // --- Wiki Handlers --- + if (request.params.name === "gitea_list_wiki_pages") { + const { owner, repo } = request.params.arguments as any; + try { + const response = await giteaClient.get( + `/repos/${owner}/${repo}/wiki/pages`, + ); + const pages = (response.data || []).map((p: any) => ({ + title: p.title, + last_commit: p.last_commit?.message, + })); + return { + content: [{ type: "text", text: JSON.stringify(pages, null, 2) }], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error listing wiki pages: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_get_wiki_page") { + const { owner, repo, page_name } = request.params.arguments as any; + try { + const response = await giteaClient.get( + `/repos/${owner}/${repo}/wiki/page/${encodeURIComponent(page_name)}`, + ); + const content = Buffer.from( + response.data.content_base64 || "", + "base64", + ).toString("utf-8"); + return { + content: [ + { type: "text", text: `# ${response.data.title}\n\n${content}` }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error fetching wiki page: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_create_wiki_page") { + const { owner, repo, title, content, message } = request.params + .arguments as any; + try { + const response = await giteaClient.post( + `/repos/${owner}/${repo}/wiki/pages`, + { + title, + content_base64: Buffer.from(content).toString("base64"), + message: message || `Create wiki page: ${title}`, + }, + ); + return { + content: [ + { type: "text", text: `Wiki page '${response.data.title}' created.` }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error creating wiki page: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_edit_wiki_page") { + const { owner, repo, page_name, title, content, message } = request.params + .arguments as any; + try { + const updateData: Record = { + content_base64: Buffer.from(content).toString("base64"), + message: message || `Update wiki page: ${page_name}`, + }; + if (title) updateData.title = title; + const response = await giteaClient.patch( + `/repos/${owner}/${repo}/wiki/pages/${encodeURIComponent(page_name)}`, + updateData, + ); + return { + content: [ + { type: "text", text: `Wiki page '${response.data.title}' updated.` }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error updating wiki page: ${error.message}` }, + ], + }; + } + } + + // --- Package Handlers --- + if (request.params.name === "gitea_list_packages") { + const { owner, type, limit = 10 } = request.params.arguments as any; + try { + const params: Record = { limit }; + if (type) params.type = type; + const response = await giteaClient.get(`/packages/${owner}`, { params }); + const packages = (response.data || []).map((p: any) => ({ + name: p.name, + type: p.type, + version: p.version, + created: p.created_at, + })); + return { + content: [{ type: "text", text: JSON.stringify(packages, null, 2) }], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error listing packages: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_list_package_versions") { + const { owner, type, name } = request.params.arguments as any; + try { + const response = await giteaClient.get( + `/packages/${owner}/${type}/${encodeURIComponent(name)}`, + ); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error listing package versions: ${error.message}`, + }, + ], + }; + } + } + + // --- Release Handlers --- + if (request.params.name === "gitea_list_releases") { + const { owner, repo, limit = 10 } = request.params.arguments as any; + try { + const response = await giteaClient.get( + `/repos/${owner}/${repo}/releases`, + { params: { limit } }, + ); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error listing releases: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_get_latest_release") { + const { owner, repo } = request.params.arguments as any; + try { + const response = await giteaClient.get( + `/repos/${owner}/${repo}/releases/latest`, + ); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error fetching latest release: ${error.message}`, + }, + ], + }; + } + } + + if (request.params.name === "gitea_create_release") { + const { + owner, + repo, + tag_name, + name, + body, + draft = false, + prerelease = false, + } = request.params.arguments as any; + try { + const response = await giteaClient.post( + `/repos/${owner}/${repo}/releases`, + { + tag_name, + name, + body, + draft, + prerelease, + }, + ); + return { + content: [ + { + type: "text", + text: `Release '${response.data.name}' created: ${response.data.html_url}`, + }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error creating release: ${error.message}` }, + ], + }; + } + } + + // --- Project Handlers --- + if (request.params.name === "gitea_list_projects") { + const { owner, repo } = request.params.arguments as any; + try { + // Gitea API: repo-level projects or user projects + const url = repo + ? `/repos/${owner}/${repo}/projects` + : `/users/${owner}/projects`; + const response = await giteaClient.get(url); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { type: "text", text: `Error listing projects: ${error.message}` }, + ], + }; + } + } + + if (request.params.name === "gitea_get_project_columns") { + const { project_id } = request.params.arguments as any; + try { + const response = await giteaClient.get(`/projects/${project_id}/columns`); + return { + content: [ + { type: "text", text: JSON.stringify(response.data, null, 2) }, + ], + }; + } catch (error: any) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error fetching project columns: ${error.message}`, + }, + ], + }; + } + } + + throw new Error(`Unknown tool: ${request.params.name}`); }); // --- Resources & Subscriptions --- // We will expose a dynamic resource URI pattern: gitea://runs/{owner}/{repo}/{run_id} server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { - resources: [ - { - uri: "gitea://runs", - name: "Gitea Pipeline Runs", - description: "Dynamic resource for subscribing to pipeline runs. Format: gitea://runs/{owner}/{repo}/{run_id}", - mimeType: "application/json", - } - ], - }; + return { + resources: [ + { + uri: "gitea://runs", + name: "Gitea Pipeline Runs", + description: + "Dynamic resource for subscribing to pipeline runs. Format: gitea://runs/{owner}/{repo}/{run_id}", + mimeType: "application/json", + }, + ], + }; }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const uri = request.params.uri; - const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/); + const uri = request.params.uri; + const match = uri.match(/^gitea:\/\/runs\/([^/]+)\/([^/]+)\/(\d+)$/); - if (!match) { - throw new Error(`Invalid resource URI. Must be gitea://runs/{owner}/{repo}/{run_id}`); - } + if (!match) { + throw new Error( + `Invalid resource URI. Must be gitea://runs/{owner}/{repo}/{run_id}`, + ); + } - const [, owner, repo, run_id] = match; + const [, owner, repo, run_id] = match; - try { - const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`); - const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`); + try { + const runResponse = await giteaClient.get( + `/repos/${owner}/${repo}/actions/runs/${run_id}`, + ); + const jobsResponse = await giteaClient.get( + `/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`, + ); - const resourceContent = { - run: runResponse.data, - jobs: jobsResponse.data - }; + const resourceContent = { + run: runResponse.data, + jobs: jobsResponse.data, + }; - // Update internal cache when read - runStatusCache.set(uri, runResponse.data.status); + // Update internal cache when read + runStatusCache.set(uri, runResponse.data.status); - return { - contents: [ - { - uri, - mimeType: "application/json", - text: JSON.stringify(resourceContent, null, 2), - }, - ], - }; - } catch (error: any) { - throw new Error(`Failed to read Gitea resource: ${error.message}`); - } + return { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify(resourceContent, null, 2), + }, + ], + }; + } catch (error: any) { + throw new Error(`Failed to read Gitea resource: ${error.message}`); + } }); server.setRequestHandler(SubscribeRequestSchema, async (request) => { - const uri = request.params.uri; - if (!uri.startsWith("gitea://runs/")) { - throw new Error("Only gitea://runs resources can be subscribed to"); - } - subscriptions.add(uri); - console.error(`Client subscribed to ${uri}`); - return {}; + const uri = request.params.uri; + if (!uri.startsWith("gitea://runs/")) { + throw new Error("Only gitea://runs resources can be subscribed to"); + } + subscriptions.add(uri); + console.error(`Client subscribed to ${uri}`); + return {}; }); server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { - const uri = request.params.uri; - subscriptions.delete(uri); - console.error(`Client unsubscribed from ${uri}`); - return {}; + const uri = request.params.uri; + subscriptions.delete(uri); + console.error(`Client unsubscribed from ${uri}`); + return {}; }); // The server polling mechanism that pushes updates to subscribed clients async function pollSubscriptions() { - for (const uri of subscriptions) { - const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/); - if (!match) continue; + for (const uri of subscriptions) { + const match = uri.match(/^gitea:\/\/runs\/([^/]+)\/([^/]+)\/(\d+)$/); + if (!match) continue; - const [, owner, repo, run_id] = match; - try { - const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`); - const currentStatus = runResponse.data.status; - const prevStatus = runStatusCache.get(uri); + const [, owner, repo, run_id] = match; + try { + const runResponse = await giteaClient.get( + `/repos/${owner}/${repo}/actions/runs/${run_id}`, + ); + const currentStatus = runResponse.data.status; + const prevStatus = runStatusCache.get(uri); - // If status changed (e.g. running -> completed), notify client - if (prevStatus !== currentStatus) { - runStatusCache.set(uri, currentStatus); + // If status changed (e.g. running -> completed), notify client + if (prevStatus !== currentStatus) { + runStatusCache.set(uri, currentStatus); - server.notification({ - method: "notifications/resources/updated", - params: { uri } - }); - console.error(`Pushed update for ${uri}: ${prevStatus} -> ${currentStatus}`); + server.notification({ + method: "notifications/resources/updated", + params: { uri }, + }); + console.error( + `Pushed update for ${uri}: ${prevStatus} -> ${currentStatus}`, + ); - // Auto-unsubscribe if completed/failed so we don't poll forever? - // Let the client decide, or we can handle it here if requested. - } - } catch (e) { - console.error(`Error polling subscription ${uri}:`, e); - } + // Auto-unsubscribe if completed/failed so we don't poll forever? + // Let the client decide, or we can handle it here if requested. + } + } catch (e) { + console.error(`Error polling subscription ${uri}:`, e); } + } - // Poll every 5 seconds - setTimeout(pollSubscriptions, 5000); + // Poll every 5 seconds + setTimeout(pollSubscriptions, 5000); } - async function run() { - const isStdio = process.argv.includes('--stdio'); + const isStdio = process.argv.includes("--stdio"); - if (isStdio) { - const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('Gitea MCP server is running on stdio'); - } else { - const app = express(); - let transport: SSEServerTransport | null = null; + if (isStdio) { + const { StdioServerTransport } = + await import("@modelcontextprotocol/sdk/server/stdio.js"); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Gitea MCP server is running on stdio"); + } else { + const app = express(); + let transport: SSEServerTransport | null = null; - app.get('/sse', async (req, res) => { - console.error('New SSE connection established'); - transport = new SSEServerTransport('/message', res); - await server.connect(transport); - }); + app.get("/sse", async (req, res) => { + console.error("New SSE connection established"); + transport = new SSEServerTransport("/message", res); + await server.connect(transport); + }); - app.post('/message', async (req, res) => { - if (!transport) { - res.status(400).send('No active SSE connection'); - return; - } - await transport.handlePostMessage(req, res); - }); + app.post("/message", async (req, res) => { + if (!transport) { + res.status(400).send("No active SSE connection"); + return; + } + await transport.handlePostMessage(req, res); + }); - const PORT = process.env.GITEA_MCP_PORT || 3001; - app.listen(PORT, () => { - console.error(`Gitea MCP server running on http://localhost:${PORT}/sse`); - }); + const PORT = process.env.GITEA_MCP_PORT || 3001; + app.listen(PORT, () => { + console.error(`Gitea MCP server running on http://localhost:${PORT}/sse`); + }); - // Start the background poller only in SSE mode or if specifically desired - pollSubscriptions(); - } + // Start the background poller only in SSE mode or if specifically desired + pollSubscriptions(); + } } - run().catch((error) => { - console.error("Fatal error:", error); - process.exit(1); + console.error("Fatal error:", error); + process.exit(1); }); diff --git a/packages/payload-ai/src/chatPlugin.ts b/packages/payload-ai/src/chatPlugin.ts index c9ccab7..355e161 100644 --- a/packages/payload-ai/src/chatPlugin.ts +++ b/packages/payload-ai/src/chatPlugin.ts @@ -1,90 +1,98 @@ -import type { Config, Plugin } from 'payload' -import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js' -import type { PayloadChatPluginConfig } from './types.js' -import { optimizePostEndpoint } from './endpoints/optimizeEndpoint.js' -import { generateSlugEndpoint, generateThumbnailEndpoint, generateSingleFieldEndpoint } from './endpoints/generateEndpoints.js' +import type { Plugin } from "payload"; +import { AIChatPermissionsCollection } from "./collections/AIChatPermissions.js"; +import type { PayloadChatPluginConfig } from "./types.js"; +import { optimizePostEndpoint } from "./endpoints/optimizeEndpoint.js"; +import { + generateSlugEndpoint, + generateThumbnailEndpoint, + generateSingleFieldEndpoint, +} from "./endpoints/generateEndpoints.js"; export const payloadChatPlugin = - (pluginOptions: PayloadChatPluginConfig): Plugin => - (incomingConfig) => { - let config = { ...incomingConfig } + (pluginOptions: PayloadChatPluginConfig): Plugin => + (incomingConfig) => { + const config = { ...incomingConfig }; - // If disabled, return config untouched - if (pluginOptions.enabled === false) { - return config - } + // If disabled, return config untouched + if (pluginOptions.enabled === false) { + return config; + } - // 1. Inject the Permissions Collection into the Schema - const existingCollections = config.collections || [] + // 1. Inject the Permissions Collection into the Schema + const existingCollections = config.collections || []; - const mcpServers = pluginOptions.mcpServers || [] + const mcpServers = pluginOptions.mcpServers || []; - // Dynamically populate the select options for Collections and MCP Servers - const permissionCollection = { ...AIChatPermissionsCollection } - const collectionField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedCollections') as any - if (collectionField) { - collectionField.options = existingCollections.map(c => ({ - label: c.labels?.singular || c.slug, - value: c.slug - })) - } + // Dynamically populate the select options for Collections and MCP Servers + const permissionCollection = { ...AIChatPermissionsCollection }; + const collectionField = permissionCollection.fields.find( + (f) => "name" in f && f.name === "allowedCollections", + ) as any; + if (collectionField) { + collectionField.options = existingCollections.map((c) => ({ + label: c.labels?.singular || c.slug, + value: c.slug, + })); + } - const mcpField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedMcpServers') as any - if (mcpField) { - mcpField.options = mcpServers.map(s => ({ - label: s.name, - value: s.name - })) - } + const mcpField = permissionCollection.fields.find( + (f) => "name" in f && f.name === "allowedMcpServers", + ) as any; + if (mcpField) { + mcpField.options = mcpServers.map((s) => ({ + label: s.name, + value: s.name, + })); + } - config.collections = [...existingCollections, permissionCollection] + config.collections = [...existingCollections, permissionCollection]; - // 2. Register Custom API Endpoint for the AI Chat - config.endpoints = [ - ...(config.endpoints || []), - { - path: '/api/mcp-chat', - method: 'post', - handler: async (req) => { - // Fallback simple handler while developing endpoint logic - return Response.json({ message: "Chat endpoint active" }) - }, - }, - { - path: '/api/mintel-ai/optimize', - method: 'post', - handler: optimizePostEndpoint, - }, - { - path: '/api/mintel-ai/generate-slug', - method: 'post', - handler: generateSlugEndpoint, - }, - { - path: '/api/mintel-ai/generate-thumbnail', - method: 'post', - handler: generateThumbnailEndpoint, - }, - { - path: '/api/mintel-ai/generate-single-field', - method: 'post', - handler: generateSingleFieldEndpoint, - }, - ] + // 2. Register Custom API Endpoint for the AI Chat + config.endpoints = [ + ...(config.endpoints || []), + { + path: "/api/mcp-chat", + method: "post", + handler: async (_req) => { + // Fallback simple handler while developing endpoint logic + return Response.json({ message: "Chat endpoint active" }); + }, + }, + { + path: "/api/mintel-ai/optimize", + method: "post", + handler: optimizePostEndpoint, + }, + { + path: "/api/mintel-ai/generate-slug", + method: "post", + handler: generateSlugEndpoint, + }, + { + path: "/api/mintel-ai/generate-thumbnail", + method: "post", + handler: generateThumbnailEndpoint, + }, + { + path: "/api/mintel-ai/generate-single-field", + method: "post", + handler: generateSingleFieldEndpoint, + }, + ]; - // 3. Inject Chat React Component into Admin UI - if (pluginOptions.renderChatBubble !== false) { - config.admin = { - ...(config.admin || {}), - components: { - ...(config.admin?.components || {}), - providers: [ - ...(config.admin?.components?.providers || []), - '@mintel/payload-ai/components/ChatWindow#ChatWindowProvider', - ], - }, - } - } + // 3. Inject Chat React Component into Admin UI + if (pluginOptions.renderChatBubble !== false) { + config.admin = { + ...(config.admin || {}), + components: { + ...(config.admin?.components || {}), + providers: [ + ...(config.admin?.components?.providers || []), + "@mintel/payload-ai/components/ChatWindow#ChatWindowProvider", + ], + }, + }; + } - return config - } + return config; + }; diff --git a/packages/payload-ai/src/components/ChatWindow/index.tsx b/packages/payload-ai/src/components/ChatWindow/index.tsx index 916db2b..c54c652 100644 --- a/packages/payload-ai/src/components/ChatWindow/index.tsx +++ b/packages/payload-ai/src/components/ChatWindow/index.tsx @@ -1,136 +1,158 @@ -'use client' +"use client"; -import React, { useState, useEffect } from 'react' -import { useChat } from '@ai-sdk/react' -import './ChatWindow.scss' +import React, { useState, useEffect } from "react"; +import { useChat } from "@ai-sdk/react"; +import "./ChatWindow.scss"; -export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return ( - <> - {children} - - - ) -} +export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + return ( + <> + {children} + + + ); +}; const ChatWindow: React.FC = () => { - const [isOpen, setIsOpen] = useState(false) - const [pageContext, setPageContext] = useState({ url: '' }) + const [isOpen, setIsOpen] = useState(false); + const [pageContext, setPageContext] = useState({ url: "" }); - useEffect(() => { - if (typeof window !== 'undefined') { - const path = window.location.pathname; - let collectionSlug = null; - let id = null; - // Payload admin URLs are usually /admin/collections/:slug/:id - const match = path.match(/\/collections\/([^/]+)(?:\/([^/]+))?/); - if (match) { - collectionSlug = match[1]; - if (match[2] && match[2] !== 'create') { - id = match[2]; - } - } - - setPageContext({ - url: window.location.href, - title: document.title, - collectionSlug, - id - }) + useEffect(() => { + if (typeof window !== "undefined") { + const path = window.location.pathname; + let collectionSlug = null; + let id = null; + // Payload admin URLs are usually /admin/collections/:slug/:id + const match = path.match(/\/collections\/([^/]+)(?:\/([^/]+))?/); + if (match) { + collectionSlug = match[1]; + if (match[2] && match[2] !== "create") { + id = match[2]; } - }, [isOpen]) // Refresh context when chat is opened + } - // @ts-ignore - AI hook version mismatch between core and react packages - const { messages, input, handleInputChange, handleSubmit, setMessages } = useChat({ - api: '/api/mcp-chat', - initialMessages: [], - body: { - pageContext - } - } as any) + setPageContext({ + url: window.location.href, + title: document.title, + collectionSlug, + id, + }); + } + }, [isOpen]); // Refresh context when chat is opened - // Basic implementation to toggle chat window and submit messages - return ( -
- + + {isOpen && ( +
+
+

Payload MCP Chat

+
+ +
+ {messages.map((m: any) => ( +
- {isOpen ? 'Close AI Chat' : 'Ask AI'} - - - {isOpen && ( + >
-
-

Payload MCP Chat

-
- -
- {messages.map((m: any) => ( -
-
- {m.role === 'user' ? 'G: ' : 'AI: '} - {m.content} -
-
- ))} -
- -
- -
+ {m.role === "user" ? "G: " : "AI: "} + {m.content}
- )} +
+ ))} +
+ +
+ +
- ) -} + )} +
+ ); +}; diff --git a/packages/payload-ai/src/endpoints/chatEndpoint.ts b/packages/payload-ai/src/endpoints/chatEndpoint.ts index 4aaabd0..c9b31a9 100644 --- a/packages/payload-ai/src/endpoints/chatEndpoint.ts +++ b/packages/payload-ai/src/endpoints/chatEndpoint.ts @@ -1,115 +1,143 @@ -import { streamText } from 'ai' -import { createOpenAI } from '@ai-sdk/openai' -import { generatePayloadLocalTools } from '../tools/payloadLocal.js' -import { createMcpTools } from '../tools/mcpAdapter.js' -import { generateMemoryTools } from '../tools/memoryDb.js' -import type { PayloadRequest } from 'payload' +import { streamText } from "ai"; +import { createOpenAI } from "@ai-sdk/openai"; +import { generatePayloadLocalTools } from "../tools/payloadLocal.js"; +import { createMcpTools } from "../tools/mcpAdapter.js"; +import { generateMemoryTools } from "../tools/memoryDb.js"; +import type { PayloadRequest } from "payload"; const openrouter = createOpenAI({ - baseURL: 'https://openrouter.ai/api/v1', - apiKey: process.env.OPENROUTER_API_KEY || 'dummy_key', -}) + baseURL: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY || "dummy_key", +}); export const handleMcpChat = async (req: PayloadRequest) => { - if (!req.user) { - return Response.json({ error: 'Unauthorized. You must be logged in to use AI Chat.' }, { status: 401 }) + if (!req.user) { + return Response.json( + { error: "Unauthorized. You must be logged in to use AI Chat." }, + { status: 401 }, + ); + } + + const { messages, pageContext } = ((await req.json?.()) || { + messages: [], + }) as { messages: any[]; pageContext?: any }; + + // 1. Check AI Permissions for req.user + // Look up the collection for permissions + const permissionsQuery = await req.payload.find({ + collection: "ai-chat-permissions" as any, + where: { + or: [ + { targetUser: { equals: req.user.id } }, + { targetRole: { equals: req.user.role || "admin" } }, + ], + }, + limit: 10, + }); + + const allowedCollections = new Set(); + const allowedMcpServers = new Set(); + + for (const perm of permissionsQuery.docs) { + if (perm.allowedCollections) { + perm.allowedCollections.forEach((c: string) => allowedCollections.add(c)); } - - const { messages, pageContext } = (await req.json?.() || { messages: [] }) as { messages: any[], pageContext?: any } - - // 1. Check AI Permissions for req.user - // Look up the collection for permissions - const permissionsQuery = await req.payload.find({ - collection: 'ai-chat-permissions' as any, - where: { - or: [ - { targetUser: { equals: req.user.id } }, - { targetRole: { equals: req.user.role || 'admin' } } - ] - }, - limit: 10 - }) - - const allowedCollections = new Set() - const allowedMcpServers = new Set() - - for (const perm of permissionsQuery.docs) { - if (perm.allowedCollections) { - perm.allowedCollections.forEach((c: string) => allowedCollections.add(c)) - } - if (perm.allowedMcpServers) { - perm.allowedMcpServers.forEach((s: string) => allowedMcpServers.add(s)) - } + if (perm.allowedMcpServers) { + perm.allowedMcpServers.forEach((s: string) => allowedMcpServers.add(s)); } + } - let accessCollections = Array.from(allowedCollections) - if (accessCollections.length === 0) { - // Fallback or demo config if not configured yet - accessCollections = ['users', 'pages', 'posts', 'products', 'leads', 'media'] + let accessCollections = Array.from(allowedCollections); + if (accessCollections.length === 0) { + // Fallback or demo config if not configured yet + accessCollections = [ + "users", + "pages", + "posts", + "products", + "leads", + "media", + ]; + } + + let activeTools: Record = {}; + + // 2. Generate Payload Local Tools + if (accessCollections.length > 0) { + const payloadTools = generatePayloadLocalTools( + req.payload, + req, + accessCollections, + ); + activeTools = { ...activeTools, ...payloadTools }; + } + + // 3. Connect External MCPs + if (Array.from(allowedMcpServers).includes("gitea")) { + try { + const { tools: giteaTools } = await createMcpTools({ + name: "gitea", + command: "npx", + args: [ + "-y", + "@modelcontextprotocol/server-gitea", + "--url", + "https://git.mintel.int", + "--token", + process.env.GITEA_TOKEN || "", + ], + }); + activeTools = { ...activeTools, ...giteaTools }; + } catch (e) { + console.error("Failed to connect to Gitea MCP", e); } + } - let activeTools: Record = {} + // 4. Inject Memory Database Tools + // We provide the user ID so memory is partitioned per user + const memoryTools = generateMemoryTools(req.user.id); + activeTools = { ...activeTools, ...memoryTools }; - // 2. Generate Payload Local Tools - if (accessCollections.length > 0) { - const payloadTools = generatePayloadLocalTools(req.payload, req, accessCollections) - activeTools = { ...activeTools, ...payloadTools } - } - - // 3. Connect External MCPs - if (Array.from(allowedMcpServers).includes('gitea')) { - try { - const { tools: giteaTools } = await createMcpTools({ - name: 'gitea', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-gitea', '--url', 'https://git.mintel.int', '--token', process.env.GITEA_TOKEN || ''] - }) - activeTools = { ...activeTools, ...giteaTools } - } catch (e) { - console.error('Failed to connect to Gitea MCP', e) - } - } - - // 4. Inject Memory Database Tools - // We provide the user ID so memory is partitioned per user - const memoryTools = generateMemoryTools(req.user.id) - activeTools = { ...activeTools, ...memoryTools } - - // 5. Build prompt to ensure it asks before saving - const memorySystemPrompt = ` + // 5. Build prompt to ensure it asks before saving + const memorySystemPrompt = ` You have access to a long-term vector memory database (Qdrant). If the user says "speicher das", "merk dir das", "vergiss das nicht" etc., you MUST use the save_memory tool. If the user shares important context but doesn't explicitly ask you to remember it, you should ask "Soll ich mir das für die Zukunft merken?" before saving it. Do not ask for trivial things. - ` + `; - const contextContextStr = pageContext ? ` + const contextContextStr = pageContext + ? ` Current User Context: - URL: ${pageContext.url || 'Unknown'} - Title: ${pageContext.title || 'Unknown'} - Collection: ${pageContext.collectionSlug || 'None'} - Document ID: ${pageContext.id || 'None'} + URL: ${pageContext.url || "Unknown"} + Title: ${pageContext.title || "Unknown"} + Collection: ${pageContext.collectionSlug || "None"} + Document ID: ${pageContext.id || "None"} You can use this to understand what the user is currently looking at. - ` : '' + ` + : ""; - try { - const result = streamText({ - // @ts-ignore - AI SDK type mismatch - model: openrouter('google/gemini-3.0-flash'), - messages, - tools: activeTools, - // @ts-ignore - AI SDK type mismatch with maxSteps - maxSteps: 10, - system: `You are a helpful Payload CMS Agent orchestrating the local Mintel ecosystem. + try { + const result = streamText({ + // @ts-expect-error - AI SDK type mismatch + model: openrouter("google/gemini-3.0-flash"), + messages, + tools: activeTools, + // @ts-expect-error - AI SDK type mismatch with maxSteps + maxSteps: 10, + system: `You are a helpful Payload CMS Agent orchestrating the local Mintel ecosystem. You only have access to tools explicitly granted by the Admin. You can completely control Payload CMS (read, create, update, delete documents). If you need more details to fulfill a request (e.g. creating a blog post), you can ask the user. ${contextContextStr} - ${memorySystemPrompt}` - }) + ${memorySystemPrompt}`, + }); - return result.toTextStreamResponse() - } catch (error) { - console.error("AI Error:", error) - return Response.json({ error: 'Failed to process AI request' }, { status: 500 }) - } -} + return result.toTextStreamResponse(); + } catch (error) { + console.error("AI Error:", error); + return Response.json( + { error: "Failed to process AI request" }, + { status: 500 }, + ); + } +}; diff --git a/packages/payload-ai/src/tools/mcpAdapter.ts b/packages/payload-ai/src/tools/mcpAdapter.ts index cba2d1c..00f79f6 100644 --- a/packages/payload-ai/src/tools/mcpAdapter.ts +++ b/packages/payload-ai/src/tools/mcpAdapter.ts @@ -1,65 +1,72 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import { tool } from 'ai' -import { z } from 'zod' +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { tool } from "ai"; +import { z } from "zod"; /** * Connects to an external MCP Server and maps its tools to Vercel AI SDK Tools. */ -export async function createMcpTools(mcpConfig: { name: string, url?: string, command?: string, args?: string[] }) { - let transport +export async function createMcpTools(mcpConfig: { + name: string; + url?: string; + command?: string; + args?: string[]; +}) { + let transport; - // Support both HTTP/SSE and STDIO transports - if (mcpConfig.url) { - transport = new SSEClientTransport(new URL(mcpConfig.url)) - } else if (mcpConfig.command) { - transport = new StdioClientTransport({ - command: mcpConfig.command, - args: mcpConfig.args || [], - }) - } else { - throw new Error('Invalid MCP config: Must provide either URL or Command.') - } + // Support both HTTP/SSE and STDIO transports + if (mcpConfig.url) { + transport = new SSEClientTransport(new URL(mcpConfig.url)); + } else if (mcpConfig.command) { + transport = new StdioClientTransport({ + command: mcpConfig.command, + args: mcpConfig.args || [], + }); + } else { + throw new Error("Invalid MCP config: Must provide either URL or Command."); + } - const client = new Client( - { name: `payload-ai-client-${mcpConfig.name}`, version: '1.0.0' }, - { capabilities: {} } - ) + const client = new Client( + { name: `payload-ai-client-${mcpConfig.name}`, version: "1.0.0" }, + { capabilities: {} }, + ); - await client.connect(transport) + await client.connect(transport); - // Fetch available tools from the external MCP server - const toolListResult = await client.listTools() - const externalTools = toolListResult.tools || [] + // Fetch available tools from the external MCP server + const toolListResult = await client.listTools(); + const externalTools = toolListResult.tools || []; - const aiSdkTools: Record = {} + const aiSdkTools: Record = {}; - // Map each external tool to a Vercel AI SDK Tool - for (const extTool of externalTools) { - // Basic conversion of JSON Schema to Zod for the AI SDK - // Note: For a production ready adapter, you might need a more robust jsonSchemaToZod converter - // or use AI SDK's new experimental generateSchema feature if available. - // Here we use a generic `z.any()` as a fallback since AI SDK requires a Zod schema. - const toolSchema = extTool.inputSchema as Record + // Map each external tool to a Vercel AI SDK Tool + for (const extTool of externalTools) { + // Basic conversion of JSON Schema to Zod for the AI SDK + // Note: For a production ready adapter, you might need a more robust jsonSchemaToZod converter + // or use AI SDK's new experimental generateSchema feature if available. + // Here we use a generic `z.any()` as a fallback since AI SDK requires a Zod schema. + const toolSchema = extTool.inputSchema as Record; - // We create a simplified parameter parser. - // An ideal approach uses `jsonSchemaToZod` library or native AI SDK JSON schema support - // (introduced recently in `ai` package). + // We create a simplified parameter parser. + // An ideal approach uses `jsonSchemaToZod` library or native AI SDK JSON schema support + // (introduced recently in `ai` package). - aiSdkTools[`${mcpConfig.name}_${extTool.name}`] = tool({ - description: `[From ${mcpConfig.name}] ${extTool.description || extTool.name}`, - parameters: z.any().describe('JSON matching the original MCP input_schema'), // Simplify for prototype - // @ts-ignore - AI strict mode overload bug with implicit zod inferences - execute: async (args: any) => { - const result = await client.callTool({ - name: extTool.name, - arguments: args - }) - return result - } - }) - } + aiSdkTools[`${mcpConfig.name}_${extTool.name}`] = tool({ + description: `[From ${mcpConfig.name}] ${extTool.description || extTool.name}`, + parameters: z + .any() + .describe("JSON matching the original MCP input_schema"), // Simplify for prototype + // @ts-expect-error - AI strict mode overload bug with implicit zod inferences + execute: async (args: any) => { + const result = await client.callTool({ + name: extTool.name, + arguments: args, + }); + return result; + }, + }); + } - return { tools: aiSdkTools, client } + return { tools: aiSdkTools, client }; } diff --git a/packages/payload-ai/src/tools/memoryDb.ts b/packages/payload-ai/src/tools/memoryDb.ts index 9e172c4..bb89b52 100644 --- a/packages/payload-ai/src/tools/memoryDb.ts +++ b/packages/payload-ai/src/tools/memoryDb.ts @@ -1,37 +1,39 @@ -import { tool } from 'ai' -import { z } from 'zod' -import { QdrantClient } from '@qdrant/js-client-rest' +import { tool } from "ai"; +import { z } from "zod"; +import { QdrantClient } from "@qdrant/js-client-rest"; // Qdrant initialization // This requires the user to have Qdrant running and QDRANT_URL/QDRANT_API_KEY environment variables set const qdrantClient = new QdrantClient({ - url: process.env.QDRANT_URL || 'http://localhost:6333', - apiKey: process.env.QDRANT_API_KEY, -}) + url: process.env.QDRANT_URL || "http://localhost:6333", + apiKey: process.env.QDRANT_API_KEY, +}); -const MEMORY_COLLECTION = 'mintel_ai_memory' +const MEMORY_COLLECTION = "mintel_ai_memory"; // Ensure collection exists on load async function initQdrant() { - try { - const res = await qdrantClient.getCollections() - const exists = res.collections.find((c: any) => c.name === MEMORY_COLLECTION) - if (!exists) { - await qdrantClient.createCollection(MEMORY_COLLECTION, { - vectors: { - size: 1536, // typical embedding size, adjust based on the embedding model used - distance: 'Cosine', - }, - }) - console.log(`Qdrant collection '${MEMORY_COLLECTION}' created.`) - } - } catch (error) { - console.error('Failed to initialize Qdrant memory collection:', error) + try { + const res = await qdrantClient.getCollections(); + const exists = res.collections.find( + (c: any) => c.name === MEMORY_COLLECTION, + ); + if (!exists) { + await qdrantClient.createCollection(MEMORY_COLLECTION, { + vectors: { + size: 1536, // typical embedding size, adjust based on the embedding model used + distance: "Cosine", + }, + }); + console.log(`Qdrant collection '${MEMORY_COLLECTION}' created.`); } + } catch (error) { + console.error("Failed to initialize Qdrant memory collection:", error); + } } // Call init, but don't block -initQdrant() +initQdrant(); /** * Returns memory tools for the AI SDK. @@ -40,76 +42,99 @@ initQdrant() * by a utility function, or we use Qdrant's FastEmbed (if running their specialized container). */ export const generateMemoryTools = (userId: string | number) => { - return { - save_memory: tool({ - description: 'Save an important preference, fact, or instruction about the user to long-term memory. Only use this when explicitly asked or when it is clearly a long-term preference.', - parameters: z.object({ - fact: z.string().describe('The fact or instruction to remember.'), - category: z.string().optional().describe('An optional category like "preference", "rule", or "project_detail".'), - }), - // @ts-ignore - AI SDK strict mode bug - execute: async ({ fact, category }: { fact: string; category?: string }) => { - // In a real scenario, you MUST generate embeddings for the 'fact' string here - // using OpenAI or another embedding provider before inserting into Qdrant. - // const embedding = await generateEmbedding(fact) + return { + save_memory: tool({ + description: + "Save an important preference, fact, or instruction about the user to long-term memory. Only use this when explicitly asked or when it is clearly a long-term preference.", + parameters: z.object({ + fact: z.string().describe("The fact or instruction to remember."), + category: z + .string() + .optional() + .describe( + 'An optional category like "preference", "rule", or "project_detail".', + ), + }), + // @ts-expect-error - AI SDK strict mode bug + execute: async ({ + fact, + category, + }: { + fact: string; + category?: string; + }) => { + // In a real scenario, you MUST generate embeddings for the 'fact' string here + // using OpenAI or another embedding provider before inserting into Qdrant. + // const embedding = await generateEmbedding(fact) - try { - // Mock embedding payload for demonstration - const mockEmbedding = new Array(1536).fill(0).map(() => Math.random()) + try { + // Mock embedding payload for demonstration + const mockEmbedding = new Array(1536) + .fill(0) + .map(() => Math.random()); - await qdrantClient.upsert(MEMORY_COLLECTION, { - wait: true, - points: [ - { - id: crypto.randomUUID(), - vector: mockEmbedding, - payload: { - userId: String(userId), // Partition memory by user - fact, - category, - createdAt: new Date().toISOString(), - }, - }, - ], - }) - return { success: true, message: `Successfully remembered: "${fact}"` } - } catch (error) { - console.error("Qdrant save error:", error) - return { success: false, error: 'Failed to save to memory database.' } - } + await qdrantClient.upsert(MEMORY_COLLECTION, { + wait: true, + points: [ + { + id: crypto.randomUUID(), + vector: mockEmbedding, + payload: { + userId: String(userId), // Partition memory by user + fact, + category, + createdAt: new Date().toISOString(), + }, + }, + ], + }); + return { + success: true, + message: `Successfully remembered: "${fact}"`, + }; + } catch (error) { + console.error("Qdrant save error:", error); + return { + success: false, + error: "Failed to save to memory database.", + }; + } + }, + }), + + search_memory: tool({ + description: + "Search the user's long-term memory for past factual context, preferences, or rules.", + parameters: z.object({ + query: z.string().describe("The search string to find in memory."), + }), + // @ts-expect-error - AI SDK strict mode bug + execute: async ({ query }: { query: string }) => { + // Generate embedding for query + const mockQueryEmbedding = new Array(1536) + .fill(0) + .map(() => Math.random()); + + try { + const results = await qdrantClient.search(MEMORY_COLLECTION, { + vector: mockQueryEmbedding, + limit: 5, + filter: { + must: [ + { + key: "userId", + match: { value: String(userId) }, + }, + ], }, - }), + }); - search_memory: tool({ - description: 'Search the user\'s long-term memory for past factual context, preferences, or rules.', - parameters: z.object({ - query: z.string().describe('The search string to find in memory.'), - }), - // @ts-ignore - AI SDK strict mode bug - execute: async ({ query }: { query: string }) => { - // Generate embedding for query - const mockQueryEmbedding = new Array(1536).fill(0).map(() => Math.random()) - - try { - const results = await qdrantClient.search(MEMORY_COLLECTION, { - vector: mockQueryEmbedding, - limit: 5, - filter: { - must: [ - { - key: 'userId', - match: { value: String(userId) } - } - ] - } - }) - - return results.map((r: any) => r.payload?.fact || '') - } catch (error) { - console.error("Qdrant search error:", error) - return [] - } - } - }) - } -} + return results.map((r: any) => r.payload?.fact || ""); + } catch (error) { + console.error("Qdrant search error:", error); + return []; + } + }, + }), + }; +}; diff --git a/packages/payload-ai/src/tools/payloadLocal.ts b/packages/payload-ai/src/tools/payloadLocal.ts index b4bbef8..752b0e0 100644 --- a/packages/payload-ai/src/tools/payloadLocal.ts +++ b/packages/payload-ai/src/tools/payloadLocal.ts @@ -1,107 +1,137 @@ -import { tool } from 'ai' -import { z } from 'zod' -import type { Payload, PayloadRequest, User } from 'payload' +import { tool } from "ai"; +import { z } from "zod"; +import type { Payload, PayloadRequest, User } from "payload"; export const generatePayloadLocalTools = ( - payload: Payload, - req: PayloadRequest, - allowedCollections: string[] + payload: Payload, + req: PayloadRequest, + allowedCollections: string[], ) => { - const tools: Record = {} + const tools: Record = {}; - for (const collectionSlug of allowedCollections) { - const slugKey = collectionSlug.replace(/-/g, '_') + for (const collectionSlug of allowedCollections) { + const slugKey = collectionSlug.replace(/-/g, "_"); - // 1. Read (Find) Tool - tools[`read_${slugKey}`] = tool({ - description: `Read/Find documents from the Payload CMS collection: ${collectionSlug}`, - parameters: z.object({ - limit: z.number().optional().describe('Number of documents to return, max 100.'), - page: z.number().optional().describe('Page number for pagination.'), - // Simple string-based query for demo purposes. For a robust implementation, - // we'd map this to Payload's where query logic using a structured Zod schema. - query: z.string().optional().describe('Optional text to search within the collection.'), - }), - // @ts-ignore - AI SDK strict mode type inference bug - execute: async ({ limit = 10, page = 1, query }: { limit?: number; page?: number; query?: string }) => { - const where = query ? { id: { equals: query } } : undefined // Placeholder logic + // 1. Read (Find) Tool + tools[`read_${slugKey}`] = tool({ + description: `Read/Find documents from the Payload CMS collection: ${collectionSlug}`, + parameters: z.object({ + limit: z + .number() + .optional() + .describe("Number of documents to return, max 100."), + page: z.number().optional().describe("Page number for pagination."), + // Simple string-based query for demo purposes. For a robust implementation, + // we'd map this to Payload's where query logic using a structured Zod schema. + query: z + .string() + .optional() + .describe("Optional text to search within the collection."), + }), + // @ts-expect-error - AI SDK strict mode type inference bug + execute: async ({ + limit = 10, + page = 1, + query, + }: { + limit?: number; + page?: number; + query?: string; + }) => { + const where = query ? { id: { equals: query } } : undefined; // Placeholder logic - return await payload.find({ - collection: collectionSlug as any, - limit: Math.min(limit, 100), - page, - where, - req, // Crucial for passing the user context and respecting access control! - }) - }, - }) + return await payload.find({ + collection: collectionSlug as any, + limit: Math.min(limit, 100), + page, + where, + req, // Crucial for passing the user context and respecting access control! + }); + }, + }); - // 2. Read by ID Tool - tools[`read_${slugKey}_by_id`] = tool({ - description: `Get a specific document by its ID from the ${collectionSlug} collection.`, - parameters: z.object({ - id: z.union([z.string(), z.number()]).describe('The ID of the document.'), - }), - // @ts-ignore - AI SDK strict mode type inference bug - execute: async ({ id }: { id: string | number }) => { - return await payload.findByID({ - collection: collectionSlug as any, - id, - req, // Enforce access control - }) - }, - }) + // 2. Read by ID Tool + tools[`read_${slugKey}_by_id`] = tool({ + description: `Get a specific document by its ID from the ${collectionSlug} collection.`, + parameters: z.object({ + id: z + .union([z.string(), z.number()]) + .describe("The ID of the document."), + }), + // @ts-expect-error - AI SDK strict mode type inference bug + execute: async ({ id }: { id: string | number }) => { + return await payload.findByID({ + collection: collectionSlug as any, + id, + req, // Enforce access control + }); + }, + }); - // 3. Create Tool - tools[`create_${slugKey}`] = tool({ - description: `Create a new document in the ${collectionSlug} collection.`, - parameters: z.object({ - data: z.record(z.any()).describe('A JSON object containing the data to insert.'), - }), - // @ts-ignore - AI SDK strict mode type inference bug - execute: async ({ data }: { data: Record }) => { - return await payload.create({ - collection: collectionSlug as any, - data, - req, // Enforce access control - }) - }, - }) + // 3. Create Tool + tools[`create_${slugKey}`] = tool({ + description: `Create a new document in the ${collectionSlug} collection.`, + parameters: z.object({ + data: z + .record(z.any()) + .describe("A JSON object containing the data to insert."), + }), + // @ts-expect-error - AI SDK strict mode type inference bug + execute: async ({ data }: { data: Record }) => { + return await payload.create({ + collection: collectionSlug as any, + data, + req, // Enforce access control + }); + }, + }); - // 4. Update Tool - tools[`update_${slugKey}`] = tool({ - description: `Update an existing document in the ${collectionSlug} collection.`, - parameters: z.object({ - id: z.union([z.string(), z.number()]).describe('The ID of the document to update.'), - data: z.record(z.any()).describe('A JSON object containing the fields to update.'), - }), - // @ts-ignore - AI SDK strict mode type inference bug - execute: async ({ id, data }: { id: string | number; data: Record }) => { - return await payload.update({ - collection: collectionSlug as any, - id, - data, - req, // Enforce access control - }) - }, - }) + // 4. Update Tool + tools[`update_${slugKey}`] = tool({ + description: `Update an existing document in the ${collectionSlug} collection.`, + parameters: z.object({ + id: z + .union([z.string(), z.number()]) + .describe("The ID of the document to update."), + data: z + .record(z.any()) + .describe("A JSON object containing the fields to update."), + }), + // @ts-expect-error - AI SDK strict mode type inference bug + execute: async ({ + id, + data, + }: { + id: string | number; + data: Record; + }) => { + return await payload.update({ + collection: collectionSlug as any, + id, + data, + req, // Enforce access control + }); + }, + }); - // 5. Delete Tool - tools[`delete_${slugKey}`] = tool({ - description: `Delete a document from the ${collectionSlug} collection by ID.`, - parameters: z.object({ - id: z.union([z.string(), z.number()]).describe('The ID of the document to delete.'), - }), - // @ts-ignore - AI SDK strict mode type inference bug - execute: async ({ id }: { id: string | number }) => { - return await payload.delete({ - collection: collectionSlug as any, - id, - req, // Enforce access control - }) - }, - }) - } + // 5. Delete Tool + tools[`delete_${slugKey}`] = tool({ + description: `Delete a document from the ${collectionSlug} collection by ID.`, + parameters: z.object({ + id: z + .union([z.string(), z.number()]) + .describe("The ID of the document to delete."), + }), + // @ts-expect-error - AI SDK strict mode type inference bug + execute: async ({ id }: { id: string | number }) => { + return await payload.delete({ + collection: collectionSlug as any, + id, + req, // Enforce access control + }); + }, + }); + } - return tools -} + return tools; +};