Compare commits
52 Commits
v1.9.17
...
fix/analyt
| Author | SHA1 | Date | |
|---|---|---|---|
| 87445975cb | |||
| 24625c20f2 | |||
| 9a92874011 | |||
| a54c3a784b | |||
| 7b975ed88f | |||
| 1a5a60304f | |||
| 48374528ad | |||
| 541da2db20 | |||
| 39bb862fd6 | |||
| 09a85000bb | |||
| b6e8628970 | |||
| 53ad9f23d8 | |||
| 7a9dd06fc6 | |||
| 985e532c61 | |||
| 04462f21e1 | |||
| 11babf3347 | |||
| 3de92accc4 | |||
| c8961411ec | |||
| 42ea30c11f | |||
| 7b2cc3723a | |||
| 0fc4ddbc28 | |||
| 9f8eab2ad2 | |||
| 69c483ac46 | |||
| ac178ccbb0 | |||
| 3f16828bdf | |||
| 0f7cd94e14 | |||
| 2cc6512afa | |||
| 151c98c884 | |||
| ba17d7940e | |||
| 46120dbd56 | |||
| 571c4797fa | |||
| c7d107a578 | |||
| 5f50e98a0a | |||
| 45f831ca1c | |||
| 73356cca30 | |||
| b7e630c1d8 | |||
| 13c8bc29ae | |||
| 51a892fd76 | |||
| 9cc8e573ec | |||
| a80464abc0 | |||
| 0b929d00a1 | |||
| 8ec2ecb454 | |||
| 2a9c5780c3 | |||
| fbfc9feab0 | |||
| 8486261555 | |||
| 5e1f2669e6 | |||
| 541f1c17b7 | |||
| 048fafa3db | |||
| 77e2c4f9b6 | |||
| 4eb1aaf640 | |||
| 61f2f83e0c | |||
| 2dc61e4937 |
5
.changeset/next-config-server-actions.md
Normal file
5
.changeset/next-config-server-actions.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@mintel/next-config": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: add serverActions.allowedOrigins to support branch deployments
|
||||||
23
.env
23
.env
@@ -3,6 +3,7 @@ IMAGE_TAG=v1.8.19
|
|||||||
PROJECT_NAME=at-mintel
|
PROJECT_NAME=at-mintel
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||||
|
GITEA_HOST=https://git.infra.mintel.me
|
||||||
OPENROUTER_API_KEY=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
|
OPENROUTER_API_KEY=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
|
||||||
ZYTE_API_KEY=1f0f74726f044f55aaafc7ead32cd489
|
ZYTE_API_KEY=1f0f74726f044f55aaafc7ead32cd489
|
||||||
REPLICATE_API_KEY=r8_W3grtpXMRfi0u3AM9VdkKbuWdZMmhwU2Tn0yt
|
REPLICATE_API_KEY=r8_W3grtpXMRfi0u3AM9VdkKbuWdZMmhwU2Tn0yt
|
||||||
@@ -11,6 +12,11 @@ DATA_FOR_SEO_API_KEY=bWFyY0BtaW50ZWwubWU6MjQ0YjBjZmIzOGY3NTIzZA==
|
|||||||
DATA_FOR_SEO_LOGIN=marc@mintel.me
|
DATA_FOR_SEO_LOGIN=marc@mintel.me
|
||||||
DATA_FOR_SEO_PASSWORD=244b0cfb38f7523d
|
DATA_FOR_SEO_PASSWORD=244b0cfb38f7523d
|
||||||
|
|
||||||
|
# Kabelfachmann LLM Configuration
|
||||||
|
KABELFACHMANN_LLM_PROVIDER=openrouter
|
||||||
|
KABELFACHMANN_OLLAMA_MODEL=qwen3.5
|
||||||
|
KABELFACHMANN_OLLAMA_HOST=http://host.docker.internal:11434
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
GATEKEEPER_PASSWORD=mintel
|
GATEKEEPER_PASSWORD=mintel
|
||||||
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||||
@@ -41,3 +47,20 @@ SENTRY_DSN=
|
|||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
|
UMAMI_USERNAME=mmintel
|
||||||
|
UMAMI_PASSWORD=Tim300493.
|
||||||
|
|
||||||
|
# MCP Services
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
SERPBEAR_API_KEY=Tim300493.
|
||||||
|
GLITCHTIP_API_KEY=3d37db66fa1fc3a1cdcd1357728b056225aca950f0163b50c5e922344807ede9
|
||||||
|
|
||||||
|
# Corrected MCP Base URLs
|
||||||
|
UMAMI_BASE_URL=https://analytics.infra.mintel.me
|
||||||
|
GLITCHTIP_BASE_URL=https://errors.infra.mintel.me
|
||||||
|
SERPBEAR_BASE_URL=https://seo.infra.mintel.me
|
||||||
|
|
||||||
|
# Vikunja MCP
|
||||||
|
VIKUNJA_API_TOKEN=tk_468a6f7a59b329aa42796378e55075b79eb4d3d4
|
||||||
|
VIKUNJA_BASE_URL=https://tasks.infra.mintel.me
|
||||||
|
|
||||||
|
|||||||
49
.gitea/workflows/analytics-mailer-monthly.yml
Normal file
49
.gitea/workflows/analytics-mailer-monthly.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Analytics Mailer (Monthly)
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run monthly on the 1st day of the month at 08:30 UTC
|
||||||
|
- cron: '30 8 1 * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
send-reports:
|
||||||
|
name: 📊 Send Analytics Reports (Monthly)
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
UMAMI_BASE_URL: ${{ secrets.UMAMI_BASE_URL }}
|
||||||
|
UMAMI_USERNAME: ${{ secrets.UMAMI_USERNAME }}
|
||||||
|
UMAMI_PASSWORD: ${{ secrets.UMAMI_PASSWORD }}
|
||||||
|
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||||
|
SMTP_PORT: ${{ secrets.SMTP_PORT }}
|
||||||
|
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||||
|
SMTP_PASS: ${{ secrets.SMTP_PASS }}
|
||||||
|
SMTP_SENDER: ${{ secrets.SMTP_SENDER }}
|
||||||
|
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||||
|
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node_version: 20
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
||||||
|
|
||||||
|
- name: Build mail templates
|
||||||
|
run: pnpm --filter @mintel/mail build
|
||||||
|
|
||||||
|
- name: Build analytics mailer
|
||||||
|
run: pnpm --filter @mintel/analytics-mailer build
|
||||||
|
|
||||||
|
- name: Run Analytics Mailer
|
||||||
|
run: pnpm --filter @mintel/analytics-mailer run start
|
||||||
@@ -34,29 +34,34 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN || secrets.MINTEL_PRIVATE_TOKEN || secrets.GITEA_PAT }}" >> .npmrc
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||||
|
echo "//$REGISTRY/:_authToken=${{ secrets.NPM_TOKEN || secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}" >> .npmrc
|
||||||
|
- name: 🔍 Diagnostics
|
||||||
|
run: |
|
||||||
|
echo "👤 User: $(id)"
|
||||||
|
echo "📂 Directory: $(pwd)"
|
||||||
|
echo "📂 Disk Space:"
|
||||||
|
df -h
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
pnpm store prune
|
rm -rf .next .turbo node_modules || true
|
||||||
pnpm install --no-frozen-lockfile
|
pnpm install --no-frozen-lockfile --reporter=append-only
|
||||||
- name: 📦 Archive dependencies
|
- name: 📦 Archive dependencies
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: node_modules
|
name: node_modules
|
||||||
path: |
|
path: node_modules
|
||||||
node_modules
|
|
||||||
.npmrc
|
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
static:
|
static:
|
||||||
@@ -77,7 +82,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- name: 📥 Restore dependencies
|
- name: 📥 Restore dependencies
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: node_modules
|
name: node_modules
|
||||||
- name: 🌐 HTML Validation
|
- name: 🌐 HTML Validation
|
||||||
@@ -109,7 +114,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- name: 📥 Restore dependencies
|
- name: 📥 Restore dependencies
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: node_modules
|
name: node_modules
|
||||||
- name: 🔍 Install Chromium
|
- name: 🔍 Install Chromium
|
||||||
@@ -147,7 +152,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- name: 📥 Restore dependencies
|
- name: 📥 Restore dependencies
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: node_modules
|
name: node_modules
|
||||||
- name: 📦 Depcheck
|
- name: 📦 Depcheck
|
||||||
@@ -177,7 +182,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: 📥 Restore dependencies
|
- name: 📥 Restore dependencies
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: node_modules
|
name: node_modules
|
||||||
- name: 🔍 Install Chromium
|
- name: 🔍 Install Chromium
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -51,3 +51,7 @@ apps/web/out/estimations/
|
|||||||
# Memory MCP
|
# Memory MCP
|
||||||
data/qdrant/
|
data/qdrant/
|
||||||
packages/memory-mcp/models/
|
packages/memory-mcp/models/
|
||||||
|
|
||||||
|
# Kabelfachmann MCP
|
||||||
|
packages/kabelfachmann-mcp/data/
|
||||||
|
packages/kabelfachmann-mcp/models/
|
||||||
1
apps/analytics-mailer/.gitignore
vendored
Normal file
1
apps/analytics-mailer/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dry-run-*.html
|
||||||
30
apps/analytics-mailer/config.json
Normal file
30
apps/analytics-mailer/config.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"websiteId": "59a7db94-0100-4c7e-98ef-99f45b17f9c3",
|
||||||
|
"domain": "klz-cables.com",
|
||||||
|
"clientEmail": "klaus.mintel@klz-cables.com, michael.bodemer@klz-cables.com, marc@mintel.me",
|
||||||
|
"clientName": "KLZ Cables",
|
||||||
|
"greeting": "Hallo Klaus, hallo Michael,"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"websiteId": "b9035279-72f1-49bc-909d-b073d184c388",
|
||||||
|
"domain": "mintel.me",
|
||||||
|
"clientEmail": "marc@mintel.me",
|
||||||
|
"clientName": "Mintel.me",
|
||||||
|
"greeting": "Hallo Team Mintel,"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"websiteId": "caa402bc-b2d2-4349-a254-84c0dd2a4460",
|
||||||
|
"domain": "mb-grid-solutions.com",
|
||||||
|
"clientEmail": "marc@mintel.me",
|
||||||
|
"clientName": "MB Grid Solutions",
|
||||||
|
"greeting": "Hallo Marc,"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"websiteId": "d773ea10-a3b3-4ccf-9024-987e14c4d669",
|
||||||
|
"domain": "e-tib.com",
|
||||||
|
"clientEmail": "d.joseph@e-tib.com, marc@mintel.me",
|
||||||
|
"clientName": "E-TIB GmbH",
|
||||||
|
"greeting": "Hallo Danny,"
|
||||||
|
}
|
||||||
|
]
|
||||||
27
apps/analytics-mailer/package.json
Normal file
27
apps/analytics-mailer/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/analytics-mailer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm --clean",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mintel/mail": "workspace:*",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"nodemailer": "^9.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mintel/eslint-config": "workspace:*",
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"@types/node": "^22.19.10",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/analytics-mailer/src/index.test.ts
Normal file
28
apps/analytics-mailer/src/index.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { fetchUmamiData } from "./index";
|
||||||
|
|
||||||
|
describe("fetchUmamiData", () => {
|
||||||
|
it("fetches entry and exit pages correctly", async () => {
|
||||||
|
const apiMock = {
|
||||||
|
get: vi.fn().mockImplementation((url, config) => {
|
||||||
|
if (url.includes("/stats")) return Promise.resolve({ data: { visitors: 10, visits: 10, pageviews: 10, bounces: 5, totaltime: 100 } });
|
||||||
|
if (url.includes("/pageviews")) return Promise.resolve({ data: { pageviews: [] } });
|
||||||
|
if (url.includes("/metrics")) {
|
||||||
|
if (config.params.type === "path") return Promise.resolve({ data: [{ x: "/path", y: 5 }] });
|
||||||
|
if (config.params.type === "referrer") return Promise.resolve({ data: [{ x: "google", y: 3 }] });
|
||||||
|
if (config.params.type === "entry") return Promise.resolve({ data: [{ x: "/entry", y: 4 }] });
|
||||||
|
if (config.params.type === "exit") return Promise.resolve({ data: [{ x: "/exit", y: 2 }] });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: {} });
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchUmamiData(apiMock, "test-id", 0, 1000, "Test");
|
||||||
|
|
||||||
|
// We expect the new entry and exit pages to be parsed and returned correctly
|
||||||
|
expect(result.topEntryPages).toBeDefined();
|
||||||
|
expect(result.topEntryPages[0]).toEqual({ url: "/entry", visitors: 4 });
|
||||||
|
expect(result.topExitPages).toBeDefined();
|
||||||
|
expect(result.topExitPages[0]).toEqual({ url: "/exit", visitors: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
202
apps/analytics-mailer/src/index.ts
Normal file
202
apps/analytics-mailer/src/index.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import axios from "axios";
|
||||||
|
import { render } from "@mintel/mail";
|
||||||
|
import { AnalyticsTemplate } from "@mintel/mail";
|
||||||
|
import { getThisMonth, getThisYear } from "./utils/time-calculator";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const UMAMI_BASE_URL = process.env.UMAMI_BASE_URL || "https://umami.infra.mintel.me";
|
||||||
|
const UMAMI_USERNAME = process.env.UMAMI_USERNAME;
|
||||||
|
const UMAMI_PASSWORD = process.env.UMAMI_PASSWORD;
|
||||||
|
const UMAMI_API_KEY = process.env.UMAMI_API_KEY;
|
||||||
|
|
||||||
|
const SMTP_HOST = process.env.SMTP_HOST || "mail.infra.mintel.me";
|
||||||
|
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10);
|
||||||
|
const SMTP_USER = process.env.SMTP_USER;
|
||||||
|
const SMTP_PASS = process.env.SMTP_PASS;
|
||||||
|
const SMTP_SENDER = process.env.SMTP_SENDER || "reports@mintel.me";
|
||||||
|
|
||||||
|
const GOTIFY_URL = process.env.GOTIFY_URL;
|
||||||
|
const GOTIFY_TOKEN = process.env.GOTIFY_TOKEN;
|
||||||
|
|
||||||
|
interface ConfigClient {
|
||||||
|
websiteId: string;
|
||||||
|
domain: string;
|
||||||
|
clientEmail: string;
|
||||||
|
clientName: string;
|
||||||
|
greeting?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUmamiToken(): Promise<string> {
|
||||||
|
if (UMAMI_API_KEY) {
|
||||||
|
return UMAMI_API_KEY;
|
||||||
|
}
|
||||||
|
if (!UMAMI_USERNAME || !UMAMI_PASSWORD) {
|
||||||
|
throw new Error("Missing UMAMI_USERNAME and UMAMI_PASSWORD or UMAMI_API_KEY");
|
||||||
|
}
|
||||||
|
const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, {
|
||||||
|
username: UMAMI_USERNAME,
|
||||||
|
password: UMAMI_PASSWORD,
|
||||||
|
});
|
||||||
|
return res.data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
import https from "https";
|
||||||
|
|
||||||
|
export async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number, label: string) {
|
||||||
|
let unit = "day";
|
||||||
|
if (label === "Dieses Jahr") unit = "month";
|
||||||
|
|
||||||
|
const [statsRes, pagesRes, refRes, viewsRes, entryRes, exitRes] = await Promise.all([
|
||||||
|
api.get(`/websites/${websiteId}/stats`, { params: { startAt, endAt } }),
|
||||||
|
api.get(`/websites/${websiteId}/metrics`, { params: { type: "path", limit: 5, startAt, endAt } }),
|
||||||
|
api.get(`/websites/${websiteId}/metrics`, { params: { type: "referrer", limit: 5, startAt, endAt } }),
|
||||||
|
api.get(`/websites/${websiteId}/pageviews`, { params: { startAt, endAt, unit, timezone: "Europe/Berlin" } }).catch(() => ({ data: { pageviews: [] } })),
|
||||||
|
api.get(`/websites/${websiteId}/metrics`, { params: { type: "entry", limit: 5, startAt, endAt } }),
|
||||||
|
api.get(`/websites/${websiteId}/metrics`, { params: { type: "exit", limit: 5, startAt, endAt } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = statsRes.data;
|
||||||
|
const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y || 0 }));
|
||||||
|
const topReferrers = (refRes.data || []).map((r: any) => ({ url: r.x, visitors: r.y || 0 }));
|
||||||
|
const topEntryPages = (entryRes.data || []).map((p: any) => ({ url: p.x, visitors: p.y || 0 }));
|
||||||
|
const topExitPages = (exitRes.data || []).map((p: any) => ({ url: p.x, visitors: p.y || 0 }));
|
||||||
|
|
||||||
|
const visitors = typeof stats.visitors === "object" ? stats.visitors?.value || 0 : stats.visitors || 0;
|
||||||
|
const visits = typeof stats.visits === "object" ? stats.visits?.value || 0 : stats.visits || 0;
|
||||||
|
const views = typeof stats.pageviews === "object" ? stats.pageviews?.value || 0 : stats.pageviews || 0;
|
||||||
|
const bounces = typeof stats.bounces === "object" ? stats.bounces?.value || 0 : stats.bounces || 0;
|
||||||
|
const totaltime = typeof stats.totaltime === "object" ? stats.totaltime?.value || 0 : stats.totaltime || 0;
|
||||||
|
|
||||||
|
const bounceRate = visits > 0 ? Math.round((bounces / visits) * 100) : 0;
|
||||||
|
|
||||||
|
let visitDuration = "0s";
|
||||||
|
if (visits > 0) {
|
||||||
|
const avgSeconds = Math.round(totaltime / visits);
|
||||||
|
if (avgSeconds < 60) {
|
||||||
|
visitDuration = `${avgSeconds}s`;
|
||||||
|
} else {
|
||||||
|
visitDuration = `${Math.floor(avgSeconds / 60)}m ${avgSeconds % 60}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = (viewsRes.data?.pageviews || []).map((pv: any) => ({
|
||||||
|
label: pv.x,
|
||||||
|
value: pv.y || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
visitors,
|
||||||
|
visits,
|
||||||
|
views,
|
||||||
|
bounceRate,
|
||||||
|
visitDuration,
|
||||||
|
topPages,
|
||||||
|
topReferrers,
|
||||||
|
topEntryPages,
|
||||||
|
topExitPages,
|
||||||
|
chartData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const isDryRun = process.argv.includes("--dry-run");
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
const configPath = path.resolve(process.cwd(), "config.json");
|
||||||
|
const configRaw = await fs.readFile(configPath, "utf-8");
|
||||||
|
const clients: ConfigClient[] = JSON.parse(configRaw);
|
||||||
|
|
||||||
|
// Setup Umami API
|
||||||
|
const token = await getUmamiToken();
|
||||||
|
const headers = UMAMI_API_KEY ? { "x-umami-api-key": token } : { Authorization: `Bearer ${token}` };
|
||||||
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers, httpsAgent });
|
||||||
|
|
||||||
|
// Setup SMTP Transporter (Nodemailer)
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: SMTP_HOST,
|
||||||
|
port: SMTP_PORT,
|
||||||
|
secure: SMTP_PORT === 465,
|
||||||
|
auth: SMTP_USER && SMTP_PASS ? {
|
||||||
|
user: SMTP_USER,
|
||||||
|
pass: SMTP_PASS,
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeRanges = [getThisMonth(), getThisYear()];
|
||||||
|
|
||||||
|
console.log(`Generating reports...`);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.websiteId === "replace-with-umami-website-id") continue;
|
||||||
|
|
||||||
|
console.log(`Processing ${client.domain} (${client.clientName})...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const periods = await Promise.all(
|
||||||
|
timeRanges.map((tr) => fetchUmamiData(api, client.websiteId, tr.startAt, tr.endAt, tr.label))
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await render(AnalyticsTemplate({
|
||||||
|
greeting: client.greeting || `Hallo ${client.clientName},`,
|
||||||
|
domain: client.domain,
|
||||||
|
periods,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const subject = `Performance-Report - ${client.domain}`;
|
||||||
|
|
||||||
|
const recipients = client.clientEmail.split(',').map(e => e.trim()).filter(e => e.length > 0);
|
||||||
|
|
||||||
|
if (isDryRun) {
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
console.log(`[DRY-RUN] Would send individual email to: ${recipient} (for ${client.domain})`);
|
||||||
|
}
|
||||||
|
const debugPath = path.resolve(process.cwd(), `dry-run-${client.domain}.html`);
|
||||||
|
await fs.writeFile(debugPath, html);
|
||||||
|
console.log(`[DRY-RUN] Saved HTML preview to ${debugPath}`);
|
||||||
|
} else {
|
||||||
|
if (!SMTP_HOST) {
|
||||||
|
throw new Error("SMTP_HOST is missing for live run.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: SMTP_SENDER,
|
||||||
|
to: recipient,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
console.log(`Successfully sent to ${recipient}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const errorMsg = e.response?.data || e.message;
|
||||||
|
console.error(`Failed to process client ${client.domain}:`, errorMsg);
|
||||||
|
|
||||||
|
if (GOTIFY_URL && GOTIFY_TOKEN) {
|
||||||
|
try {
|
||||||
|
await axios.post(`${GOTIFY_URL}/message?token=${GOTIFY_TOKEN}`, {
|
||||||
|
title: `❌ Analytics Mailer Error (${client.domain})`,
|
||||||
|
message: `Failed to generate or send report for ${client.domain}.\n\nError:\n${errorMsg}`,
|
||||||
|
priority: 8
|
||||||
|
});
|
||||||
|
} catch (gotifyErr: any) {
|
||||||
|
console.error("Failed to push to Gotify:", gotifyErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
19
apps/analytics-mailer/src/utils/time-calculator.ts
Normal file
19
apps/analytics-mailer/src/utils/time-calculator.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function getLast7Days() {
|
||||||
|
const endAt = Date.now();
|
||||||
|
const startAt = endAt - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
return { label: "Letzte 7 Tage", startAt, endAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThisMonth() {
|
||||||
|
const now = new Date();
|
||||||
|
const startAt = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
||||||
|
const endAt = Date.now();
|
||||||
|
return { label: "Dieser Monat", startAt, endAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThisYear() {
|
||||||
|
const now = new Date();
|
||||||
|
const startAt = new Date(now.getFullYear(), 0, 1).getTime();
|
||||||
|
const endAt = Date.now();
|
||||||
|
return { label: "Dieses Jahr", startAt, endAt };
|
||||||
|
}
|
||||||
10
apps/analytics-mailer/tsconfig.json
Normal file
10
apps/analytics-mailer/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
177
blender-mv.sh
Executable file
177
blender-mv.sh
Executable file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# blender-mv: A smart file/directory mover that automatically updates Blender paths
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
print_usage() {
|
||||||
|
echo "Usage: blender-mv [OPTIONS] SOURCE DEST"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --search-project <dir> Scan this specific project directory for .blend files"
|
||||||
|
echo " to update (useful if you move an external asset folder,"
|
||||||
|
echo " and old .blend files referencing it are elsewhere)."
|
||||||
|
echo ""
|
||||||
|
echo "Example: blender-mv ./old_folder ./new_folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
SEARCH_PROJECT=""
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ "$#" -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--search-project) SEARCH_PROJECT="$2"; shift 2 ;;
|
||||||
|
-h|--help) print_usage; exit 0 ;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$SOURCE" ]]; then
|
||||||
|
SOURCE="$1"
|
||||||
|
elif [[ -z "$DEST" ]]; then
|
||||||
|
DEST="$1"
|
||||||
|
else
|
||||||
|
echo "Unknown parameter passed: $1"
|
||||||
|
print_usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$SOURCE" || -z "$DEST" ]]; then
|
||||||
|
echo "Error: Both SOURCE and DEST must be provided."
|
||||||
|
print_usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -e "$SOURCE" ]]; then
|
||||||
|
echo "Error: Source path '$SOURCE' does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to get absolute normalized path (handles missing destination gracefully)
|
||||||
|
get_abs_path() {
|
||||||
|
python3 -c 'import os,sys; print(os.path.normpath(os.path.abspath(sys.argv[1])))' "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve source before moving
|
||||||
|
ABS_SOURCE=$(get_abs_path "$SOURCE")
|
||||||
|
|
||||||
|
# Determine resulting target path
|
||||||
|
if [[ -d "$DEST" ]]; then
|
||||||
|
# If DEST is an existing dir, standard mv puts SOURCE inside it
|
||||||
|
BASENAME_SOURCE=$(basename "$SOURCE")
|
||||||
|
ABS_DEST=$(get_abs_path "$DEST/$BASENAME_SOURCE")
|
||||||
|
else
|
||||||
|
# Placed exactly at DEST (rename or target dir doesn't exist yet)
|
||||||
|
ABS_DEST=$(get_abs_path "$DEST")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Moving: $SOURCE -> $DEST"
|
||||||
|
mv "$SOURCE" "$DEST"
|
||||||
|
|
||||||
|
echo "Replacing paths in Blender's recent-files lists..."
|
||||||
|
# Find all recent-files.txt across Blender versions and replace the string
|
||||||
|
find "$HOME/Library/Application Support/Blender" -type f -name "recent-files.txt" 2>/dev/null | while read -r RECENT_FILE; do
|
||||||
|
# Using sed with alternative delimiter to avoid path interference
|
||||||
|
sed -i '' "s|${ABS_SOURCE}|${ABS_DEST}|g" "$RECENT_FILE"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Prepare the embedded Python payload to update blend paths
|
||||||
|
TMP_PY=$(mktemp /tmp/blender_update_paths.XXXXXX.py)
|
||||||
|
cat << 'EOF' > "$TMP_PY"
|
||||||
|
import bpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def replace_in_prop(prop, old_path, new_path):
|
||||||
|
if prop and isinstance(prop, str) and old_path in prop:
|
||||||
|
return prop.replace(old_path, new_path)
|
||||||
|
return prop
|
||||||
|
|
||||||
|
def run():
|
||||||
|
# Args are passed after `--`
|
||||||
|
if '--' not in sys.argv:
|
||||||
|
return
|
||||||
|
idx = sys.argv.index('--')
|
||||||
|
if len(sys.argv) <= idx + 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
old_path = sys.argv[idx + 1]
|
||||||
|
new_path = sys.argv[idx + 2]
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# 1. Libraries (Linked objects/collections)
|
||||||
|
for lib in bpy.data.libraries:
|
||||||
|
new_fp = replace_in_prop(lib.filepath, old_path, new_path)
|
||||||
|
if new_fp != lib.filepath:
|
||||||
|
lib.filepath = new_fp
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 2. Images (Textures, Env Maps)
|
||||||
|
for img in bpy.data.images:
|
||||||
|
new_fp = replace_in_prop(img.filepath, old_path, new_path)
|
||||||
|
if new_fp != img.filepath:
|
||||||
|
img.filepath = new_fp
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 3. Cache files (Alembic/VDB)
|
||||||
|
for cache in bpy.data.cache_files:
|
||||||
|
new_fp = replace_in_prop(cache.filepath, old_path, new_path)
|
||||||
|
if new_fp != cache.filepath:
|
||||||
|
cache.filepath = new_fp
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 4. Fonts
|
||||||
|
for font in bpy.data.fonts:
|
||||||
|
new_fp = replace_in_prop(font.filepath, old_path, new_path)
|
||||||
|
if new_fp != font.filepath:
|
||||||
|
font.filepath = new_fp
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 5. Video strips
|
||||||
|
for scene in bpy.data.scenes:
|
||||||
|
if scene.sequence_editor:
|
||||||
|
for strip in scene.sequence_editor.sequences_all:
|
||||||
|
if strip.type in ('MOVIE', 'IMAGE'):
|
||||||
|
new_fp = replace_in_prop(strip.filepath, old_path, new_path)
|
||||||
|
if new_fp != strip.filepath:
|
||||||
|
strip.filepath = new_fp
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
print(f"[*] Updated paths in {bpy.data.filepath}")
|
||||||
|
bpy.ops.wm.save_mainfile()
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Error updating blend file paths: {e}")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Determine what to search for blend files
|
||||||
|
if [[ -n "$SEARCH_PROJECT" ]]; then
|
||||||
|
SEARCH_ROOT=$(get_abs_path "$SEARCH_PROJECT")
|
||||||
|
echo "Scanning project root '$SEARCH_ROOT' for .blend files..."
|
||||||
|
elif [[ -d "$ABS_DEST" ]]; then
|
||||||
|
SEARCH_ROOT="$ABS_DEST"
|
||||||
|
echo "Scanning moved directory for .blend files..."
|
||||||
|
elif [[ "$ABS_DEST" == *.blend ]]; then
|
||||||
|
SEARCH_ROOT="$ABS_DEST"
|
||||||
|
echo "Updating moved .blend file..."
|
||||||
|
else
|
||||||
|
echo "No project root defined and moved object is not a directory or .blend file."
|
||||||
|
echo "Finished."
|
||||||
|
rm -f "$TMP_PY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute headless blender on all found blend files
|
||||||
|
if [[ -d "$SEARCH_ROOT" ]]; then
|
||||||
|
find "$SEARCH_ROOT" -type f -name "*.blend" -print0 | while IFS= read -r -d '' BLEND_FILE; do
|
||||||
|
blender -b "$BLEND_FILE" -P "$TMP_PY" -- "$ABS_SOURCE" "$ABS_DEST" >/dev/null 2>&1 || echo "Warning: Failed to process $BLEND_FILE"
|
||||||
|
done
|
||||||
|
elif [[ -f "$SEARCH_ROOT" && "$SEARCH_ROOT" == *.blend ]]; then
|
||||||
|
blender -b "$SEARCH_ROOT" -P "$TMP_PY" -- "$ABS_SOURCE" "$ABS_DEST" >/dev/null 2>&1 || echo "Warning: Failed to process $SEARCH_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$TMP_PY"
|
||||||
|
echo "Success: Move complete and paths updated."
|
||||||
@@ -3,14 +3,112 @@ services:
|
|||||||
image: qdrant/qdrant:latest
|
image: qdrant/qdrant:latest
|
||||||
container_name: qdrant-mcp
|
container_name: qdrant-mcp
|
||||||
ports:
|
ports:
|
||||||
- "6333:6333"
|
- "6335:6333"
|
||||||
- "6334:6334"
|
- "6336:6334"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/qdrant:/qdrant/storage
|
- ./data/qdrant:/qdrant/storage
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- mcp-network
|
- mcp-network
|
||||||
|
|
||||||
|
gitea-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/gitea-mcp
|
||||||
|
container_name: gitea-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3011:3001"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
|
memory-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/memory-mcp
|
||||||
|
container_name: memory-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3002:3002"
|
||||||
|
depends_on:
|
||||||
|
- qdrant
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
|
umami-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/umami-mcp
|
||||||
|
container_name: umami-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3003:3003"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
|
serpbear-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/serpbear-mcp
|
||||||
|
container_name: serpbear-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3004:3004"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
|
glitchtip-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/glitchtip-mcp
|
||||||
|
container_name: glitchtip-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3005:3005"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
|
klz-payload-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/klz-payload-mcp
|
||||||
|
container_name: klz-payload-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3006:3006"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
|
vikunja-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/vikunja-mcp
|
||||||
|
container_name: vikunja-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3007:3007"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
|
outline-mcp:
|
||||||
|
build:
|
||||||
|
context: ./packages/outline-mcp
|
||||||
|
container_name: outline-mcp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3008:3008"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- mcp-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mcp-network:
|
mcp-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'gitea-mcp',
|
|
||||||
script: 'node',
|
|
||||||
args: 'dist/start.js',
|
|
||||||
cwd: './packages/gitea-mcp',
|
|
||||||
watch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'memory-mcp',
|
|
||||||
script: 'node',
|
|
||||||
args: 'dist/start.js',
|
|
||||||
cwd: './packages/memory-mcp',
|
|
||||||
watch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'umami-mcp',
|
|
||||||
script: 'node',
|
|
||||||
args: 'dist/start.js',
|
|
||||||
cwd: './packages/umami-mcp',
|
|
||||||
watch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'serpbear-mcp',
|
|
||||||
script: 'node',
|
|
||||||
args: 'dist/start.js',
|
|
||||||
cwd: './packages/serpbear-mcp',
|
|
||||||
watch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'glitchtip-mcp',
|
|
||||||
script: 'node',
|
|
||||||
args: 'dist/start.js',
|
|
||||||
cwd: './packages/glitchtip-mcp',
|
|
||||||
watch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'klz-payload-mcp',
|
|
||||||
script: 'node',
|
|
||||||
args: 'dist/start.js',
|
|
||||||
cwd: './packages/klz-payload-mcp',
|
|
||||||
watch: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
69
eslint-errors-2.txt
Normal file
69
eslint-errors-2.txt
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts[24m[0m
|
||||||
|
[0m [2m11:0[22m [31merror[39m Parsing error: Identifier expected[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts[24m[0m
|
||||||
|
[0m [2m124:19[22m [33mwarning[39m 'res' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts[24m[0m
|
||||||
|
[0m [2m39:18[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts[24m[0m
|
||||||
|
[0m [2m7:52[22m [33mwarning[39m 'text' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts[24m[0m
|
||||||
|
[0m [2m7:47[22m [33mwarning[39m 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m7:62[22m [33mwarning[39m 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx[24m[0m
|
||||||
|
[0m [2m11:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx[24m[0m
|
||||||
|
[0m [2m20:21[22m [33mwarning[39m 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx[24m[0m
|
||||||
|
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx[24m[0m
|
||||||
|
[0m [2m5:10[22m [33mwarning[39m 'Button' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts[24m[0m
|
||||||
|
[0m [2m44:15[22m [33mwarning[39m 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts[24m[0m
|
||||||
|
[0m [2m89:31[22m [33mwarning[39m 'query' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts[24m[0m
|
||||||
|
[0m [2m3:40[22m [33mwarning[39m 'User' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts[24m[0m
|
||||||
|
[0m [2m1:15[22m [33mwarning[39m 'Plugin' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx[24m[0m
|
||||||
|
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx[24m[0m
|
||||||
|
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m54:11[22m [33mwarning[39m 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx[24m[0m
|
||||||
|
[0m [2m5:13[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m12:5[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx[24m[0m
|
||||||
|
[0m [2m528:5[22m [33mwarning[39m 'bankData' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx[24m[0m
|
||||||
|
[0m [2m4:52[22m [33mwarning[39m 'PDFText' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m5:26[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts[24m[0m
|
||||||
|
[0m [2m5:3[22m [33mwarning[39m 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m6:3[22m [33mwarning[39m 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m7:3[22m [33mwarning[39m 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[31m[1m✖ 28 problems (1 error, 27 warnings)[22m[39m[0m
|
||||||
|
[0m[31m[1m[22m[39m[0m
|
||||||
97
eslint-errors.txt
Normal file
97
eslint-errors.txt
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts[24m[0m
|
||||||
|
[0m [2m12:5[22m [33mwarning[39m 'Resource' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m14:10[22m [33mwarning[39m 'z' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m427:30[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m745:50[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||||
|
[0m [2m745:60[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||||
|
[0m [2m799:54[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||||
|
[0m [2m799:64[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts[24m[0m
|
||||||
|
[0m [2m124:19[22m [33mwarning[39m 'res' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts[24m[0m
|
||||||
|
[0m [2m39:18[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts[24m[0m
|
||||||
|
[0m [2m7:52[22m [33mwarning[39m 'text' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts[24m[0m
|
||||||
|
[0m [2m7:47[22m [33mwarning[39m 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m7:62[22m [33mwarning[39m 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/chatPlugin.ts[24m[0m
|
||||||
|
[0m [2m1:15[22m [33mwarning[39m 'Config' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m10:17[22m [31merror[39m 'config' is never reassigned. Use 'const' instead [2mprefer-const[22m[0m
|
||||||
|
[0m [2m48:37[22m [33mwarning[39m 'req' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/ChatWindow/index.tsx[24m[0m
|
||||||
|
[0m [2m43:5[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m44:63[22m [33mwarning[39m 'setMessages' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx[24m[0m
|
||||||
|
[0m [2m11:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx[24m[0m
|
||||||
|
[0m [2m20:21[22m [33mwarning[39m 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx[24m[0m
|
||||||
|
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx[24m[0m
|
||||||
|
[0m [2m5:10[22m [33mwarning[39m 'Button' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/endpoints/chatEndpoint.ts[24m[0m
|
||||||
|
[0m [2m96:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m100:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts[24m[0m
|
||||||
|
[0m [2m44:15[22m [33mwarning[39m 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m53:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts[24m[0m
|
||||||
|
[0m [2m50:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m88:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m89:31[22m [33mwarning[39m 'query' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts[24m[0m
|
||||||
|
[0m [2m3:40[22m [33mwarning[39m 'User' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m25:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m45:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m61:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m78:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m [2m95:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts[24m[0m
|
||||||
|
[0m [2m1:15[22m [33mwarning[39m 'Plugin' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx[24m[0m
|
||||||
|
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx[24m[0m
|
||||||
|
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m54:11[22m [33mwarning[39m 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx[24m[0m
|
||||||
|
[0m [2m5:13[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m12:5[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx[24m[0m
|
||||||
|
[0m [2m528:5[22m [33mwarning[39m 'bankData' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx[24m[0m
|
||||||
|
[0m [2m4:52[22m [33mwarning[39m 'PDFText' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m5:26[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts[24m[0m
|
||||||
|
[0m [2m5:3[22m [33mwarning[39m 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m6:3[22m [33mwarning[39m 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m [2m7:3[22m [33mwarning[39m 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||||
|
[0m[0m
|
||||||
|
[0m[31m[1m✖ 49 problems (16 errors, 33 warnings)[22m[39m[0m
|
||||||
|
[0m[31m[1m[22m[39m[31m[1m 1 error and 0 warnings potentially fixable with the `--fix` option.[22m[39m[0m
|
||||||
|
[0m[31m[1m[22m[39m[0m
|
||||||
12
package.json
12
package.json
@@ -6,12 +6,12 @@
|
|||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"dev": "pnpm -r dev",
|
"dev": "pnpm -r dev",
|
||||||
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
|
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
|
||||||
"dev:mcps:up": "docker-compose -f docker-compose.mcps.yml up -d",
|
"dev:mcps:up": "docker-compose -f docker-compose.mcps.yml up -d --build --remove-orphans",
|
||||||
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down && pm2 delete ecosystem.mcps.config.cjs || true",
|
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down",
|
||||||
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" exec tsc -w",
|
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" exec tsc -w",
|
||||||
"dev:mcps": "npm run dev:mcps:up && pm2 start ecosystem.mcps.config.cjs --watch && npm run dev:mcps:watch",
|
"dev:mcps": "npm run dev:mcps:up && npm run dev:mcps:watch",
|
||||||
"start:mcps:run": "pm2 start ecosystem.mcps.config.cjs",
|
"start:mcps": "npm run dev:mcps:up",
|
||||||
"start:mcps": "npm run dev:mcps:up && npm run start:mcps:run",
|
"start:mcps:force": "docker-compose -f docker-compose.mcps.yml up -d --build --force-recreate --remove-orphans",
|
||||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||||
"test": "pnpm -r test",
|
"test": "pnpm -r test",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"@next/eslint-plugin-next": "16.1.6",
|
"@next/eslint-plugin-next": "16.1.6",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20.17.16",
|
"@types/node": "^20.19.33",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ShieldCheck } from "lucide-react";
|
import { ShieldCheck } from "lucide-react";
|
||||||
import Image from "next/image";
|
|
||||||
import { GateScene } from "../../components/gate-scene";
|
import { GateScene } from "../../components/gate-scene";
|
||||||
import { AnimatedLoginForm } from "../../components/animated-login-form";
|
import { AnimatedLoginForm } from "../../components/animated-login-form";
|
||||||
|
|
||||||
@@ -165,14 +164,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-14 h-14 bg-white rounded-2xl flex items-center justify-center shadow-lg shadow-black/[0.06] hover:scale-105 hover:shadow-black/10 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] border border-black/[0.06] hover:border-black/10"
|
className="w-14 h-14 bg-white rounded-2xl flex items-center justify-center shadow-lg shadow-black/[0.06] hover:scale-105 hover:shadow-black/10 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] border border-black/[0.06] hover:border-black/10"
|
||||||
>
|
>
|
||||||
<Image
|
<img
|
||||||
src="/gatekeeper/icon-white.svg"
|
src="/gatekeeper/icon-white.svg"
|
||||||
alt="Mintel"
|
alt="Mintel"
|
||||||
style={{ filter: "invert(1)" }}
|
style={{ filter: "invert(1)" }}
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
className="w-7 h-7 opacity-80"
|
className="w-7 h-7 opacity-80"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,14 +212,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="opacity-30 transition-opacity hover:opacity-60"
|
className="opacity-30 transition-opacity hover:opacity-60"
|
||||||
>
|
>
|
||||||
<Image
|
<img
|
||||||
src="/gatekeeper/logo-white.svg"
|
src="/gatekeeper/logo-white.svg"
|
||||||
alt={projectName}
|
alt={projectName}
|
||||||
width={120}
|
width={120}
|
||||||
height={36}
|
height={36}
|
||||||
className="h-5 sm:h-6 w-auto"
|
className="h-5 sm:h-6 w-auto"
|
||||||
style={{ filter: "invert(1)" }}
|
style={{ filter: "invert(1)" }}
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-[7px] font-sans font-semibold text-black/25 uppercase tracking-[0.5em] text-center">
|
<p className="text-[7px] font-sans font-semibold text-black/25 uppercase tracking-[0.5em] text-center">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
RUN corepack enable pnpm && pnpm install
|
RUN npm install -g pnpm@10.18.3 && pnpm install
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
@@ -15,5 +15,5 @@ COPY --from=builder /app/package.json ./
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Use node to run the compiled index.js
|
# Use node to run the compiled start.js
|
||||||
ENTRYPOINT ["node", "dist/index.js"]
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
|
|||||||
@@ -12,11 +12,10 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^4.19.2"
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,9 @@ import { fileURLToPath } from 'url';
|
|||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
// Try to load .env.local first (contains credentials usually)
|
// Try to load .env.local first (contains credentials usually)
|
||||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
// Fallback to .env (contains defaults)
|
// Fallback to .env (contains defaults)
|
||||||
config({ path: resolve(__dirname, '../../../.env') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
// Now boot the compiled MCP index
|
// Now boot the compiled MCP index
|
||||||
import('./index.js').catch(err => {
|
import('./index.js').catch(err => {
|
||||||
|
|||||||
15
packages/glitchtip-mcp/Dockerfile
Normal file
15
packages/glitchtip-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1"
|
"express": "^4.19.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
import express from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
@@ -23,7 +24,25 @@ const httpsAgent = new https.Agent({
|
|||||||
const glitchtipClient = axios.create({
|
const glitchtipClient = axios.create({
|
||||||
baseURL: `${GLITCHTIP_BASE_URL}/api/0`,
|
baseURL: `${GLITCHTIP_BASE_URL}/api/0`,
|
||||||
headers: { Authorization: `Bearer ${GLITCHTIP_API_KEY}` },
|
headers: { Authorization: `Bearer ${GLITCHTIP_API_KEY}` },
|
||||||
httpsAgent
|
httpsAgent,
|
||||||
|
timeout: 10000 // Add timeout to prevent hangs
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logging middleware for GlitchTip client
|
||||||
|
glitchtipClient.interceptors.request.use((config) => {
|
||||||
|
console.error(`GlitchTip Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||||
|
return config;
|
||||||
|
}, (error) => {
|
||||||
|
console.error(`GlitchTip Request Error: ${error.message}`);
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
glitchtipClient.interceptors.response.use((response) => {
|
||||||
|
return response;
|
||||||
|
}, (error) => {
|
||||||
|
const msg = error.response?.data?.detail || error.response?.data?.message || error.message;
|
||||||
|
console.error(`GlitchTip API Error (${error.config?.url}): ${msg}`);
|
||||||
|
return Promise.reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
const LIST_PROJECTS_TOOL: Tool = {
|
const LIST_PROJECTS_TOOL: Tool = {
|
||||||
@@ -87,48 +106,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
if (request.params.name === "glitchtip_list_projects") {
|
|
||||||
try {
|
try {
|
||||||
|
if (request.params.name === "glitchtip_list_projects") {
|
||||||
const res = await glitchtipClient.get('/projects/');
|
const res = await glitchtipClient.get('/projects/');
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "glitchtip_list_issues") {
|
if (request.params.name === "glitchtip_list_issues") {
|
||||||
const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any;
|
const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await glitchtipClient.get(`/projects/${organization_slug}/${project_slug}/issues/`, {
|
const res = await glitchtipClient.get(`/projects/${organization_slug}/${project_slug}/issues/`, {
|
||||||
params: { query, limit }
|
params: { query, limit }
|
||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "glitchtip_get_issue_details") {
|
if (request.params.name === "glitchtip_get_issue_details") {
|
||||||
const { issue_id } = request.params.arguments as any;
|
const { issue_id } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await glitchtipClient.get(`/issues/${issue_id}/`);
|
const res = await glitchtipClient.get(`/issues/${issue_id}/`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "glitchtip_update_issue") {
|
if (request.params.name === "glitchtip_update_issue") {
|
||||||
const { issue_id, status } = request.params.arguments as any;
|
const { issue_id, status } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await glitchtipClient.put(`/issues/${issue_id}/`, { status });
|
const res = await glitchtipClient.put(`/issues/${issue_id}/`, { status });
|
||||||
return { content: [{ type: "text", text: `Issue ${issue_id} status updated to ${status}.` }] };
|
return { content: [{ type: "text", text: `Issue ${issue_id} status updated to ${status}.` }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e.response?.data?.detail || e.response?.data?.message || e.message;
|
||||||
|
return { isError: true, content: [{ type: "text", text: `GlitchTip Service Error: ${msg}` }] };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
@@ -141,25 +149,65 @@ async function run() {
|
|||||||
console.error('GlitchTip MCP server is running on stdio');
|
console.error('GlitchTip MCP server is running on stdio');
|
||||||
} else {
|
} else {
|
||||||
const app = express();
|
const app = express();
|
||||||
let transport: SSEServerTransport | null = null;
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
app.get('/sse', async (req, res) => {
|
// Manual CORS middleware
|
||||||
console.error('New SSE connection established');
|
app.use((req, res, next) => {
|
||||||
transport = new SSEServerTransport('/message', res);
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
await server.connect(transport);
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/message', async (req, res) => {
|
app.use((req, _res, next) => {
|
||||||
|
console.error(`${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/message', res as any);
|
||||||
|
const sessionId = transport.sessionId;
|
||||||
|
console.error(`New SSE connection: ${sessionId}`);
|
||||||
|
transports.set(sessionId, transport);
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
res.write(": heartbeat\n\n");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
console.error(`SSE connection closed: ${sessionId}`);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
transports.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
req.on('close', resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/message', express.json(), async (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
|
||||||
|
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||||
|
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
res.status(400).send('No active SSE connection');
|
console.error(`No transport found for session: ${sessionId}`);
|
||||||
|
res.status(400).send('No active SSE connection for this session');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transport.handlePostMessage(req, res);
|
await transport.handlePostMessage(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.GLITCHTIP_MCP_PORT || 3005;
|
const PORT = Number(process.env.GLITCHTIP_MCP_PORT) || 3005;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.error(`GlitchTip MCP server running on http://localhost:${PORT}/sse`);
|
console.error(`GlitchTip MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
config({ path: resolve(__dirname, '../../../.env') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
import('./index.js').catch(err => {
|
import('./index.js').catch(err => {
|
||||||
console.error('Failed to start GlitchTip MCP Server:', err);
|
console.error('Failed to start GlitchTip MCP Server:', err);
|
||||||
|
|||||||
2
packages/infra/docker/config.yml
Normal file
2
packages/infra/docker/config.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
service:
|
||||||
|
allowiconchanges: false
|
||||||
42
packages/infra/docker/vikunja-compose.yml
Normal file
42
packages/infra/docker/vikunja-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: vikunja
|
||||||
|
POSTGRES_PASSWORD: mintel_vikunja_DB_2026!
|
||||||
|
POSTGRES_DB: vikunja
|
||||||
|
volumes:
|
||||||
|
- ./data/db:/var/lib/postgresql/data
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
vikunja:
|
||||||
|
image: vikunja/vikunja:latest
|
||||||
|
environment:
|
||||||
|
VIKUNJA_DATABASE_HOST: db
|
||||||
|
VIKUNJA_DATABASE_PASSWORD: mintel_vikunja_DB_2026!
|
||||||
|
VIKUNJA_DATABASE_TYPE: postgres
|
||||||
|
VIKUNJA_DATABASE_USER: vikunja
|
||||||
|
VIKUNJA_DATABASE_DATABASE: vikunja
|
||||||
|
VIKUNJA_SERVICE_PUBLICURL: https://tasks.infra.mintel.me/
|
||||||
|
VIKUNJA_SERVICE_ENABLEREGISTRATION: true
|
||||||
|
VIKUNJA_SERVICE_ALLOWICONCHANGES: false
|
||||||
|
VIKUNJA_SERVICE_JWTSECRET: d2FzaHViZXJzZWN1cmVqd3RzZWNyZXQyMDI2IQ==
|
||||||
|
volumes:
|
||||||
|
- ./data/files:/app/vikunja/files
|
||||||
|
- ./config.yml:/etc/vikunja/config.yml:ro
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: always
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.vikunja.rule=Host(`tasks.infra.mintel.me`)"
|
||||||
|
- "traefik.http.routers.vikunja.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.vikunja.tls.certresolver=le"
|
||||||
|
- "traefik.http.services.vikunja.loadbalancer.server.port=3456"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
|
||||||
|
networks:
|
||||||
|
infra:
|
||||||
|
external: true
|
||||||
@@ -56,7 +56,17 @@ fi
|
|||||||
# 3. Global Docker Pruning
|
# 3. Global Docker Pruning
|
||||||
echo "🧹 Pruning Docker resources..."
|
echo "🧹 Pruning Docker resources..."
|
||||||
docker system prune -af --filter "until=24h"
|
docker system prune -af --filter "until=24h"
|
||||||
|
|
||||||
|
echo "🧹 Pruning BuildKit builder containers and cache..."
|
||||||
|
# Remove orphaned buildkit containers to release volume locks
|
||||||
|
docker rm -f $(docker ps -aq --filter name=buildx_buildkit_builder) 2>/dev/null || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
docker buildx prune -af || true
|
||||||
|
|
||||||
|
echo "🧹 Pruning Docker volumes..."
|
||||||
docker volume prune -f
|
docker volume prune -f
|
||||||
|
# Force remove dangling buildx state volumes that might bypass standard prune
|
||||||
|
docker volume ls -q | grep buildx | xargs -r docker volume rm 2>/dev/null || true
|
||||||
|
|
||||||
echo "✅ Optimization complete!"
|
echo "✅ Optimization complete!"
|
||||||
df -h /mnt/HC_Volume_104796416
|
df -h /mnt/HC_Volume_104796416
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Configuration
|
# Find registry data dynamically (volume ID might change)
|
||||||
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
|
REGISTRY_DATA=$(find /mnt -maxdepth 5 -type d -path '*/registry-data/docker/registry/v2' | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$REGISTRY_DATA" ]; then
|
||||||
|
echo "❌ Registry data directory not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
KEEP_TAGS=3
|
KEEP_TAGS=3
|
||||||
|
|
||||||
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
|
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
|
||||||
|
|||||||
15
packages/klz-payload-mcp/Dockerfile
Normal file
15
packages/klz-payload-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
@@ -10,16 +10,15 @@
|
|||||||
"dev": "tsx watch src/index.ts"
|
"dev": "tsx watch src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^4.19.2"
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"typescript": "^5.5.3",
|
"tsx": "^4.19.2",
|
||||||
"tsx": "^4.19.2"
|
"typescript": "^5.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import crypto from "crypto";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
|
||||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://klz-cables.com";
|
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://klz-cables.com";
|
||||||
@@ -588,25 +589,60 @@ async function run() {
|
|||||||
console.error('KLZ Payload MCP server is running on stdio');
|
console.error('KLZ Payload MCP server is running on stdio');
|
||||||
} else {
|
} else {
|
||||||
const app = express();
|
const app = express();
|
||||||
let transport: SSEServerTransport | null = null;
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
app.get('/sse', async (req: Request, res: Response) => {
|
// Manual CORS middleware
|
||||||
console.error('New SSE connection established');
|
app.use((req, res, next) => {
|
||||||
transport = new SSEServerTransport('/message', res);
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
await server.connect(transport);
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/message', async (req: Request, res: Response) => {
|
app.use((req, _res, next) => {
|
||||||
|
console.error(`${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/message', res as any);
|
||||||
|
const sessionId = transport.sessionId;
|
||||||
|
console.error(`New SSE connection: ${sessionId}`);
|
||||||
|
transports.set(sessionId, transport);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
console.error(`SSE connection closed: ${sessionId}`);
|
||||||
|
transports.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
req.on('close', resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/message', express.json(), async (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
|
||||||
|
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||||
|
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
res.status(400).send('No active SSE connection');
|
console.error(`No transport found for session: ${sessionId}`);
|
||||||
|
res.status(400).send('No active SSE connection for this session');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transport.handlePostMessage(req, res);
|
await transport.handlePostMessage(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.KLZ_PAYLOAD_MCP_PORT || 3006;
|
const PORT = Number(process.env.KLZ_PAYLOAD_MCP_PORT) || 3006;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.error(`KLZ Payload MCP server running on http://localhost:${PORT}/sse`);
|
console.error(`KLZ Payload MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
config({ path: resolve(__dirname, '../../../.env') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
import('./index.js').catch(err => {
|
import('./index.js').catch(err => {
|
||||||
console.error('Failed to start KLZ Payload MCP Server:', err);
|
console.error('Failed to start KLZ Payload MCP Server:', err);
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ export * from "./templates/ConfirmationMessage";
|
|||||||
export * from "./templates/FollowUpTemplate";
|
export * from "./templates/FollowUpTemplate";
|
||||||
export * from "./templates/ProjectEstimateTemplate";
|
export * from "./templates/ProjectEstimateTemplate";
|
||||||
export * from "./templates/SiteAuditTemplate";
|
export * from "./templates/SiteAuditTemplate";
|
||||||
|
export * from "./templates/AnalyticsTemplate";
|
||||||
|
|||||||
197
packages/mail/src/layouts/GatekeeperLayout.tsx
Normal file
197
packages/mail/src/layouts/GatekeeperLayout.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Img,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export interface GatekeeperLayoutProps {
|
||||||
|
preview: string;
|
||||||
|
projectName?: string;
|
||||||
|
subTitle?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GatekeeperLayout = ({ preview, projectName = "MINTEL", subTitle = "INFRASTRUCTURE", children }: GatekeeperLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{preview}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
|
||||||
|
{/* Top Icon Box */}
|
||||||
|
<Section style={iconBoxSection}>
|
||||||
|
<div style={iconBox}>
|
||||||
|
<Img
|
||||||
|
src="https://mintel.me/favicon-32x32.png"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
alt="Mintel"
|
||||||
|
style={iconImg}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Title Area */}
|
||||||
|
<Section style={titleSection}>
|
||||||
|
<Text style={gatekeeperTitle}>
|
||||||
|
{projectName} <span style={{ color: "rgba(0,0,0,0.3)" }}>GATEKEEPER</span>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<table align="center" width="100%" border={0} cellPadding={0} cellSpacing={0} role="presentation">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="30%" align="right"><div style={lineGradientRight} /></td>
|
||||||
|
<td width="40%" align="center">
|
||||||
|
<Text style={gatekeeperSub}>
|
||||||
|
{subTitle}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td width="30%" align="left"><div style={lineGradientLeft} /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<Section style={contentSection}>
|
||||||
|
{children}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Footer Area */}
|
||||||
|
<Section style={footerSection}>
|
||||||
|
<div style={lineGradientCenter} />
|
||||||
|
<div style={{ marginTop: "24px" }}>
|
||||||
|
<Img
|
||||||
|
src="https://mintel.me/favicon-32x32.png"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
alt={projectName}
|
||||||
|
style={{ opacity: 0.3, margin: "0 auto", display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text style={footerText}>
|
||||||
|
© {new Date().getFullYear()} MINTEL
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
backgroundColor: "#f5f5f7",
|
||||||
|
color: "#111111",
|
||||||
|
fontFamily:
|
||||||
|
'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
|
||||||
|
WebkitFontSmoothing: "antialiased" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "40px 20px 80px",
|
||||||
|
maxWidth: "420px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconBoxSection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "40px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconBox = {
|
||||||
|
width: "56px",
|
||||||
|
height: "56px",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "16px",
|
||||||
|
margin: "0 auto",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
boxShadow: "0 10px 15px -3px rgba(0,0,0,0.06), 0 4px 6px -2px rgba(0,0,0,0.03)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconImg = {
|
||||||
|
margin: "14px auto",
|
||||||
|
display: "block",
|
||||||
|
opacity: 0.8,
|
||||||
|
filter: "grayscale(100%) brightness(0)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleSection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const gatekeeperTitle = {
|
||||||
|
fontSize: "11px",
|
||||||
|
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
||||||
|
fontWeight: "bold",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.5em",
|
||||||
|
color: "rgba(0,0,0,0.8)",
|
||||||
|
margin: "0 0 12px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const gatekeeperSub = {
|
||||||
|
fontSize: "8px",
|
||||||
|
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.35em",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "rgba(0,0,0,0.3)",
|
||||||
|
margin: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineGradientRight = {
|
||||||
|
height: "1px",
|
||||||
|
width: "32px",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.1)",
|
||||||
|
marginLeft: "auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineGradientLeft = {
|
||||||
|
height: "1px",
|
||||||
|
width: "32px",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.1)",
|
||||||
|
marginRight: "auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentSection = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
padding: "32px",
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.03)",
|
||||||
|
marginBottom: "32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerSection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
paddingTop: "24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineGradientCenter = {
|
||||||
|
height: "1px",
|
||||||
|
width: "64px",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.1)",
|
||||||
|
margin: "0 auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerText = {
|
||||||
|
fontSize: "7px",
|
||||||
|
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "rgba(0,0,0,0.25)",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.5em",
|
||||||
|
marginTop: "20px",
|
||||||
|
};
|
||||||
41
packages/mail/src/templates/AnalyticsTemplate.test.tsx
Normal file
41
packages/mail/src/templates/AnalyticsTemplate.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { render } from "@react-email/components";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { AnalyticsTemplate, PeriodData } from "./AnalyticsTemplate";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
describe("AnalyticsTemplate", () => {
|
||||||
|
it("renders entry and exit pages correctly", async () => {
|
||||||
|
const period: PeriodData = {
|
||||||
|
label: "Test Period",
|
||||||
|
visitors: 100,
|
||||||
|
visits: 120,
|
||||||
|
views: 500,
|
||||||
|
bounceRate: 50,
|
||||||
|
visitDuration: "2m",
|
||||||
|
topPages: [{ url: "/home", views: 50 }],
|
||||||
|
topReferrers: [{ url: "google.com", visitors: 30 }],
|
||||||
|
// These fields will cause a TS error because they don't exist yet,
|
||||||
|
// but we force it to see it fail.
|
||||||
|
topEntryPages: [{ url: "/entry", visitors: 40 }],
|
||||||
|
topExitPages: [{ url: "/exit", visitors: 20 }],
|
||||||
|
chartData: [],
|
||||||
|
} as any; // Cast as any to bypass TS compilation before we update the type
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<AnalyticsTemplate
|
||||||
|
greeting="Hallo Kunde,"
|
||||||
|
domain="example.com"
|
||||||
|
periods={[period]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert that the new sections for entry and exit pages exist.
|
||||||
|
expect(html).toContain("Häufigste Einstiege");
|
||||||
|
expect(html).toContain("Häufigste Ausstiege");
|
||||||
|
expect(html).toContain("/entry");
|
||||||
|
expect(html).toContain("/exit");
|
||||||
|
|
||||||
|
// Assert that the badges have updated line heights
|
||||||
|
expect(html).toContain('line-height:1.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
378
packages/mail/src/templates/AnalyticsTemplate.tsx
Normal file
378
packages/mail/src/templates/AnalyticsTemplate.tsx
Normal file
File diff suppressed because one or more lines are too long
18
packages/memory-mcp/Dockerfile
Normal file
18
packages/memory-mcp/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||||
|
RUN for dir in $(find /app/node_modules -type d -name "sharp" | grep "node_modules/sharp$"); do \
|
||||||
|
echo "module.exports = {};" > "$dir/lib/index.js" || true; \
|
||||||
|
done
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
@@ -14,12 +14,13 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
"@qdrant/js-client-rest": "^1.12.0",
|
"@qdrant/js-client-rest": "^1.12.0",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"onnxruntime-node": "^1.14.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^4.19.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { QdrantMemoryService } from './qdrant.js';
|
import { QdrantMemoryService } from './qdrant.js';
|
||||||
|
|
||||||
@@ -77,25 +78,65 @@ async function main() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const app = express();
|
const app = express();
|
||||||
let transport: SSEServerTransport | null = null;
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
app.get('/sse', async (req, res) => {
|
// Manual CORS middleware
|
||||||
console.error('New SSE connection established');
|
app.use((req, res, next) => {
|
||||||
transport = new SSEServerTransport('/message', res);
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
await server.connect(transport);
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/message', async (req, res) => {
|
app.use((req, _res, next) => {
|
||||||
|
console.error(`${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/message', res as any);
|
||||||
|
const sessionId = transport.sessionId;
|
||||||
|
console.error(`New SSE connection: ${sessionId}`);
|
||||||
|
transports.set(sessionId, transport);
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
res.write(": heartbeat\n\n");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
console.error(`SSE connection closed: ${sessionId}`);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
transports.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
req.on('close', resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/message', express.json(), async (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
|
||||||
|
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||||
|
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
res.status(400).send('No active SSE connection');
|
console.error(`No transport found for session: ${sessionId}`);
|
||||||
|
res.status(400).send('No active SSE connection for this session');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transport.handlePostMessage(req, res);
|
await transport.handlePostMessage(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.MEMORY_MCP_PORT || 3002;
|
const PORT = Number(process.env.MEMORY_MCP_PORT) || 3002;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, '0.0.0.0', async () => {
|
||||||
console.error(`Memory MCP server running on http://localhost:${PORT}/sse`);
|
console.error(`Memory MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||||
// Initialize dependencies in SSE mode on startup
|
// Initialize dependencies in SSE mode on startup
|
||||||
try {
|
try {
|
||||||
await qdrantService.initialize();
|
await qdrantService.initialize();
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { fileURLToPath } from 'url';
|
|||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
// Try to load .env.local first (contains credentials usually)
|
// Try to load .env.local first (contains credentials usually)
|
||||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
// Fallback to .env (contains defaults)
|
// Fallback to .env (contains defaults)
|
||||||
config({ path: resolve(__dirname, '../../../.env') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
// Now boot the compiled MCP index
|
// Now boot the compiled MCP index
|
||||||
import('./index.js').catch(err => {
|
import('./index.js').catch(err => {
|
||||||
|
|||||||
@@ -53,6 +53,31 @@ const withMintelConfig = (config) => {
|
|||||||
|
|
||||||
let nextConfig = { ...baseNextConfig, ...config };
|
let nextConfig = { ...baseNextConfig, ...config };
|
||||||
|
|
||||||
|
// Explicitly merge rewrites
|
||||||
|
nextConfig.rewrites = async () => {
|
||||||
|
const defaultRewrites = await baseNextConfig.rewrites();
|
||||||
|
let customRewrites = {};
|
||||||
|
if (typeof config.rewrites === 'function') {
|
||||||
|
customRewrites = await config.rewrites();
|
||||||
|
} else if (Array.isArray(config.rewrites)) {
|
||||||
|
customRewrites = config.rewrites;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(customRewrites)) {
|
||||||
|
return {
|
||||||
|
beforeFiles: [...customRewrites, ...defaultRewrites],
|
||||||
|
afterFiles: [],
|
||||||
|
fallback: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
beforeFiles: [...(customRewrites.beforeFiles || []), ...defaultRewrites],
|
||||||
|
afterFiles: customRewrites.afterFiles || [],
|
||||||
|
fallback: customRewrites.fallback || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
if (hasI18nConfig) {
|
if (hasI18nConfig) {
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
nextConfig = withNextIntl(nextConfig);
|
nextConfig = withNextIntl(nextConfig);
|
||||||
@@ -71,3 +96,4 @@ const withMintelConfig = (config) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default withMintelConfig;
|
export default withMintelConfig;
|
||||||
|
|
||||||
|
|||||||
15
packages/outline-mcp/Dockerfile
Normal file
15
packages/outline-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
26
packages/outline-mcp/package.json
Normal file
26
packages/outline-mcp/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/outline-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Outline MCP server for Mintel infrastructure",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/start.js",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test:unit": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vitest": "^2.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
packages/outline-mcp/src/index.test.ts
Normal file
91
packages/outline-mcp/src/index.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
|
||||||
|
// Mock environment
|
||||||
|
process.env.OUTLINE_API_TOKEN = 'test-token';
|
||||||
|
process.env.OUTLINE_BASE_URL = 'https://outline.test';
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
vi.mock('axios', () => {
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: vi.fn(() => ({
|
||||||
|
post: mockPost
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Outline MCP Server Tools', () => {
|
||||||
|
let mockPost: any;
|
||||||
|
let requestHandler: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Extract the mock
|
||||||
|
const axiosInstance = axios.create();
|
||||||
|
mockPost = axiosInstance.post;
|
||||||
|
|
||||||
|
// Mock Server setRequestHandler
|
||||||
|
let capturedHandler: any;
|
||||||
|
vi.spyOn(Server.prototype, 'setRequestHandler').mockImplementation((schema: any, handler: any) => {
|
||||||
|
// The first call is usually ListToolsRequestSchema, second is CallToolRequestSchema
|
||||||
|
// We just store the last one which is CallToolRequestSchema
|
||||||
|
capturedHandler = handler;
|
||||||
|
return {} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-import to trigger registration
|
||||||
|
vi.resetModules();
|
||||||
|
await import('./index.js');
|
||||||
|
|
||||||
|
requestHandler = capturedHandler;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search documents', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'Test' }] } });
|
||||||
|
|
||||||
|
const result = await requestHandler({
|
||||||
|
params: {
|
||||||
|
name: 'outline_search_documents',
|
||||||
|
arguments: { query: 'test' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/documents.search', { query: 'test', collectionId: undefined });
|
||||||
|
expect(result.content[0].text).toContain('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a document', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ data: { data: { id: 'doc1', title: 'Doc 1' } } });
|
||||||
|
|
||||||
|
const result = await requestHandler({
|
||||||
|
params: {
|
||||||
|
name: 'outline_get_document',
|
||||||
|
arguments: { id: 'doc1' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/documents.info', { id: 'doc1' });
|
||||||
|
expect(result.content[0].text).toContain('Doc 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a document', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ data: { data: { id: 'new-doc' } } });
|
||||||
|
|
||||||
|
const result = await requestHandler({
|
||||||
|
params: {
|
||||||
|
name: 'outline_create_document',
|
||||||
|
arguments: { collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/documents.create', {
|
||||||
|
collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true
|
||||||
|
});
|
||||||
|
expect(result.content[0].text).toContain('new-doc');
|
||||||
|
});
|
||||||
|
});
|
||||||
184
packages/outline-mcp/src/index.ts
Normal file
184
packages/outline-mcp/src/index.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
Tool,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
|
const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN;
|
||||||
|
const OUTLINE_BASE_URL = process.env.OUTLINE_BASE_URL;
|
||||||
|
|
||||||
|
if (!OUTLINE_API_TOKEN || !OUTLINE_BASE_URL) {
|
||||||
|
console.error("Missing required environment variables: OUTLINE_API_TOKEN or OUTLINE_BASE_URL");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
baseURL: OUTLINE_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${OUTLINE_API_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define tools
|
||||||
|
const tools: Tool[] = [
|
||||||
|
{
|
||||||
|
name: "outline_search_documents",
|
||||||
|
description: "Search for documents in Outline.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Search query" },
|
||||||
|
collectionId: { type: "string", description: "Optional collection ID to scope search" },
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outline_get_document",
|
||||||
|
description: "Get a document by its ID.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "The ID of the document" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outline_create_document",
|
||||||
|
description: "Create a new document.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
collectionId: { type: "string", description: "The ID of the collection" },
|
||||||
|
title: { type: "string", description: "Document title" },
|
||||||
|
text: { type: "string", description: "Document content in Markdown" },
|
||||||
|
publish: { type: "boolean", description: "Whether to publish the document" },
|
||||||
|
},
|
||||||
|
required: ["collectionId", "title", "text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outline_update_document",
|
||||||
|
description: "Update an existing document.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "The ID of the document" },
|
||||||
|
title: { type: "string", description: "New title" },
|
||||||
|
text: { type: "string", description: "New content in Markdown" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outline_create_collection",
|
||||||
|
description: "Create a new collection.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Collection name" },
|
||||||
|
description: { type: "string", description: "Collection description" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const server = new Server(
|
||||||
|
{
|
||||||
|
name: "at-mintel-outline-mcp",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return { tools };
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
try {
|
||||||
|
switch (request.params.name) {
|
||||||
|
case "outline_search_documents": {
|
||||||
|
const { query, collectionId } = request.params.arguments as any;
|
||||||
|
const res = await axiosInstance.post("/api/documents.search", {
|
||||||
|
query,
|
||||||
|
collectionId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "outline_get_document": {
|
||||||
|
const { id } = request.params.arguments as any;
|
||||||
|
const res = await axiosInstance.post("/api/documents.info", { id });
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "outline_create_document": {
|
||||||
|
const { collectionId, title, text, publish } = request.params.arguments as any;
|
||||||
|
const res = await axiosInstance.post("/api/documents.create", {
|
||||||
|
collectionId,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
publish: publish ?? true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "outline_update_document": {
|
||||||
|
const { id, title, text } = request.params.arguments as any;
|
||||||
|
const res = await axiosInstance.post("/api/documents.update", {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "outline_create_collection": {
|
||||||
|
const { name, description } = request.params.arguments as any;
|
||||||
|
const res = await axiosInstance.post("/api/collections.create", {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = error.response?.data ? JSON.stringify(error.response.data) : error.message;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `API Error: ${errorMsg}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error("Outline MCP Server running on stdio");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Fatal error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
13
packages/outline-mcp/src/start.ts
Normal file
13
packages/outline-mcp/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
|
import('./index.js').catch(err => {
|
||||||
|
console.error('Failed to start Outline MCP Server:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
16
packages/outline-mcp/tsconfig.json
Normal file
16
packages/outline-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/payload-ai",
|
"name": "@mintel/payload-ai",
|
||||||
"version": "1.9.17",
|
"version": "1.9.18",
|
||||||
"description": "Reusable Payload CMS AI Extensions",
|
"description": "Reusable Payload CMS AI Extensions",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,12 +11,13 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
"./components/*": "./dist/components/*",
|
"./components/FieldGenerators/*": "./dist/components/FieldGenerators/*.js",
|
||||||
"./actions/*": "./dist/actions/*",
|
"./components/*": "./dist/components/*.js",
|
||||||
"./globals/*": "./dist/globals/*",
|
"./actions/*": "./dist/actions/*.js",
|
||||||
"./endpoints/*": "./dist/endpoints/*",
|
"./globals/*": "./dist/globals/*.js",
|
||||||
"./utils/*": "./dist/utils/*",
|
"./endpoints/*": "./dist/endpoints/*.js",
|
||||||
"./tools/*": "./dist/tools/*"
|
"./utils/*": "./dist/utils/*.js",
|
||||||
|
"./tools/*": "./dist/tools/*.js"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@payloadcms/next": ">=3.0.0",
|
"@payloadcms/next": ">=3.0.0",
|
||||||
|
|||||||
@@ -1,76 +1,84 @@
|
|||||||
import type { Config, Plugin } from 'payload'
|
import type { Plugin } from "payload";
|
||||||
import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js'
|
import { AIChatPermissionsCollection } from "./collections/AIChatPermissions.js";
|
||||||
import type { PayloadChatPluginConfig } from './types.js'
|
import type { PayloadChatPluginConfig } from "./types.js";
|
||||||
import { optimizePostEndpoint } from './endpoints/optimizeEndpoint.js'
|
import { optimizePostEndpoint } from "./endpoints/optimizeEndpoint.js";
|
||||||
import { generateSlugEndpoint, generateThumbnailEndpoint, generateSingleFieldEndpoint } from './endpoints/generateEndpoints.js'
|
import {
|
||||||
|
generateSlugEndpoint,
|
||||||
|
generateThumbnailEndpoint,
|
||||||
|
generateSingleFieldEndpoint,
|
||||||
|
} from "./endpoints/generateEndpoints.js";
|
||||||
|
|
||||||
export const payloadChatPlugin =
|
export const payloadChatPlugin =
|
||||||
(pluginOptions: PayloadChatPluginConfig): Plugin =>
|
(pluginOptions: PayloadChatPluginConfig): Plugin =>
|
||||||
(incomingConfig) => {
|
(incomingConfig) => {
|
||||||
let config = { ...incomingConfig }
|
const config = { ...incomingConfig };
|
||||||
|
|
||||||
// If disabled, return config untouched
|
// If disabled, return config untouched
|
||||||
if (pluginOptions.enabled === false) {
|
if (pluginOptions.enabled === false) {
|
||||||
return config
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Inject the Permissions Collection into the Schema
|
// 1. Inject the Permissions Collection into the Schema
|
||||||
const existingCollections = config.collections || []
|
const existingCollections = config.collections || [];
|
||||||
|
|
||||||
const mcpServers = pluginOptions.mcpServers || []
|
const mcpServers = pluginOptions.mcpServers || [];
|
||||||
|
|
||||||
// Dynamically populate the select options for Collections and MCP Servers
|
// Dynamically populate the select options for Collections and MCP Servers
|
||||||
const permissionCollection = { ...AIChatPermissionsCollection }
|
const permissionCollection = { ...AIChatPermissionsCollection };
|
||||||
const collectionField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedCollections') as any
|
const collectionField = permissionCollection.fields.find(
|
||||||
|
(f) => "name" in f && f.name === "allowedCollections",
|
||||||
|
) as any;
|
||||||
if (collectionField) {
|
if (collectionField) {
|
||||||
collectionField.options = existingCollections.map(c => ({
|
collectionField.options = existingCollections.map((c) => ({
|
||||||
label: c.labels?.singular || c.slug,
|
label: c.labels?.singular || c.slug,
|
||||||
value: c.slug
|
value: c.slug,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const mcpField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedMcpServers') as any
|
const mcpField = permissionCollection.fields.find(
|
||||||
|
(f) => "name" in f && f.name === "allowedMcpServers",
|
||||||
|
) as any;
|
||||||
if (mcpField) {
|
if (mcpField) {
|
||||||
mcpField.options = mcpServers.map(s => ({
|
mcpField.options = mcpServers.map((s) => ({
|
||||||
label: s.name,
|
label: s.name,
|
||||||
value: s.name
|
value: s.name,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
config.collections = [...existingCollections, permissionCollection]
|
config.collections = [...existingCollections, permissionCollection];
|
||||||
|
|
||||||
// 2. Register Custom API Endpoint for the AI Chat
|
// 2. Register Custom API Endpoint for the AI Chat
|
||||||
config.endpoints = [
|
config.endpoints = [
|
||||||
...(config.endpoints || []),
|
...(config.endpoints || []),
|
||||||
{
|
{
|
||||||
path: '/api/mcp-chat',
|
path: "/api/mcp-chat",
|
||||||
method: 'post',
|
method: "post",
|
||||||
handler: async (req) => {
|
handler: async (_req) => {
|
||||||
// Fallback simple handler while developing endpoint logic
|
// Fallback simple handler while developing endpoint logic
|
||||||
return Response.json({ message: "Chat endpoint active" })
|
return Response.json({ message: "Chat endpoint active" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/api/mintel-ai/optimize',
|
path: "/api/mintel-ai/optimize",
|
||||||
method: 'post',
|
method: "post",
|
||||||
handler: optimizePostEndpoint,
|
handler: optimizePostEndpoint,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/api/mintel-ai/generate-slug',
|
path: "/api/mintel-ai/generate-slug",
|
||||||
method: 'post',
|
method: "post",
|
||||||
handler: generateSlugEndpoint,
|
handler: generateSlugEndpoint,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/api/mintel-ai/generate-thumbnail',
|
path: "/api/mintel-ai/generate-thumbnail",
|
||||||
method: 'post',
|
method: "post",
|
||||||
handler: generateThumbnailEndpoint,
|
handler: generateThumbnailEndpoint,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/api/mintel-ai/generate-single-field',
|
path: "/api/mintel-ai/generate-single-field",
|
||||||
method: 'post',
|
method: "post",
|
||||||
handler: generateSingleFieldEndpoint,
|
handler: generateSingleFieldEndpoint,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
// 3. Inject Chat React Component into Admin UI
|
// 3. Inject Chat React Component into Admin UI
|
||||||
if (pluginOptions.renderChatBubble !== false) {
|
if (pluginOptions.renderChatBubble !== false) {
|
||||||
@@ -80,11 +88,11 @@ export const payloadChatPlugin =
|
|||||||
...(config.admin?.components || {}),
|
...(config.admin?.components || {}),
|
||||||
providers: [
|
providers: [
|
||||||
...(config.admin?.components?.providers || []),
|
...(config.admin?.components?.providers || []),
|
||||||
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider',
|
"@mintel/payload-ai/components/ChatWindow/index#ChatWindowProvider",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from "react";
|
||||||
import { useChat } from '@ai-sdk/react'
|
import { useChat } from "@ai-sdk/react";
|
||||||
import './ChatWindow.scss'
|
|
||||||
|
|
||||||
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
<ChatWindow />
|
<ChatWindow />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const ChatWindow: React.FC = () => {
|
const ChatWindow: React.FC = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [pageContext, setPageContext] = useState<any>({ url: '' })
|
const [pageContext, setPageContext] = useState<any>({ url: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
let collectionSlug = null;
|
let collectionSlug = null;
|
||||||
let id = null;
|
let id = null;
|
||||||
@@ -26,7 +27,7 @@ const ChatWindow: React.FC = () => {
|
|||||||
const match = path.match(/\/collections\/([^/]+)(?:\/([^/]+))?/);
|
const match = path.match(/\/collections\/([^/]+)(?:\/([^/]+))?/);
|
||||||
if (match) {
|
if (match) {
|
||||||
collectionSlug = match[1];
|
collectionSlug = match[1];
|
||||||
if (match[2] && match[2] !== 'create') {
|
if (match[2] && match[2] !== "create") {
|
||||||
id = match[2];
|
id = match[2];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,19 +36,18 @@ const ChatWindow: React.FC = () => {
|
|||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
id
|
id,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [isOpen]) // Refresh context when chat is opened
|
}, [isOpen]); // Refresh context when chat is opened
|
||||||
|
|
||||||
// @ts-ignore - AI hook version mismatch between core and react packages
|
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||||
const { messages, input, handleInputChange, handleSubmit, setMessages } = useChat({
|
api: "/api/mcp-chat",
|
||||||
api: '/api/mcp-chat',
|
|
||||||
initialMessages: [],
|
initialMessages: [],
|
||||||
body: {
|
body: {
|
||||||
pageContext
|
pageContext,
|
||||||
}
|
},
|
||||||
} as any)
|
} as any) as any;
|
||||||
|
|
||||||
// Basic implementation to toggle chat window and submit messages
|
// Basic implementation to toggle chat window and submit messages
|
||||||
return (
|
return (
|
||||||
@@ -56,81 +56,101 @@ const ChatWindow: React.FC = () => {
|
|||||||
className="payload-mcp-chat-toggle"
|
className="payload-mcp-chat-toggle"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
bottom: '20px',
|
bottom: "20px",
|
||||||
right: '20px',
|
right: "20px",
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
padding: '12px 24px',
|
padding: "12px 24px",
|
||||||
backgroundColor: '#000',
|
backgroundColor: "#000",
|
||||||
color: '#fff',
|
color: "#fff",
|
||||||
borderRadius: '8px',
|
borderRadius: "8px",
|
||||||
border: 'none',
|
border: "none",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
fontWeight: 'bold'
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isOpen ? 'Close AI Chat' : 'Ask AI'}
|
{isOpen ? "Close AI Chat" : "Ask AI"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="payload-mcp-chat-window"
|
className="payload-mcp-chat-window"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
bottom: '80px',
|
bottom: "80px",
|
||||||
right: '20px',
|
right: "20px",
|
||||||
width: '450px',
|
width: "450px",
|
||||||
height: '650px',
|
height: "650px",
|
||||||
backgroundColor: '#fff',
|
backgroundColor: "#fff",
|
||||||
border: '1px solid #eaeaea',
|
border: "1px solid #eaeaea",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
|
boxShadow: "0 10px 40px rgba(0,0,0,0.1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="chat-header" style={{ padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }}>
|
<div
|
||||||
<h3 style={{ margin: 0, fontSize: '16px' }}>Payload MCP Chat</h3>
|
className="chat-header"
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
borderBottom: "1px solid #eaeaea",
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
borderTopLeftRadius: "12px",
|
||||||
|
borderTopRightRadius: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: 0, fontSize: "16px" }}>Payload MCP Chat</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-messages" style={{ flex: 1, padding: '16px', overflowY: 'auto' }}>
|
<div
|
||||||
|
className="chat-messages"
|
||||||
|
style={{ flex: 1, padding: "16px", overflowY: "auto" }}
|
||||||
|
>
|
||||||
{messages.map((m: any) => (
|
{messages.map((m: any) => (
|
||||||
<div key={m.id} style={{
|
<div
|
||||||
marginBottom: '12px',
|
key={m.id}
|
||||||
textAlign: m.role === 'user' ? 'right' : 'left'
|
style={{
|
||||||
}}>
|
marginBottom: "12px",
|
||||||
<div style={{
|
textAlign: m.role === "user" ? "right" : "left",
|
||||||
display: 'inline-block',
|
}}
|
||||||
padding: '8px 12px',
|
>
|
||||||
borderRadius: '8px',
|
<div
|
||||||
backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
|
style={{
|
||||||
color: m.role === 'user' ? '#fff' : '#000',
|
display: "inline-block",
|
||||||
maxWidth: '80%'
|
padding: "8px 12px",
|
||||||
}}>
|
borderRadius: "8px",
|
||||||
{m.role === 'user' ? 'G: ' : 'AI: '}
|
backgroundColor: m.role === "user" ? "#000" : "#f0f0f0",
|
||||||
|
color: m.role === "user" ? "#fff" : "#000",
|
||||||
|
maxWidth: "80%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.role === "user" ? "G: " : "AI: "}
|
||||||
{m.content}
|
{m.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ padding: '16px', borderTop: '1px solid #eaeaea' }}>
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ padding: "16px", borderTop: "1px solid #eaeaea" }}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
placeholder="Ask me anything or use /commands..."
|
placeholder="Ask me anything or use /commands..."
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
padding: '12px',
|
padding: "12px",
|
||||||
borderRadius: '8px',
|
borderRadius: "8px",
|
||||||
border: '1px solid #eaeaea',
|
border: "1px solid #eaeaea",
|
||||||
boxSizing: 'border-box'
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,115 +1,142 @@
|
|||||||
import { streamText } from 'ai'
|
import { streamText } from "ai";
|
||||||
import { createOpenAI } from '@ai-sdk/openai'
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
import { generatePayloadLocalTools } from '../tools/payloadLocal.js'
|
import { generatePayloadLocalTools } from "../tools/payloadLocal.js";
|
||||||
import { createMcpTools } from '../tools/mcpAdapter.js'
|
import { createMcpTools } from "../tools/mcpAdapter.js";
|
||||||
import { generateMemoryTools } from '../tools/memoryDb.js'
|
import { generateMemoryTools } from "../tools/memoryDb.js";
|
||||||
import type { PayloadRequest } from 'payload'
|
import type { PayloadRequest } from "payload";
|
||||||
|
|
||||||
const openrouter = createOpenAI({
|
const openrouter = createOpenAI({
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
baseURL: "https://openrouter.ai/api/v1",
|
||||||
apiKey: process.env.OPENROUTER_API_KEY || 'dummy_key',
|
apiKey: process.env.OPENROUTER_API_KEY || "dummy_key",
|
||||||
})
|
});
|
||||||
|
|
||||||
export const handleMcpChat = async (req: PayloadRequest) => {
|
export const handleMcpChat = async (req: PayloadRequest) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return Response.json({ error: 'Unauthorized. You must be logged in to use AI Chat.' }, { status: 401 })
|
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 }
|
const { messages, pageContext } = ((await req.json?.()) || {
|
||||||
|
messages: [],
|
||||||
|
}) as { messages: any[]; pageContext?: any };
|
||||||
|
|
||||||
// 1. Check AI Permissions for req.user
|
// 1. Check AI Permissions for req.user
|
||||||
// Look up the collection for permissions
|
// Look up the collection for permissions
|
||||||
const permissionsQuery = await req.payload.find({
|
const permissionsQuery = await req.payload.find({
|
||||||
collection: 'ai-chat-permissions' as any,
|
collection: "ai-chat-permissions" as any,
|
||||||
where: {
|
where: {
|
||||||
or: [
|
or: [
|
||||||
{ targetUser: { equals: req.user.id } },
|
{ targetUser: { equals: req.user.id } },
|
||||||
{ targetRole: { equals: req.user.role || 'admin' } }
|
{ targetRole: { equals: req.user.role || "admin" } },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
limit: 10
|
limit: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const allowedCollections = new Set<string>()
|
const allowedCollections = new Set<string>();
|
||||||
const allowedMcpServers = new Set<string>()
|
const allowedMcpServers = new Set<string>();
|
||||||
|
|
||||||
for (const perm of permissionsQuery.docs) {
|
for (const perm of permissionsQuery.docs) {
|
||||||
if (perm.allowedCollections) {
|
if (perm.allowedCollections) {
|
||||||
perm.allowedCollections.forEach((c: string) => allowedCollections.add(c))
|
perm.allowedCollections.forEach((c: string) => allowedCollections.add(c));
|
||||||
}
|
}
|
||||||
if (perm.allowedMcpServers) {
|
if (perm.allowedMcpServers) {
|
||||||
perm.allowedMcpServers.forEach((s: string) => allowedMcpServers.add(s))
|
perm.allowedMcpServers.forEach((s: string) => allowedMcpServers.add(s));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let accessCollections = Array.from(allowedCollections)
|
let accessCollections = Array.from(allowedCollections);
|
||||||
if (accessCollections.length === 0) {
|
if (accessCollections.length === 0) {
|
||||||
// Fallback or demo config if not configured yet
|
// Fallback or demo config if not configured yet
|
||||||
accessCollections = ['users', 'pages', 'posts', 'products', 'leads', 'media']
|
accessCollections = [
|
||||||
|
"users",
|
||||||
|
"pages",
|
||||||
|
"posts",
|
||||||
|
"products",
|
||||||
|
"leads",
|
||||||
|
"media",
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeTools: Record<string, any> = {}
|
let activeTools: Record<string, any> = {};
|
||||||
|
|
||||||
// 2. Generate Payload Local Tools
|
// 2. Generate Payload Local Tools
|
||||||
if (accessCollections.length > 0) {
|
if (accessCollections.length > 0) {
|
||||||
const payloadTools = generatePayloadLocalTools(req.payload, req, accessCollections)
|
const payloadTools = generatePayloadLocalTools(
|
||||||
activeTools = { ...activeTools, ...payloadTools }
|
req.payload,
|
||||||
|
req,
|
||||||
|
accessCollections,
|
||||||
|
);
|
||||||
|
activeTools = { ...activeTools, ...payloadTools };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Connect External MCPs
|
// 3. Connect External MCPs
|
||||||
if (Array.from(allowedMcpServers).includes('gitea')) {
|
if (Array.from(allowedMcpServers).includes("gitea")) {
|
||||||
try {
|
try {
|
||||||
const { tools: giteaTools } = await createMcpTools({
|
const { tools: giteaTools } = await createMcpTools({
|
||||||
name: 'gitea',
|
name: "gitea",
|
||||||
command: 'npx',
|
command: "npx",
|
||||||
args: ['-y', '@modelcontextprotocol/server-gitea', '--url', 'https://git.mintel.int', '--token', process.env.GITEA_TOKEN || '']
|
args: [
|
||||||
})
|
"-y",
|
||||||
activeTools = { ...activeTools, ...giteaTools }
|
"@modelcontextprotocol/server-gitea",
|
||||||
|
"--url",
|
||||||
|
"https://git.mintel.int",
|
||||||
|
"--token",
|
||||||
|
process.env.GITEA_TOKEN || "",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
activeTools = { ...activeTools, ...giteaTools };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to connect to Gitea MCP', e)
|
console.error("Failed to connect to Gitea MCP", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Inject Memory Database Tools
|
// 4. Inject Memory Database Tools
|
||||||
// We provide the user ID so memory is partitioned per user
|
// We provide the user ID so memory is partitioned per user
|
||||||
const memoryTools = generateMemoryTools(req.user.id)
|
const memoryTools = generateMemoryTools(req.user.id);
|
||||||
activeTools = { ...activeTools, ...memoryTools }
|
activeTools = { ...activeTools, ...memoryTools };
|
||||||
|
|
||||||
// 5. Build prompt to ensure it asks before saving
|
// 5. Build prompt to ensure it asks before saving
|
||||||
const memorySystemPrompt = `
|
const memorySystemPrompt = `
|
||||||
You have access to a long-term vector memory database (Qdrant).
|
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 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.
|
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:
|
Current User Context:
|
||||||
URL: ${pageContext.url || 'Unknown'}
|
URL: ${pageContext.url || "Unknown"}
|
||||||
Title: ${pageContext.title || 'Unknown'}
|
Title: ${pageContext.title || "Unknown"}
|
||||||
Collection: ${pageContext.collectionSlug || 'None'}
|
Collection: ${pageContext.collectionSlug || "None"}
|
||||||
Document ID: ${pageContext.id || 'None'}
|
Document ID: ${pageContext.id || "None"}
|
||||||
You can use this to understand what the user is currently looking at.
|
You can use this to understand what the user is currently looking at.
|
||||||
` : ''
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
// @ts-ignore - AI SDK type mismatch
|
model: openrouter("google/gemini-3.0-flash"),
|
||||||
model: openrouter('google/gemini-3.0-flash'),
|
|
||||||
messages,
|
messages,
|
||||||
tools: activeTools,
|
tools: activeTools,
|
||||||
// @ts-ignore - AI SDK type mismatch with maxSteps
|
// @ts-expect-error - AI SDK type mismatch with maxSteps
|
||||||
maxSteps: 10,
|
maxSteps: 10,
|
||||||
system: `You are a helpful Payload CMS Agent orchestrating the local Mintel ecosystem.
|
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 only have access to tools explicitly granted by the Admin.
|
||||||
You can completely control Payload CMS (read, create, update, delete documents).
|
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.
|
If you need more details to fulfill a request (e.g. creating a blog post), you can ask the user.
|
||||||
${contextContextStr}
|
${contextContextStr}
|
||||||
${memorySystemPrompt}`
|
${memorySystemPrompt}`,
|
||||||
})
|
});
|
||||||
|
|
||||||
return result.toTextStreamResponse()
|
return result.toTextStreamResponse();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("AI Error:", error)
|
console.error("AI Error:", error);
|
||||||
return Response.json({ error: 'Failed to process AI request' }, { status: 500 })
|
return Response.json(
|
||||||
}
|
{ error: "Failed to process AI request" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ async function getOrchestrator() {
|
|||||||
|
|
||||||
export const generateSlugEndpoint = async (req: PayloadRequest) => {
|
export const generateSlugEndpoint = async (req: PayloadRequest) => {
|
||||||
try {
|
try {
|
||||||
const { title, draftContent, oldSlug, instructions } = (await req.json?.() || {}) as any;
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
if (req.body) body = (await req.json?.()) || {};
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const { title, draftContent, oldSlug, instructions } = body;
|
||||||
const orchestrator = await getOrchestrator();
|
const orchestrator = await getOrchestrator();
|
||||||
const newSlug = await orchestrator.generateSlug(
|
const newSlug = await orchestrator.generateSlug(
|
||||||
draftContent,
|
draftContent,
|
||||||
@@ -50,11 +56,17 @@ export const generateSlugEndpoint = async (req: PayloadRequest) => {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
|
export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
|
||||||
try {
|
try {
|
||||||
const { draftContent, title, instructions } = (await req.json?.() || {}) as any;
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
if (req.body) body = (await req.json?.()) || {};
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const { draftContent, title, instructions } = body;
|
||||||
const OPENROUTER_KEY =
|
const OPENROUTER_KEY =
|
||||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||||
@@ -62,9 +74,16 @@ export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
|
|||||||
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY in .env");
|
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY in .env");
|
||||||
if (!REPLICATE_KEY) throw new Error("Missing REPLICATE_API_KEY in .env");
|
if (!REPLICATE_KEY) throw new Error("Missing REPLICATE_API_KEY in .env");
|
||||||
|
|
||||||
const importDynamic = new Function("modulePath", "return import(modulePath)");
|
const importDynamic = new Function(
|
||||||
const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
|
"modulePath",
|
||||||
const { ThumbnailGenerator } = await importDynamic("@mintel/thumbnail-generator");
|
"return import(modulePath)",
|
||||||
|
);
|
||||||
|
const { AiBlogPostOrchestrator } = await importDynamic(
|
||||||
|
"@mintel/content-engine",
|
||||||
|
);
|
||||||
|
const { ThumbnailGenerator } = await importDynamic(
|
||||||
|
"@mintel/thumbnail-generator",
|
||||||
|
);
|
||||||
|
|
||||||
const orchestrator = new AiBlogPostOrchestrator({
|
const orchestrator = new AiBlogPostOrchestrator({
|
||||||
apiKey: OPENROUTER_KEY,
|
apiKey: OPENROUTER_KEY,
|
||||||
@@ -105,11 +124,23 @@ export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const generateSingleFieldEndpoint = async (req: PayloadRequest) => {
|
export const generateSingleFieldEndpoint = async (req: PayloadRequest) => {
|
||||||
try {
|
try {
|
||||||
const { documentTitle, documentContent, fieldName, fieldDescription, instructions } = (await req.json?.() || {}) as any;
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
if (req.body) body = (await req.json?.()) || {};
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
documentTitle,
|
||||||
|
documentContent,
|
||||||
|
fieldName,
|
||||||
|
fieldDescription,
|
||||||
|
instructions,
|
||||||
|
} = body;
|
||||||
|
|
||||||
const OPENROUTER_KEY =
|
const OPENROUTER_KEY =
|
||||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||||
@@ -155,4 +186,4 @@ CRITICAL RULES:
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
import { PayloadRequest } from 'payload'
|
import { PayloadRequest } from "payload";
|
||||||
import { parseMarkdownToLexical } from "../utils/lexicalParser.js";
|
import { parseMarkdownToLexical } from "../utils/lexicalParser.js";
|
||||||
|
|
||||||
export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
||||||
try {
|
try {
|
||||||
const { draftContent, instructions } = (await req.json?.() || {}) as { draftContent: string; instructions?: string };
|
let body: any = {};
|
||||||
|
try {
|
||||||
if (!draftContent) {
|
if (req.body) {
|
||||||
return Response.json({ error: 'Missing draftContent' }, { status: 400 })
|
// req.json() acts as a method in Next.js/Payload req wrapper
|
||||||
|
body = (await req.json?.()) || {};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore JSON parse error, body remains empty
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalAiSettings = (await req.payload.findGlobal({ slug: "ai-settings" })) as any;
|
const { draftContent, instructions } = body as {
|
||||||
|
draftContent?: string;
|
||||||
|
instructions?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!draftContent) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "Missing draftContent" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalAiSettings = (await req.payload.findGlobal({
|
||||||
|
slug: "ai-settings",
|
||||||
|
})) as any;
|
||||||
const customSources =
|
const customSources =
|
||||||
globalAiSettings?.customSources?.map((s: any) => s.sourceName) || [];
|
globalAiSettings?.customSources?.map((s: any) => s.sourceName) || [];
|
||||||
|
|
||||||
@@ -18,12 +36,20 @@ export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
|||||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||||
|
|
||||||
if (!OPENROUTER_KEY) {
|
if (!OPENROUTER_KEY) {
|
||||||
return Response.json({ error: "OPENROUTER_KEY not found in environment." }, { status: 500 })
|
return Response.json(
|
||||||
|
{ error: "OPENROUTER_KEY not found in environment." },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamically import to avoid bundling it into client components that might accidentally import this file
|
// Dynamically import to avoid bundling it into client components that might accidentally import this file
|
||||||
const importDynamic = new Function("modulePath", "return import(modulePath)");
|
const importDynamic = new Function(
|
||||||
const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
|
"modulePath",
|
||||||
|
"return import(modulePath)",
|
||||||
|
);
|
||||||
|
const { AiBlogPostOrchestrator } = await importDynamic(
|
||||||
|
"@mintel/content-engine",
|
||||||
|
);
|
||||||
|
|
||||||
const orchestrator = new AiBlogPostOrchestrator({
|
const orchestrator = new AiBlogPostOrchestrator({
|
||||||
apiKey: OPENROUTER_KEY,
|
apiKey: OPENROUTER_KEY,
|
||||||
@@ -47,7 +73,10 @@ export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") {
|
if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") {
|
||||||
return Response.json({ error: "AI returned invalid markup." }, { status: 500 })
|
return Response.json(
|
||||||
|
{ error: "AI returned invalid markup." },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks = parseMarkdownToLexical(optimizedMarkdown);
|
const blocks = parseMarkdownToLexical(optimizedMarkdown);
|
||||||
@@ -64,12 +93,16 @@ export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
|||||||
direction: "ltr",
|
direction: "ltr",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to optimize post in endpoint:", error);
|
console.error("Failed to optimize post in endpoint:", error);
|
||||||
return Response.json({
|
return Response.json(
|
||||||
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message || "An unknown error occurred during optimization.",
|
error:
|
||||||
}, { status: 500 })
|
error.message || "An unknown error occurred during optimization.",
|
||||||
}
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
import { tool } from 'ai'
|
import { tool, jsonSchema } from "ai";
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects to an external MCP Server and maps its tools to Vercel AI SDK Tools.
|
* 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[] }) {
|
export async function createMcpTools(mcpConfig: {
|
||||||
let transport
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
}) {
|
||||||
|
let transport;
|
||||||
|
|
||||||
// Support both HTTP/SSE and STDIO transports
|
// Support both HTTP/SSE and STDIO transports
|
||||||
if (mcpConfig.url) {
|
if (mcpConfig.url) {
|
||||||
transport = new SSEClientTransport(new URL(mcpConfig.url))
|
transport = new SSEClientTransport(new URL(mcpConfig.url));
|
||||||
} else if (mcpConfig.command) {
|
} else if (mcpConfig.command) {
|
||||||
transport = new StdioClientTransport({
|
transport = new StdioClientTransport({
|
||||||
command: mcpConfig.command,
|
command: mcpConfig.command,
|
||||||
args: mcpConfig.args || [],
|
args: mcpConfig.args || [],
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid MCP config: Must provide either URL or Command.')
|
throw new Error("Invalid MCP config: Must provide either URL or Command.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
{ name: `payload-ai-client-${mcpConfig.name}`, version: '1.0.0' },
|
{ name: `payload-ai-client-${mcpConfig.name}`, version: "1.0.0" },
|
||||||
{ capabilities: {} }
|
{ capabilities: {} },
|
||||||
)
|
);
|
||||||
|
|
||||||
await client.connect(transport)
|
await client.connect(transport);
|
||||||
|
|
||||||
// Fetch available tools from the external MCP server
|
// Fetch available tools from the external MCP server
|
||||||
const toolListResult = await client.listTools()
|
const toolListResult = await client.listTools();
|
||||||
const externalTools = toolListResult.tools || []
|
const externalTools = toolListResult.tools || [];
|
||||||
|
|
||||||
const aiSdkTools: Record<string, any> = {}
|
const aiSdkTools: Record<string, any> = {};
|
||||||
|
|
||||||
// Map each external tool to a Vercel AI SDK Tool
|
// Map each external tool to a Vercel AI SDK Tool
|
||||||
for (const extTool of externalTools) {
|
for (const extTool of externalTools) {
|
||||||
@@ -41,7 +44,7 @@ export async function createMcpTools(mcpConfig: { name: string, url?: string, co
|
|||||||
// Note: For a production ready adapter, you might need a more robust jsonSchemaToZod converter
|
// 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.
|
// 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.
|
// Here we use a generic `z.any()` as a fallback since AI SDK requires a Zod schema.
|
||||||
const toolSchema = extTool.inputSchema as Record<string, any>
|
const toolSchema = extTool.inputSchema as Record<string, any>;
|
||||||
|
|
||||||
// We create a simplified parameter parser.
|
// We create a simplified parameter parser.
|
||||||
// An ideal approach uses `jsonSchemaToZod` library or native AI SDK JSON schema support
|
// An ideal approach uses `jsonSchemaToZod` library or native AI SDK JSON schema support
|
||||||
@@ -49,17 +52,17 @@ export async function createMcpTools(mcpConfig: { name: string, url?: string, co
|
|||||||
|
|
||||||
aiSdkTools[`${mcpConfig.name}_${extTool.name}`] = tool({
|
aiSdkTools[`${mcpConfig.name}_${extTool.name}`] = tool({
|
||||||
description: `[From ${mcpConfig.name}] ${extTool.description || extTool.name}`,
|
description: `[From ${mcpConfig.name}] ${extTool.description || extTool.name}`,
|
||||||
parameters: z.any().describe('JSON matching the original MCP input_schema'), // Simplify for prototype
|
parameters: jsonSchema(toolSchema as any),
|
||||||
// @ts-ignore - AI strict mode overload bug with implicit zod inferences
|
// @ts-expect-error - AI strict mode overload bug with implicit zod inferences
|
||||||
execute: async (args: any) => {
|
execute: async (args: any) => {
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: extTool.name,
|
name: extTool.name,
|
||||||
arguments: args
|
arguments: args,
|
||||||
})
|
});
|
||||||
return result
|
return result;
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tools: aiSdkTools, client }
|
return { tools: aiSdkTools, client };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import { tool } from 'ai'
|
import { tool } from "ai";
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { QdrantClient } from '@qdrant/js-client-rest'
|
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||||
|
|
||||||
// Qdrant initialization
|
// Qdrant initialization
|
||||||
// This requires the user to have Qdrant running and QDRANT_URL/QDRANT_API_KEY environment variables set
|
// This requires the user to have Qdrant running and QDRANT_URL/QDRANT_API_KEY environment variables set
|
||||||
const qdrantClient = new QdrantClient({
|
const qdrantClient = new QdrantClient({
|
||||||
url: process.env.QDRANT_URL || 'http://localhost:6333',
|
url: process.env.QDRANT_URL || "http://localhost:6333",
|
||||||
apiKey: process.env.QDRANT_API_KEY,
|
apiKey: process.env.QDRANT_API_KEY,
|
||||||
})
|
});
|
||||||
|
|
||||||
const MEMORY_COLLECTION = 'mintel_ai_memory'
|
const MEMORY_COLLECTION = "mintel_ai_memory";
|
||||||
|
|
||||||
// Ensure collection exists on load
|
// Ensure collection exists on load
|
||||||
async function initQdrant() {
|
async function initQdrant() {
|
||||||
try {
|
try {
|
||||||
const res = await qdrantClient.getCollections()
|
const res = await qdrantClient.getCollections();
|
||||||
const exists = res.collections.find((c: any) => c.name === MEMORY_COLLECTION)
|
const exists = res.collections.find(
|
||||||
|
(c: any) => c.name === MEMORY_COLLECTION,
|
||||||
|
);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await qdrantClient.createCollection(MEMORY_COLLECTION, {
|
await qdrantClient.createCollection(MEMORY_COLLECTION, {
|
||||||
vectors: {
|
vectors: {
|
||||||
size: 1536, // typical embedding size, adjust based on the embedding model used
|
size: 1536, // typical embedding size, adjust based on the embedding model used
|
||||||
distance: 'Cosine',
|
distance: "Cosine",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
console.log(`Qdrant collection '${MEMORY_COLLECTION}' created.`)
|
console.log(`Qdrant collection '${MEMORY_COLLECTION}' created.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize Qdrant memory collection:', error)
|
console.error("Failed to initialize Qdrant memory collection:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call init, but don't block
|
// Call init, but don't block
|
||||||
initQdrant()
|
initQdrant();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns memory tools for the AI SDK.
|
* Returns memory tools for the AI SDK.
|
||||||
@@ -42,20 +44,34 @@ initQdrant()
|
|||||||
export const generateMemoryTools = (userId: string | number) => {
|
export const generateMemoryTools = (userId: string | number) => {
|
||||||
return {
|
return {
|
||||||
save_memory: tool({
|
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.',
|
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({
|
parameters: z.object({
|
||||||
fact: z.string().describe('The fact or instruction to remember.'),
|
fact: z.string().describe("The fact or instruction to remember."),
|
||||||
category: z.string().optional().describe('An optional category like "preference", "rule", or "project_detail".'),
|
category: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'An optional category like "preference", "rule", or "project_detail".',
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
// @ts-ignore - AI SDK strict mode bug
|
// @ts-expect-error - AI SDK strict mode bug
|
||||||
execute: async ({ fact, category }: { fact: string; category?: string }) => {
|
execute: async ({
|
||||||
|
fact,
|
||||||
|
category,
|
||||||
|
}: {
|
||||||
|
fact: string;
|
||||||
|
category?: string;
|
||||||
|
}) => {
|
||||||
// In a real scenario, you MUST generate embeddings for the 'fact' string here
|
// In a real scenario, you MUST generate embeddings for the 'fact' string here
|
||||||
// using OpenAI or another embedding provider before inserting into Qdrant.
|
// using OpenAI or another embedding provider before inserting into Qdrant.
|
||||||
// const embedding = await generateEmbedding(fact)
|
// const embedding = await generateEmbedding(fact)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mock embedding payload for demonstration
|
// Mock embedding payload for demonstration
|
||||||
const mockEmbedding = new Array(1536).fill(0).map(() => Math.random())
|
const mockEmbedding = new Array(1536)
|
||||||
|
.fill(0)
|
||||||
|
.map(() => Math.random());
|
||||||
|
|
||||||
await qdrantClient.upsert(MEMORY_COLLECTION, {
|
await qdrantClient.upsert(MEMORY_COLLECTION, {
|
||||||
wait: true,
|
wait: true,
|
||||||
@@ -71,24 +87,33 @@ export const generateMemoryTools = (userId: string | number) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
return { success: true, message: `Successfully remembered: "${fact}"` }
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully remembered: "${fact}"`,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Qdrant save error:", error)
|
console.error("Qdrant save error:", error);
|
||||||
return { success: false, error: 'Failed to save to memory database.' }
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to save to memory database.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
search_memory: tool({
|
search_memory: tool({
|
||||||
description: 'Search the user\'s long-term memory for past factual context, preferences, or rules.',
|
description:
|
||||||
|
"Search the user's long-term memory for past factual context, preferences, or rules.",
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
query: z.string().describe('The search string to find in memory.'),
|
query: z.string().describe("The search string to find in memory."),
|
||||||
}),
|
}),
|
||||||
// @ts-ignore - AI SDK strict mode bug
|
// @ts-expect-error - AI SDK strict mode bug
|
||||||
execute: async ({ query }: { query: string }) => {
|
execute: async ({ query }: { query: string }) => {
|
||||||
// Generate embedding for query
|
// Generate embedding for query
|
||||||
const mockQueryEmbedding = new Array(1536).fill(0).map(() => Math.random())
|
const mockQueryEmbedding = new Array(1536)
|
||||||
|
.fill(0)
|
||||||
|
.map(() => Math.random());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await qdrantClient.search(MEMORY_COLLECTION, {
|
const results = await qdrantClient.search(MEMORY_COLLECTION, {
|
||||||
@@ -97,19 +122,19 @@ export const generateMemoryTools = (userId: string | number) => {
|
|||||||
filter: {
|
filter: {
|
||||||
must: [
|
must: [
|
||||||
{
|
{
|
||||||
key: 'userId',
|
key: "userId",
|
||||||
match: { value: String(userId) }
|
match: { value: String(userId) },
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return results.map((r: any) => r.payload?.fact || '')
|
return results.map((r: any) => r.payload?.fact || "");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Qdrant search error:", error)
|
console.error("Qdrant search error:", error);
|
||||||
return []
|
return [];
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
import { tool } from 'ai'
|
import { tool } from "ai";
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import type { Payload, PayloadRequest, User } from 'payload'
|
import type { Payload, PayloadRequest, User } from "payload";
|
||||||
|
|
||||||
export const generatePayloadLocalTools = (
|
export const generatePayloadLocalTools = (
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
req: PayloadRequest,
|
req: PayloadRequest,
|
||||||
allowedCollections: string[]
|
allowedCollections: string[],
|
||||||
) => {
|
) => {
|
||||||
const tools: Record<string, any> = {}
|
const tools: Record<string, any> = {};
|
||||||
|
|
||||||
for (const collectionSlug of allowedCollections) {
|
for (const collectionSlug of allowedCollections) {
|
||||||
const slugKey = collectionSlug.replace(/-/g, '_')
|
const slugKey = collectionSlug.replace(/-/g, "_");
|
||||||
|
|
||||||
// 1. Read (Find) Tool
|
// 1. Read (Find) Tool
|
||||||
tools[`read_${slugKey}`] = tool({
|
tools[`read_${slugKey}`] = tool({
|
||||||
description: `Read/Find documents from the Payload CMS collection: ${collectionSlug}`,
|
description: `Read/Find documents from the Payload CMS collection: ${collectionSlug}`,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
limit: z.number().optional().describe('Number of documents to return, max 100.'),
|
limit: z
|
||||||
page: z.number().optional().describe('Page number for pagination.'),
|
.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,
|
// 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.
|
// 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.'),
|
query: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Optional text to search within the collection."),
|
||||||
}),
|
}),
|
||||||
// @ts-ignore - AI SDK strict mode type inference bug
|
// @ts-expect-error - AI SDK strict mode type inference bug
|
||||||
execute: async ({ limit = 10, page = 1, query }: { limit?: number; page?: number; query?: string }) => {
|
execute: async ({
|
||||||
const where = query ? { id: { equals: query } } : undefined // Placeholder logic
|
limit = 10,
|
||||||
|
page = 1,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
query?: string;
|
||||||
|
}) => {
|
||||||
|
const where = query ? { id: { equals: query } } : undefined; // Placeholder logic
|
||||||
|
|
||||||
return await payload.find({
|
return await payload.find({
|
||||||
collection: collectionSlug as any,
|
collection: collectionSlug as any,
|
||||||
@@ -32,76 +46,92 @@ export const generatePayloadLocalTools = (
|
|||||||
page,
|
page,
|
||||||
where,
|
where,
|
||||||
req, // Crucial for passing the user context and respecting access control!
|
req, // Crucial for passing the user context and respecting access control!
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// 2. Read by ID Tool
|
// 2. Read by ID Tool
|
||||||
tools[`read_${slugKey}_by_id`] = tool({
|
tools[`read_${slugKey}_by_id`] = tool({
|
||||||
description: `Get a specific document by its ID from the ${collectionSlug} collection.`,
|
description: `Get a specific document by its ID from the ${collectionSlug} collection.`,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
id: z.union([z.string(), z.number()]).describe('The ID of the document.'),
|
id: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.describe("The ID of the document."),
|
||||||
}),
|
}),
|
||||||
// @ts-ignore - AI SDK strict mode type inference bug
|
// @ts-expect-error - AI SDK strict mode type inference bug
|
||||||
execute: async ({ id }: { id: string | number }) => {
|
execute: async ({ id }: { id: string | number }) => {
|
||||||
return await payload.findByID({
|
return await payload.findByID({
|
||||||
collection: collectionSlug as any,
|
collection: collectionSlug as any,
|
||||||
id,
|
id,
|
||||||
req, // Enforce access control
|
req, // Enforce access control
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// 3. Create Tool
|
// 3. Create Tool
|
||||||
tools[`create_${slugKey}`] = tool({
|
tools[`create_${slugKey}`] = tool({
|
||||||
description: `Create a new document in the ${collectionSlug} collection.`,
|
description: `Create a new document in the ${collectionSlug} collection.`,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
data: z.record(z.any()).describe('A JSON object containing the data to insert.'),
|
data: z
|
||||||
|
.record(z.any())
|
||||||
|
.describe("A JSON object containing the data to insert."),
|
||||||
}),
|
}),
|
||||||
// @ts-ignore - AI SDK strict mode type inference bug
|
// @ts-expect-error - AI SDK strict mode type inference bug
|
||||||
execute: async ({ data }: { data: Record<string, any> }) => {
|
execute: async ({ data }: { data: Record<string, any> }) => {
|
||||||
return await payload.create({
|
return await payload.create({
|
||||||
collection: collectionSlug as any,
|
collection: collectionSlug as any,
|
||||||
data,
|
data,
|
||||||
req, // Enforce access control
|
req, // Enforce access control
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// 4. Update Tool
|
// 4. Update Tool
|
||||||
tools[`update_${slugKey}`] = tool({
|
tools[`update_${slugKey}`] = tool({
|
||||||
description: `Update an existing document in the ${collectionSlug} collection.`,
|
description: `Update an existing document in the ${collectionSlug} collection.`,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
id: z.union([z.string(), z.number()]).describe('The ID of the document to update.'),
|
id: z
|
||||||
data: z.record(z.any()).describe('A JSON object containing the fields to update.'),
|
.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
|
// @ts-expect-error - AI SDK strict mode type inference bug
|
||||||
execute: async ({ id, data }: { id: string | number; data: Record<string, any> }) => {
|
execute: async ({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
id: string | number;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}) => {
|
||||||
return await payload.update({
|
return await payload.update({
|
||||||
collection: collectionSlug as any,
|
collection: collectionSlug as any,
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
req, // Enforce access control
|
req, // Enforce access control
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// 5. Delete Tool
|
// 5. Delete Tool
|
||||||
tools[`delete_${slugKey}`] = tool({
|
tools[`delete_${slugKey}`] = tool({
|
||||||
description: `Delete a document from the ${collectionSlug} collection by ID.`,
|
description: `Delete a document from the ${collectionSlug} collection by ID.`,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
id: z.union([z.string(), z.number()]).describe('The ID of the document to delete.'),
|
id: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.describe("The ID of the document to delete."),
|
||||||
}),
|
}),
|
||||||
// @ts-ignore - AI SDK strict mode type inference bug
|
// @ts-expect-error - AI SDK strict mode type inference bug
|
||||||
execute: async ({ id }: { id: string | number }) => {
|
execute: async ({ id }: { id: string | number }) => {
|
||||||
return await payload.delete({
|
return await payload.delete({
|
||||||
collection: collectionSlug as any,
|
collection: collectionSlug as any,
|
||||||
id,
|
id,
|
||||||
req, // Enforce access control
|
req, // Enforce access control
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return tools
|
return tools;
|
||||||
}
|
};
|
||||||
|
|||||||
15
packages/serpbear-mcp/Dockerfile
Normal file
15
packages/serpbear-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
@@ -13,13 +13,12 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^4.19.2"
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"typescript": "^5.5.3",
|
"tsx": "^4.19.2",
|
||||||
"tsx": "^4.19.2"
|
"typescript": "^5.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
@@ -18,10 +19,28 @@ if (!SERPBEAR_API_KEY) {
|
|||||||
|
|
||||||
const serpbearClient = axios.create({
|
const serpbearClient = axios.create({
|
||||||
baseURL: `${SERPBEAR_BASE_URL}/api`,
|
baseURL: `${SERPBEAR_BASE_URL}/api`,
|
||||||
headers: { apiKey: SERPBEAR_API_KEY },
|
headers: { 'x-api-key': SERPBEAR_API_KEY || '' },
|
||||||
httpsAgent: new https.Agent({
|
httpsAgent: new https.Agent({
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
}),
|
}),
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logging middleware for SerpBear client
|
||||||
|
serpbearClient.interceptors.request.use((config) => {
|
||||||
|
console.error(`SerpBear Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||||
|
return config;
|
||||||
|
}, (error) => {
|
||||||
|
console.error(`SerpBear Request Error: ${error.message}`);
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
serpbearClient.interceptors.response.use((response) => {
|
||||||
|
return response;
|
||||||
|
}, (error) => {
|
||||||
|
const msg = error.response?.data?.error || error.response?.data?.message || error.message;
|
||||||
|
console.error(`SerpBear API Error (${error.config?.url}): ${msg}`);
|
||||||
|
return Promise.reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -125,21 +144,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
if (request.params.name === "serpbear_list_domains") {
|
|
||||||
try {
|
try {
|
||||||
|
if (request.params.name === "serpbear_list_domains") {
|
||||||
const res = await serpbearClient.get('/domains');
|
const res = await serpbearClient.get('/domains');
|
||||||
const domains = (res.data.domains || []).map((d: any) => ({
|
const domains = (res.data.domains || []).map((d: any) => ({
|
||||||
id: d.id, domain: d.domain, keywords: d.keywordCount
|
id: d.id, domain: d.domain, keywords: d.keywordCount
|
||||||
}));
|
}));
|
||||||
return { content: [{ type: "text", text: JSON.stringify(domains, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(domains, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "serpbear_get_keywords") {
|
if (request.params.name === "serpbear_get_keywords") {
|
||||||
const { domain_id } = request.params.arguments as any;
|
const { domain_id } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await serpbearClient.get('/keywords', { params: { domain: domain_id } });
|
const res = await serpbearClient.get('/keywords', { params: { domain: domain_id } });
|
||||||
const keywords = (res.data.keywords || []).map((k: any) => ({
|
const keywords = (res.data.keywords || []).map((k: any) => ({
|
||||||
id: k.id,
|
id: k.id,
|
||||||
@@ -151,55 +166,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
change: k.position_change ?? null,
|
change: k.position_change ?? null,
|
||||||
}));
|
}));
|
||||||
return { content: [{ type: "text", text: JSON.stringify(keywords, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(keywords, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "serpbear_add_keywords") {
|
if (request.params.name === "serpbear_add_keywords") {
|
||||||
const { domain_id, keywords, country = 'de', device = 'desktop' } = request.params.arguments as any;
|
const { domain_id, keywords, country = 'de', device = 'desktop' } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await serpbearClient.post('/keywords', {
|
const res = await serpbearClient.post('/keywords', {
|
||||||
domain: domain_id,
|
domain: domain_id,
|
||||||
keywords: keywords.map((kw: string) => ({ keyword: kw, country, device })),
|
keywords: keywords.map((kw: string) => ({ keyword: kw, country, device })),
|
||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: `Added ${keywords.length} keywords. Result: ${JSON.stringify(res.data)}` }] };
|
return { content: [{ type: "text", text: `Added ${keywords.length} keywords. Result: ${JSON.stringify(res.data)}` }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "serpbear_delete_keywords") {
|
if (request.params.name === "serpbear_delete_keywords") {
|
||||||
const { keyword_ids } = request.params.arguments as any;
|
const { keyword_ids } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
await serpbearClient.delete('/keywords', { data: { ids: keyword_ids } });
|
await serpbearClient.delete('/keywords', { data: { ids: keyword_ids } });
|
||||||
return { content: [{ type: "text", text: `Deleted ${keyword_ids.length} keywords.` }] };
|
return { content: [{ type: "text", text: `Deleted ${keyword_ids.length} keywords.` }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "serpbear_refresh_keywords") {
|
if (request.params.name === "serpbear_refresh_keywords") {
|
||||||
const { keyword_ids } = request.params.arguments as any;
|
const { keyword_ids } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
await serpbearClient.post('/keywords/refresh', { ids: keyword_ids });
|
await serpbearClient.post('/keywords/refresh', { ids: keyword_ids });
|
||||||
return { content: [{ type: "text", text: `Triggered refresh for ${keyword_ids.length} keywords.` }] };
|
return { content: [{ type: "text", text: `Triggered refresh for ${keyword_ids.length} keywords.` }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "serpbear_get_keyword_history") {
|
if (request.params.name === "serpbear_get_keyword_history") {
|
||||||
const { keyword_id } = request.params.arguments as any;
|
const { keyword_id } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await serpbearClient.get(`/keywords/${keyword_id}/history`);
|
const res = await serpbearClient.get(`/keywords/${keyword_id}/history`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e.response?.data?.error || e.response?.data?.message || e.message;
|
||||||
|
return { isError: true, content: [{ type: "text", text: `SerpBear Service Error: ${msg}` }] };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Express / SSE Server ---
|
// --- Express / SSE Server ---
|
||||||
@@ -213,25 +213,65 @@ async function run() {
|
|||||||
console.error('SerpBear MCP server is running on stdio');
|
console.error('SerpBear MCP server is running on stdio');
|
||||||
} else {
|
} else {
|
||||||
const app = express();
|
const app = express();
|
||||||
let transport: SSEServerTransport | null = null;
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
app.get('/sse', async (req, res) => {
|
// Manual CORS middleware
|
||||||
console.error('New SSE connection established');
|
app.use((req, res, next) => {
|
||||||
transport = new SSEServerTransport('/message', res);
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
await server.connect(transport);
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/message', async (req, res) => {
|
app.use((req, _res, next) => {
|
||||||
|
console.error(`${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/message', res as any);
|
||||||
|
const sessionId = transport.sessionId;
|
||||||
|
console.error(`New SSE connection: ${sessionId}`);
|
||||||
|
transports.set(sessionId, transport);
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
res.write(": heartbeat\n\n");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
console.error(`SSE connection closed: ${sessionId}`);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
transports.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
req.on('close', resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/message', express.json(), async (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
|
||||||
|
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||||
|
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
res.status(400).send('No active SSE connection');
|
console.error(`No transport found for session: ${sessionId}`);
|
||||||
|
res.status(400).send('No active SSE connection for this session');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transport.handlePostMessage(req, res);
|
await transport.handlePostMessage(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.SERPBEAR_MCP_PORT || 3004;
|
const PORT = Number(process.env.SERPBEAR_MCP_PORT) || 3004;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.error(`SerpBear MCP server running on http://localhost:${PORT}/sse`);
|
console.error(`SerpBear MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
config({ path: resolve(__dirname, '../../../.env') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
import('./index.js').catch(err => {
|
import('./index.js').catch(err => {
|
||||||
console.error('Failed to start SerpBear MCP Server:', err);
|
console.error('Failed to start SerpBear MCP Server:', err);
|
||||||
|
|||||||
15
packages/umami-mcp/Dockerfile
Normal file
15
packages/umami-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
@@ -13,13 +13,12 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^4.19.2"
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"typescript": "^5.5.3",
|
"tsx": "^4.19.2",
|
||||||
"tsx": "^4.19.2"
|
"typescript": "^5.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
@@ -26,17 +27,28 @@ if (!UMAMI_USERNAME && !UMAMI_API_KEY) {
|
|||||||
let cachedToken: string | null = null;
|
let cachedToken: string | null = null;
|
||||||
|
|
||||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
if (UMAMI_API_KEY) {
|
if (UMAMI_API_KEY) {
|
||||||
return { 'x-umami-api-key': UMAMI_API_KEY };
|
return { 'x-umami-api-key': UMAMI_API_KEY };
|
||||||
}
|
}
|
||||||
if (!cachedToken) {
|
if (!cachedToken) {
|
||||||
|
console.error(`Attempting Umami login for ${UMAMI_USERNAME} at ${UMAMI_BASE_URL}`);
|
||||||
const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, {
|
const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, {
|
||||||
username: UMAMI_USERNAME,
|
username: UMAMI_USERNAME,
|
||||||
password: UMAMI_PASSWORD,
|
password: UMAMI_PASSWORD,
|
||||||
}, { httpsAgent });
|
}, {
|
||||||
|
httpsAgent,
|
||||||
|
timeout: 5000 // Add timeout to prevent hangs
|
||||||
|
});
|
||||||
cachedToken = res.data.token;
|
cachedToken = res.data.token;
|
||||||
|
console.error("Umami login successful");
|
||||||
}
|
}
|
||||||
return { Authorization: `Bearer ${cachedToken}` };
|
return { Authorization: `Bearer ${cachedToken}` };
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e.response?.data?.message || e.message;
|
||||||
|
console.error(`Umami Auth Error: ${msg}`);
|
||||||
|
throw new Error(`Authentication failed: ${msg}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -152,92 +164,72 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
try {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers, httpsAgent });
|
const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers, httpsAgent, timeout: 10000 });
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (request.params.name === "umami_list_websites") {
|
if (request.params.name === "umami_list_websites") {
|
||||||
try {
|
|
||||||
const res = await api.get('/websites');
|
const res = await api.get('/websites');
|
||||||
const sites = (res.data.data || res.data || []).map((s: any) => ({
|
const sites = (res.data.data || res.data || []).map((s: any) => ({
|
||||||
id: s.id, name: s.name, domain: s.domain
|
id: s.id, name: s.name, domain: s.domain
|
||||||
}));
|
}));
|
||||||
return { content: [{ type: "text", text: JSON.stringify(sites, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(sites, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "umami_get_website_stats") {
|
if (request.params.name === "umami_get_website_stats") {
|
||||||
const { website_id, start_at, end_at = now } = request.params.arguments as any;
|
const { website_id, start_at, end_at = now } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await api.get(`/websites/${website_id}/stats`, { params: { startAt: start_at, endAt: end_at } });
|
const res = await api.get(`/websites/${website_id}/stats`, { params: { startAt: start_at, endAt: end_at } });
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "umami_get_pageviews") {
|
if (request.params.name === "umami_get_pageviews") {
|
||||||
const { website_id, start_at, end_at = now, unit = 'day', timezone = 'Europe/Berlin' } = request.params.arguments as any;
|
const { website_id, start_at, end_at = now, unit = 'day', timezone = 'Europe/Berlin' } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await api.get(`/websites/${website_id}/pageviews`, {
|
const res = await api.get(`/websites/${website_id}/pageviews`, {
|
||||||
params: { startAt: start_at, endAt: end_at, unit, timezone }
|
params: { startAt: start_at, endAt: end_at, unit, timezone }
|
||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "umami_get_top_pages") {
|
if (request.params.name === "umami_get_top_pages") {
|
||||||
const { website_id, start_at, end_at = now, limit = 20 } = request.params.arguments as any;
|
const { website_id, start_at, end_at = now, limit = 20 } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await api.get(`/websites/${website_id}/metrics`, {
|
const res = await api.get(`/websites/${website_id}/metrics`, {
|
||||||
params: { startAt: start_at, endAt: end_at, type: 'url', limit }
|
params: { startAt: start_at, endAt: end_at, type: 'url', limit }
|
||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "umami_get_top_referrers") {
|
if (request.params.name === "umami_get_top_referrers") {
|
||||||
const { website_id, start_at, end_at = now, limit = 10 } = request.params.arguments as any;
|
const { website_id, start_at, end_at = now, limit = 10 } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await api.get(`/websites/${website_id}/metrics`, {
|
const res = await api.get(`/websites/${website_id}/metrics`, {
|
||||||
params: { startAt: start_at, endAt: end_at, type: 'referrer', limit }
|
params: { startAt: start_at, endAt: end_at, type: 'referrer', limit }
|
||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "umami_get_country_stats") {
|
if (request.params.name === "umami_get_country_stats") {
|
||||||
const { website_id, start_at, end_at = now } = request.params.arguments as any;
|
const { website_id, start_at, end_at = now } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await api.get(`/websites/${website_id}/metrics`, {
|
const res = await api.get(`/websites/${website_id}/metrics`, {
|
||||||
params: { startAt: start_at, endAt: end_at, type: 'country' }
|
params: { startAt: start_at, endAt: end_at, type: 'country' }
|
||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.name === "umami_get_active_visitors") {
|
if (request.params.name === "umami_get_active_visitors") {
|
||||||
const { website_id } = request.params.arguments as any;
|
const { website_id } = request.params.arguments as any;
|
||||||
try {
|
|
||||||
const res = await api.get(`/websites/${website_id}/active`);
|
const res = await api.get(`/websites/${website_id}/active`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
} catch (e: any) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
|
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text", text: `MCP Service Error: ${e.message}` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Express / SSE Server ---
|
// --- Express / SSE Server ---
|
||||||
@@ -251,25 +243,65 @@ async function run() {
|
|||||||
console.error('Umami MCP server is running on stdio');
|
console.error('Umami MCP server is running on stdio');
|
||||||
} else {
|
} else {
|
||||||
const app = express();
|
const app = express();
|
||||||
let transport: SSEServerTransport | null = null;
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
app.get('/sse', async (req, res) => {
|
// Manual CORS middleware
|
||||||
console.error('New SSE connection established');
|
app.use((req, res, next) => {
|
||||||
transport = new SSEServerTransport('/message', res);
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
await server.connect(transport);
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/message', async (req, res) => {
|
app.use((req, _res, next) => {
|
||||||
|
console.error(`${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/message', res as any);
|
||||||
|
const sessionId = transport.sessionId;
|
||||||
|
console.error(`New SSE connection: ${sessionId}`);
|
||||||
|
transports.set(sessionId, transport);
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
res.write(": heartbeat\n\n");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
console.error(`SSE connection closed: ${sessionId}`);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
transports.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
req.on('close', resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/message', express.json(), async (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
|
||||||
|
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||||
|
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
res.status(400).send('No active SSE connection');
|
console.error(`No transport found for session: ${sessionId}`);
|
||||||
|
res.status(400).send('No active SSE connection for this session');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transport.handlePostMessage(req, res);
|
await transport.handlePostMessage(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.UMAMI_MCP_PORT || 3003;
|
const PORT = Number(process.env.UMAMI_MCP_PORT) || 3003;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.error(`Umami MCP server running on http://localhost:${PORT}/sse`);
|
console.error(`Umami MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
config({ path: resolve(__dirname, '../../../.env') });
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
import('./index.js').catch(err => {
|
import('./index.js').catch(err => {
|
||||||
console.error('Failed to start Umami MCP Server:', err);
|
console.error('Failed to start Umami MCP Server:', err);
|
||||||
|
|||||||
15
packages/vikunja-mcp/Dockerfile
Normal file
15
packages/vikunja-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "dist/start.js"]
|
||||||
30
packages/vikunja-mcp/package.json
Normal file
30
packages/vikunja-mcp/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/vikunja-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Vikunja MCP server for Mintel infrastructure",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/start.js",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test:unit": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"marked": "^18.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/form-data": "^2.5.2",
|
||||||
|
"@types/marked": "^6.0.0",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vitest": "^2.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
205
packages/vikunja-mcp/src/index.test.ts
Normal file
205
packages/vikunja-mcp/src/index.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('axios', () => {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: vi.fn().mockImplementation(() => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
put: mockPut,
|
||||||
|
delete: mockDelete,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set env vars
|
||||||
|
process.env.VIKUNJA_API_TOKEN = 'test-token';
|
||||||
|
process.env.VIKUNJA_BASE_URL = 'https://tasks.test.me';
|
||||||
|
|
||||||
|
// Mock express to avoid starting a real server
|
||||||
|
vi.mock('express', () => {
|
||||||
|
const mockApp = {
|
||||||
|
use: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
listen: vi.fn().mockImplementation((port, host, cb) => cb && cb()),
|
||||||
|
};
|
||||||
|
const mockExpress = () => mockApp;
|
||||||
|
(mockExpress as any).json = () => vi.fn();
|
||||||
|
return { default: mockExpress };
|
||||||
|
});
|
||||||
|
|
||||||
|
let server: any;
|
||||||
|
|
||||||
|
describe('Vikunja MCP Server - New Tools (RED)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
if (!server) {
|
||||||
|
const mod = await import('./index.js');
|
||||||
|
server = mod.server;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support vikunja_create_project', async () => {
|
||||||
|
mockPut.mockResolvedValueOnce({
|
||||||
|
data: { id: 10, title: 'New Project', description: 'Testing' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (server as any)._requestHandlers.get('tools/call');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
const response = await handler({
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'vikunja_create_project',
|
||||||
|
arguments: {
|
||||||
|
title: 'New Project',
|
||||||
|
description: 'Testing',
|
||||||
|
hex_color: 'ff0000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/projects', {
|
||||||
|
title: 'New Project',
|
||||||
|
description: 'Testing',
|
||||||
|
hex_color: 'ff0000',
|
||||||
|
});
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
expect(JSON.parse(response.content[0].text)).toEqual({
|
||||||
|
id: 10,
|
||||||
|
title: 'New Project',
|
||||||
|
description: 'Testing',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support vikunja_get_task', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({
|
||||||
|
data: { id: 42, title: 'My Task', project_id: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (server as any)._requestHandlers.get('tools/call');
|
||||||
|
const response = await handler({
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'vikunja_get_task',
|
||||||
|
arguments: {
|
||||||
|
task_id: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/tasks/42');
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
expect(JSON.parse(response.content[0].text)).toEqual({
|
||||||
|
id: 42,
|
||||||
|
title: 'My Task',
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support vikunja_delete_task', async () => {
|
||||||
|
mockDelete.mockResolvedValueOnce({
|
||||||
|
data: { message: 'success' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (server as any)._requestHandlers.get('tools/call');
|
||||||
|
const response = await handler({
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'vikunja_delete_task',
|
||||||
|
arguments: {
|
||||||
|
task_id: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/tasks/42');
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
expect(JSON.parse(response.content[0].text)).toEqual({
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support vikunja_create_task_comment', async () => {
|
||||||
|
mockPut.mockResolvedValueOnce({
|
||||||
|
data: { id: 1, comment: 'New Comment', task_id: 42 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (server as any)._requestHandlers.get('tools/call');
|
||||||
|
const response = await handler({
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'vikunja_create_task_comment',
|
||||||
|
arguments: {
|
||||||
|
task_id: 42,
|
||||||
|
comment: 'New Comment',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/tasks/42/comments', {
|
||||||
|
comment: 'New Comment',
|
||||||
|
});
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
expect(JSON.parse(response.content[0].text)).toEqual({
|
||||||
|
id: 1,
|
||||||
|
comment: 'New Comment',
|
||||||
|
task_id: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support vikunja_get_task_comments', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({
|
||||||
|
data: [{ id: 1, comment: 'New Comment', task_id: 42 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (server as any)._requestHandlers.get('tools/call');
|
||||||
|
const response = await handler({
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'vikunja_get_task_comments',
|
||||||
|
arguments: {
|
||||||
|
task_id: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/tasks/42/comments');
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
expect(JSON.parse(response.content[0].text)).toEqual([
|
||||||
|
{ id: 1, comment: 'New Comment', task_id: 42 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support vikunja_create_task and convert markdown description to HTML', async () => {
|
||||||
|
mockPut.mockResolvedValueOnce({
|
||||||
|
data: { id: 11, title: 'Markdown Task', description: '<p><strong>Bold</strong> and <em>italic</em></p>\n' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (server as any)._requestHandlers.get('tools/call');
|
||||||
|
const response = await handler({
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'vikunja_create_task',
|
||||||
|
arguments: {
|
||||||
|
project_id: 1,
|
||||||
|
title: 'Markdown Task',
|
||||||
|
description: '**Bold** and *italic*'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/projects/1/tasks', expect.objectContaining({
|
||||||
|
title: 'Markdown Task',
|
||||||
|
description: '<p><strong>Bold</strong> and <em>italic</em></p>\n'
|
||||||
|
}));
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
421
packages/vikunja-mcp/src/index.ts
Normal file
421
packages/vikunja-mcp/src/index.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
Tool,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import axios from "axios";
|
||||||
|
import https from "https";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
const VIKUNJA_BASE_URL = process.env.VIKUNJA_BASE_URL || "https://tasks.infra.mintel.me";
|
||||||
|
const VIKUNJA_API_TOKEN = process.env.VIKUNJA_API_TOKEN;
|
||||||
|
|
||||||
|
const httpsAgent = new https.Agent({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!VIKUNJA_API_TOKEN) {
|
||||||
|
console.error("Warning: VIKUNJA_API_TOKEN is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApi() {
|
||||||
|
if (!VIKUNJA_API_TOKEN) {
|
||||||
|
throw new Error("VIKUNJA_API_TOKEN is not configured.");
|
||||||
|
}
|
||||||
|
return axios.create({
|
||||||
|
baseURL: `${VIKUNJA_BASE_URL}/api/v1`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${VIKUNJA_API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
httpsAgent,
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tool Definitions ---
|
||||||
|
const GET_PROJECTS_TOOL: Tool = {
|
||||||
|
name: "vikunja_get_projects",
|
||||||
|
description: "List all projects in Vikunja",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const GET_TASKS_TOOL: Tool = {
|
||||||
|
name: "vikunja_get_tasks",
|
||||||
|
description: "Get all tasks for a specific project",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
project_id: { type: "number", description: "The ID of the project" },
|
||||||
|
},
|
||||||
|
required: ["project_id"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_TASK_TOOL: Tool = {
|
||||||
|
name: "vikunja_create_task",
|
||||||
|
description: "Create a new task in a project",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
project_id: { type: "number", description: "The ID of the project to add the task to" },
|
||||||
|
title: { type: "string", description: "The title of the task" },
|
||||||
|
description: { type: "string", description: "Optional description of the task" },
|
||||||
|
parent_task_id: { type: "number", description: "Optional ID of a parent task to create a subtask" },
|
||||||
|
due_date: { type: "string", description: "ISO 8601 Date string for due date" },
|
||||||
|
start_date: { type: "string", description: "ISO 8601 Date string for start date" },
|
||||||
|
end_date: { type: "string", description: "ISO 8601 Date string for end date" },
|
||||||
|
priority: { type: "number", description: "Priority level (e.g. 1-5)" },
|
||||||
|
percent_done: { type: "number", description: "Progress percentage" },
|
||||||
|
hex_color: { type: "string", description: "Hex color code" },
|
||||||
|
is_favorite: { type: "boolean", description: "Mark as favorite" }
|
||||||
|
},
|
||||||
|
required: ["project_id", "title"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const UPDATE_TASK_TOOL: Tool = {
|
||||||
|
name: "vikunja_update_task",
|
||||||
|
description: "Update a task (e.g., mark as done)",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
task_id: { type: "number", description: "The ID of the task to update" },
|
||||||
|
done: { type: "boolean", description: "Set to true to mark the task as done" },
|
||||||
|
title: { type: "string", description: "Update the title" },
|
||||||
|
description: { type: "string", description: "Update the description" },
|
||||||
|
due_date: { type: "string", description: "ISO 8601 Date string for due date" },
|
||||||
|
start_date: { type: "string", description: "ISO 8601 Date string for start date" },
|
||||||
|
end_date: { type: "string", description: "ISO 8601 Date string for end date" },
|
||||||
|
priority: { type: "number", description: "Priority level (e.g. 1-5)" },
|
||||||
|
percent_done: { type: "number", description: "Progress percentage (0-1 or 0-100 depending on API version, try decimals like 0.5 first)" },
|
||||||
|
hex_color: { type: "string", description: "Hex color code" },
|
||||||
|
is_favorite: { type: "boolean", description: "Mark as favorite" }
|
||||||
|
},
|
||||||
|
required: ["task_id"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_PROJECT_TOOL: Tool = {
|
||||||
|
name: "vikunja_create_project",
|
||||||
|
description: "Create a new project (list) in Vikunja",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string", description: "The title of the project" },
|
||||||
|
description: { type: "string", description: "Optional description of the project" },
|
||||||
|
hex_color: { type: "string", description: "Optional hex color without the '#' prefix" }
|
||||||
|
},
|
||||||
|
required: ["title"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GET_TASK_TOOL: Tool = {
|
||||||
|
name: "vikunja_get_task",
|
||||||
|
description: "Get details for a specific task by ID",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
task_id: { type: "number", description: "The ID of the task" }
|
||||||
|
},
|
||||||
|
required: ["task_id"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DELETE_TASK_TOOL: Tool = {
|
||||||
|
name: "vikunja_delete_task",
|
||||||
|
description: "Delete a task by ID",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
task_id: { type: "number", description: "The ID of the task to delete" }
|
||||||
|
},
|
||||||
|
required: ["task_id"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_TASK_COMMENT_TOOL: Tool = {
|
||||||
|
name: "vikunja_create_task_comment",
|
||||||
|
description: "Add a comment to a task",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
task_id: { type: "number", description: "The ID of the task to comment on" },
|
||||||
|
comment: { type: "string", description: "The comment text" }
|
||||||
|
},
|
||||||
|
required: ["task_id", "comment"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GET_TASK_COMMENTS_TOOL: Tool = {
|
||||||
|
name: "vikunja_get_task_comments",
|
||||||
|
description: "Get all comments for a specific task",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
task_id: { type: "number", description: "The ID of the task" }
|
||||||
|
},
|
||||||
|
required: ["task_id"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GET_TASK_ATTACHMENT_TOOL: Tool = {
|
||||||
|
name: "vikunja_get_task_attachment",
|
||||||
|
description: "Download a task attachment to a local file",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
task_id: { type: "number", description: "The ID of the task" },
|
||||||
|
attachment_id: { type: "number", description: "The ID of the attachment" },
|
||||||
|
save_path: { type: "string", description: "Absolute path to save the file to" }
|
||||||
|
},
|
||||||
|
required: ["task_id", "attachment_id", "save_path"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_TASK_ATTACHMENT_TOOL: Tool = {
|
||||||
|
name: "vikunja_upload_task_attachment",
|
||||||
|
description: "Upload an attachment file to a specific task",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
task_id: { type: "number", description: "The ID of the task" },
|
||||||
|
file_path: { type: "string", description: "Absolute path to the local file to upload" }
|
||||||
|
},
|
||||||
|
required: ["task_id", "file_path"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Server Setup ---
|
||||||
|
export const server = new Server(
|
||||||
|
{ name: "vikunja-mcp", version: "1.0.0" },
|
||||||
|
{ capabilities: { tools: {} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
|
tools: [
|
||||||
|
GET_PROJECTS_TOOL,
|
||||||
|
GET_TASKS_TOOL,
|
||||||
|
CREATE_TASK_TOOL,
|
||||||
|
UPDATE_TASK_TOOL,
|
||||||
|
CREATE_PROJECT_TOOL,
|
||||||
|
GET_TASK_TOOL,
|
||||||
|
DELETE_TASK_TOOL,
|
||||||
|
CREATE_TASK_COMMENT_TOOL,
|
||||||
|
GET_TASK_COMMENTS_TOOL,
|
||||||
|
GET_TASK_ATTACHMENT_TOOL,
|
||||||
|
CREATE_TASK_ATTACHMENT_TOOL
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
try {
|
||||||
|
const api = getApi();
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_get_projects") {
|
||||||
|
const res = await api.get('/projects');
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_get_tasks") {
|
||||||
|
const { project_id } = request.params.arguments as any;
|
||||||
|
const res = await api.get(`/projects/${project_id}/tasks`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_create_task") {
|
||||||
|
const {
|
||||||
|
project_id, title, description, parent_task_id,
|
||||||
|
due_date, start_date, end_date, priority, percent_done, hex_color, is_favorite
|
||||||
|
} = request.params.arguments as any;
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
title,
|
||||||
|
description: description ? await marked.parse(description) : ""
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parent_task_id !== undefined) payload.parent_task_id = parent_task_id;
|
||||||
|
if (due_date !== undefined) payload.due_date = due_date;
|
||||||
|
if (start_date !== undefined) payload.start_date = start_date;
|
||||||
|
if (end_date !== undefined) payload.end_date = end_date;
|
||||||
|
if (priority !== undefined) payload.priority = priority;
|
||||||
|
if (percent_done !== undefined) payload.percent_done = percent_done;
|
||||||
|
if (hex_color !== undefined) payload.hex_color = hex_color;
|
||||||
|
if (is_favorite !== undefined) payload.is_favorite = is_favorite;
|
||||||
|
|
||||||
|
const res = await api.put(`/projects/${project_id}/tasks`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_update_task") {
|
||||||
|
const {
|
||||||
|
task_id, done, title, description,
|
||||||
|
due_date, start_date, end_date,
|
||||||
|
priority, percent_done, hex_color, is_favorite
|
||||||
|
} = request.params.arguments as any;
|
||||||
|
|
||||||
|
const payload: any = {};
|
||||||
|
if (done !== undefined) payload.done = done;
|
||||||
|
if (title !== undefined) payload.title = title;
|
||||||
|
if (description !== undefined) payload.description = await marked.parse(description);
|
||||||
|
if (due_date !== undefined) payload.due_date = due_date;
|
||||||
|
if (start_date !== undefined) payload.start_date = start_date;
|
||||||
|
if (end_date !== undefined) payload.end_date = end_date;
|
||||||
|
if (priority !== undefined) payload.priority = priority;
|
||||||
|
if (percent_done !== undefined) payload.percent_done = percent_done;
|
||||||
|
if (hex_color !== undefined) payload.hex_color = hex_color;
|
||||||
|
if (is_favorite !== undefined) payload.is_favorite = is_favorite;
|
||||||
|
|
||||||
|
const res = await api.post(`/tasks/${task_id}`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_create_project") {
|
||||||
|
const { title, description, hex_color } = request.params.arguments as any;
|
||||||
|
// Vikunja standard: creating a new root resource uses PUT /projects in this version
|
||||||
|
const res = await api.put('/projects', {
|
||||||
|
title,
|
||||||
|
description: description || "",
|
||||||
|
hex_color: hex_color || ""
|
||||||
|
});
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_get_task") {
|
||||||
|
const { task_id } = request.params.arguments as any;
|
||||||
|
const res = await api.get(`/tasks/${task_id}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_delete_task") {
|
||||||
|
const { task_id } = request.params.arguments as any;
|
||||||
|
const res = await api.delete(`/tasks/${task_id}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_create_task_comment") {
|
||||||
|
const { task_id, comment } = request.params.arguments as any;
|
||||||
|
// Vikunja standard: task sub-resources (like comments) are typically appended using PUT /tasks/{id}/comments
|
||||||
|
const res = await api.put(`/tasks/${task_id}/comments`, { comment });
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_get_task_comments") {
|
||||||
|
const { task_id } = request.params.arguments as any;
|
||||||
|
const res = await api.get(`/tasks/${task_id}/comments`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_get_task_attachment") {
|
||||||
|
const { task_id, attachment_id, save_path } = request.params.arguments as any;
|
||||||
|
const res = await api.get(`/tasks/${task_id}/attachments/${attachment_id}`, {
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
});
|
||||||
|
fs.writeFileSync(save_path, Buffer.from(res.data));
|
||||||
|
return { content: [{ type: "text", text: `Successfully saved attachment to ${save_path}` }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "vikunja_upload_task_attachment") {
|
||||||
|
const { task_id, file_path } = request.params.arguments as any;
|
||||||
|
if (!fs.existsSync(file_path)) {
|
||||||
|
throw new Error(`File not found: ${file_path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = fs.readFileSync(file_path);
|
||||||
|
const blob = new Blob([buffer]);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('files', blob, path.basename(file_path));
|
||||||
|
|
||||||
|
const res = await api.put(`/tasks/${task_id}/attachments`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { content: [{ type: "text", text: `Successfully uploaded attachment to task ${task_id}.\nResponse: ${JSON.stringify(res.data)}` }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e.response?.data?.message || e.message;
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text", text: `MCP Service Error: ${msg}` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Express / SSE Server Setup ---
|
||||||
|
async function run() {
|
||||||
|
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('Vikunja MCP server is running on stdio');
|
||||||
|
} else {
|
||||||
|
const app = express();
|
||||||
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
console.error(`${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/message', res as any);
|
||||||
|
const sessionId = transport.sessionId;
|
||||||
|
console.error(`New SSE connection: ${sessionId}`);
|
||||||
|
transports.set(sessionId, transport);
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
res.write(": heartbeat\n\n");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
console.error(`SSE connection closed: ${sessionId}`);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
transports.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/message', express.json(), async (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
|
||||||
|
if (!transport) {
|
||||||
|
res.status(400).send('No active SSE connection for this session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = Number(process.env.VIKUNJA_MCP_PORT) || 3007;
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.error(`Vikunja MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
13
packages/vikunja-mcp/src/start.ts
Normal file
13
packages/vikunja-mcp/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||||
|
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
|
import('./index.js').catch(err => {
|
||||||
|
console.error('Failed to start Vikunja MCP Server:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
16
packages/vikunja-mcp/tsconfig.json
Normal file
16
packages/vikunja-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
736
pnpm-lock.yaml
generated
736
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user