diff --git a/package-lock.json b/package-lock.json index 387777c..0c44f5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@modelcontextprotocol/sdk": "^1.9.0", "@scarf/scarf": "^1.4.0", "axios": "1.12.0", + "axios-cookiejar-support": "^6.0.4", + "tough-cookie": "^5.1.2", "zod-to-json-schema": "^3.24.5" }, "bin": { @@ -617,6 +619,7 @@ "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/types": "8.30.1", @@ -806,6 +809,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -823,6 +827,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -874,12 +887,32 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-cookiejar-support": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-6.0.4.tgz", + "integrity": "sha512-4Bzj+l63eGwnWDBFdJHeGS6Ij3ytpyqvo//ocsb5kCLN/rKthzk27Afh2iSkZtuudOBkHUWWIcyCb4GKhXqovQ==", + "license": "MIT", + "dependencies": { + "http-cookie-agent": "^7.0.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "axios": ">=0.20.0", + "tough-cookie": ">=4.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1242,6 +1275,7 @@ "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -1303,6 +1337,7 @@ "integrity": "sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -1480,6 +1515,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -1896,6 +1932,30 @@ "node": ">= 0.4" } }, + "node_modules/http-cookie-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.2.tgz", + "integrity": "sha512-aHaES6SOFtnSlmWu0yEaaQvu+QexUG2gscSAvMhJ7auzW8r/jYOgGrzuAm9G9nHbksuhz7Lw4zOwDHmfQaxZvw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "tough-cookie": "^4.0.0 || ^5.0.0", + "undici": "^7.0.0" + }, + "peerDependenciesMeta": { + "undici": { + "optional": true + } + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2443,6 +2503,7 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2843,6 +2904,24 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2865,6 +2944,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -2918,6 +3010,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3010,6 +3103,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b826731..7745bab 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "@modelcontextprotocol/sdk": "^1.9.0", "@scarf/scarf": "^1.4.0", "axios": "1.12.0", + "axios-cookiejar-support": "^6.0.4", + "tough-cookie": "^5.1.2", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { diff --git a/src/common/auth.ts b/src/common/auth.ts new file mode 100644 index 0000000..e053b73 --- /dev/null +++ b/src/common/auth.ts @@ -0,0 +1,268 @@ +import axios, { AxiosInstance } from "axios"; +import { wrapper } from "axios-cookiejar-support"; +import { CookieJar } from "tough-cookie"; +import { debugLog } from "./debug.js"; + +/** + * Result of an authentication attempt + * @property success - Whether authentication was successful + * @property error - Type of error if authentication failed + * @property message - Detailed error message if authentication failed + */ +export interface AuthResult { + success: boolean; + error?: 'network' | 'csrf' | 'credentials' | 'cookies' | 'unknown'; + message?: string; +} + +let axiosInstance: AxiosInstance | null = null; +let isAuthenticated = false; +let authenticationTime: number | null = null; +const SESSION_TIMEOUT_MS = 3600000; // 1 hour + +debugLog(`[AUTH] Module loaded - PID: ${process.pid}`).catch(() => {}); + +/** + * Gets or creates an Axios instance with cookie jar support for session authentication + * @returns Configured Axios instance with cookie persistence + */ +export function getAxiosInstance(): AxiosInstance { + if (!axiosInstance) { + debugLog("[AUTH] Creating new axios instance with cookie jar").catch(() => {}); + const jar = new CookieJar(); + axiosInstance = wrapper(axios.create({ + jar, + withCredentials: true, + timeout: 30000 // 30 second timeout + })); + } else { + debugLog("[AUTH] Reusing existing axios instance").catch(() => {}); + } + return axiosInstance; +} + +/** + * Authenticates with Plane using email and password, establishing a session with cookies + * + * This function performs a two-step authentication flow: + * 1. Requests a CSRF token from the server + * 2. Submits credentials with CSRF token to establish session + * + * Session cookies are automatically stored in the axios instance's cookie jar + * and will be included in subsequent requests to /api/ endpoints. + * + * @param email - User's Plane account email address + * @param password - User's Plane account password + * @param hostUrl - Plane server URL (e.g., "https://api.plane.so/" or self-hosted URL) + * @returns Authentication result with success status and error details if failed + * @throws Never throws - all errors are captured in AuthResult + */ +export async function authenticateWithPassword( + email: string, + password: string, + hostUrl: string +): Promise { + try { + const instance = getAxiosInstance(); + const host = hostUrl.endsWith("/") ? hostUrl : `${hostUrl}/`; + + await debugLog("[AUTH] Starting authentication flow..."); + await debugLog(`[AUTH] Host URL: ${host}`); + + // Step 1: Get CSRF token (stored in cookie jar automatically) + // Use explicit path to ensure we capture path-scoped cookies + const csrfUrl = `${host}auth/get-csrf-token/`; + const csrfResponse = await instance.get(csrfUrl); + await debugLog(`[AUTH] CSRF response status: ${csrfResponse.status}`); + + // Only log headers if verbose debug is enabled to avoid leaking config details + if (process.env.PLANE_MCP_DEBUG === 'verbose') { + await debugLog(`[AUTH] CSRF response headers: ${JSON.stringify(csrfResponse.headers)}`); + } + + await debugLog("[AUTH] CSRF token requested"); + + // Step 2: Extract CSRF token from cookie jar for the request header + const maybeJar = (instance.defaults as Record).jar; + if (!(maybeJar instanceof CookieJar)) { + await debugLog("[AUTH] ERROR: Cookie jar not found on axios instance"); + return { success: false, error: "cookies", message: "Cookie jar not available for session authentication" }; + } + const jar = maybeJar; + // Check cookies on the specific CSRF URL to ensure we get path-scoped cookies + const cookies = await jar.getCookies(csrfUrl); + await debugLog(`[AUTH] Cookies after CSRF request: ${cookies.map(c => c.key).join(", ")}`); + + const csrfCookie = cookies.find((c) => ["csrftoken", "csrf", "XSRF-TOKEN"].includes(c.key)); + + if (!csrfCookie) { + await debugLog("[AUTH] CSRF token not found in cookies"); + return { success: false, error: 'csrf', message: 'CSRF token not found in response' }; + } + + // Step 3: Login with email, password, and CSRF token + // Send as form data (application/x-www-form-urlencoded) not JSON + const formData = new URLSearchParams(); + formData.append('email', email); + formData.append('password', password); + + await debugLog(`[AUTH] Sending login request to: ${host}auth/sign-in/`); + await debugLog(`[AUTH] Login email: ${email}`); + // Do NOT log password + await debugLog("[AUTH] CSRF token found"); + + const loginResponse = await instance.post( + `${host}auth/sign-in/`, + formData.toString(), + { + headers: { + "X-CSRFToken": csrfCookie.value, + "Content-Type": "application/x-www-form-urlencoded", + }, + maxRedirects: 0, // Don't follow redirects, we just need the cookies + validateStatus: (status) => (status >= 200 && status < 300) || status === 302, // Accept 2xx and 302 (redirect) as success + } + ); + + // Log response details + await debugLog(`[AUTH] Login response status: ${loginResponse.status}`); + const headerNames = Object.keys(loginResponse.headers ?? {}); + await debugLog(`[AUTH] Login response headers present: ${headerNames.join(", ")}`); + + // Log ALL headers for debugging ONLY if verbose + if (process.env.PLANE_MCP_DEBUG === 'verbose') { + await debugLog(`[AUTH] Login response headers FULL: ${JSON.stringify(loginResponse.headers)}`); + } + + // Check if Set-Cookie headers are present + const setCookieHeader = loginResponse.headers['set-cookie']; + if (setCookieHeader) { + await debugLog(`[AUTH] Set-Cookie headers received: ${Array.isArray(setCookieHeader) ? setCookieHeader.length : 1} cookie(s)`); + } else { + await debugLog(`[AUTH] WARNING: No Set-Cookie headers in login response! Checking cookie jar anyway...`); + } + + // Verify cookies were stored in the jar + const loginCookies = await jar.getCookies(host); + await debugLog(`[AUTH] Cookies after login: ${loginCookies.map(c => c.key).join(", ")}`); + await debugLog(`[AUTH] Total cookies stored: ${loginCookies.length}`); + + // Validate that session cookie was received + const sessionCookieNames = ["session-id", "sessionid", "plane_session"]; + const sessionCookie = loginCookies.find((c) => sessionCookieNames.includes(c.key)); + if (!sessionCookie) { + await debugLog(`[AUTH] WARNING: No standard session cookie found (looked for: ${sessionCookieNames.join(", ")})`); + } + + // Log full cookie details for debugging - gated + if (process.env.PLANE_MCP_DEBUG === 'verbose') { + loginCookies.forEach(c => { + debugLog(`[AUTH] Cookie detail - ${c.key}: domain=${c.domain}, path=${c.path}, httpOnly=${c.httpOnly}, secure=${c.secure}`).catch(() => {}); + }); + } + + // Verify the session works with a test API call + try { + const verifyUrl = `${host}api/users/me/`; + await debugLog(`[AUTH] Attempting to verify session with: ${verifyUrl}`); + await debugLog(`[AUTH] Cookies being sent: ${loginCookies.map(c => c.key).join(", ")}`); + + const verifyResponse = await instance.get(verifyUrl); + await debugLog(`[AUTH] Verification response status: ${verifyResponse.status}`); + // Log only non-sensitive data if possible, or truncate heavily + await debugLog(`[AUTH] Verification response data: ${JSON.stringify(verifyResponse.data).substring(0, 50)}...`); + + if (verifyResponse.status !== 200) { + await debugLog(`[AUTH] Session verification failed with status: ${verifyResponse.status}`); + return { success: false, error: 'credentials', message: 'Session verification failed' }; + } + await debugLog("[AUTH] Session verified successfully"); + } catch (verifyError) { + if (axios.isAxiosError(verifyError)) { + await debugLog(`[AUTH] Session verification axios error - status: ${verifyError.response?.status}, message: ${verifyError.message}`); + // Avoid logging full sensitive data in error responses + await debugLog(`[AUTH] Verification error response status: ${verifyError.response?.status}`); + } + await debugLog(`[AUTH] Session verification request failed: ${verifyError}`); + return { success: false, error: 'credentials', message: 'Could not verify session validity' }; + } + + isAuthenticated = true; + authenticationTime = Date.now(); + await debugLog("[AUTH] Authentication successful"); + return { success: true }; + } catch (error) { + // Reset auth state on failure to avoid stale state + isAuthenticated = false; + authenticationTime = null; + + await debugLog(`[AUTH] Authentication failed: ${error}`); + + if (axios.isAxiosError(error)) { + if (!error.response) { + return { success: false, error: 'network', message: 'Network error - could not connect to server' }; + } + if (error.response.status === 401 || error.response.status === 403) { + return { success: false, error: 'credentials', message: 'Invalid email or password' }; + } + return { success: false, error: 'unknown', message: `Server error: ${error.response.status}` }; + } + + return { success: false, error: 'unknown', message: String(error) }; + } +} + +/** + * Checks whether a session is currently authenticated + * @returns true if authenticated, false otherwise + */ +export function isSessionAuthenticated(): boolean { + if (!isAuthenticated || !authenticationTime) { + debugLog(`[AUTH] isSessionAuthenticated() - not authenticated`).catch(() => {}); + return false; + } + + const isStale = Date.now() - authenticationTime > SESSION_TIMEOUT_MS; + if (isStale) { + debugLog(`[AUTH] Session expired, resetting authentication`).catch(() => {}); + isAuthenticated = false; + authenticationTime = null; + return false; + } + + debugLog(`[AUTH] isSessionAuthenticated() called - returning: ${isAuthenticated}`).catch(() => {}); + return isAuthenticated; +} + +/** + * Resets the authentication state and clears all session cookies + * + * This function: + * 1. Removes all cookies from the cookie jar + * 2. Clears the axios instance + * 3. Resets authentication flag + * + * Call this when logging out or when authentication needs to be cleared. + * + * @returns Promise that resolves when authentication is reset + */ +export async function resetAuthentication(): Promise { + try { + if (axiosInstance) { + const maybeJar = (axiosInstance.defaults as Record).jar; + if (maybeJar instanceof CookieJar) { + const jar = maybeJar; + await jar.removeAllCookies(); + await debugLog("[AUTH] Cookie jar cleared"); + } + } + } catch (error) { + await debugLog(`[AUTH] Error clearing cookies: ${error}`); + // Continue with cleanup even if cookie removal fails + } finally { + axiosInstance = null; + isAuthenticated = false; + authenticationTime = null; + await debugLog("[AUTH] Authentication reset"); + } +} diff --git a/src/common/debug.ts b/src/common/debug.ts new file mode 100644 index 0000000..1ac79fe --- /dev/null +++ b/src/common/debug.ts @@ -0,0 +1,20 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +const logFile = path.join(os.tmpdir(), "plane-mcp-debug.log"); +const DEBUG_ENABLED = process.env.PLANE_MCP_DEBUG === 'true' || process.env.PLANE_MCP_DEBUG === 'verbose'; + +export async function debugLog(message: string): Promise { + if (!DEBUG_ENABLED) return; + + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}\n`; + + try { + await fs.appendFile(logFile, logMessage); + console.error(message); + } catch (error) { + console.error(`[DEBUG] Log write failed: ${error}`); + } +} diff --git a/src/common/request-helper.ts b/src/common/request-helper.ts index 3e2f488..e850432 100644 --- a/src/common/request-helper.ts +++ b/src/common/request-helper.ts @@ -1,36 +1,136 @@ import axios, { AxiosRequestConfig } from "axios"; +import { getAxiosInstance, isSessionAuthenticated } from "./auth.js"; +import { debugLog } from "./debug.js"; +/** + * Makes an authenticated request to the Plane API + * + * This function handles routing requests to the correct API endpoint and authentication method: + * - Pages endpoints (matching `/pages/`, `/pages-summary/`, etc.) use session authentication and /api/ prefix + * - All other endpoints use API key authentication and /api/v1/ prefix + * + * Session authentication requires prior login via plane_login tool. + * API key authentication requires PLANE_API_KEY environment variable. + * + * @template T - Expected response type + * @param method - HTTP method (GET, POST, PATCH, DELETE, etc.) + * @param path - API path without prefix (e.g., "workspaces/my-workspace/projects") + * @param body - Request body for POST/PATCH/PUT requests (optional) + * @returns Promise resolving to typed response data + * @throws Error if authentication is required but not configured, or if request fails + */ export async function makePlaneRequest(method: string, path: string, body: any = null): Promise { const hostUrl = process.env.PLANE_API_HOST_URL || "https://api.plane.so/"; const host = hostUrl.endsWith("/") ? hostUrl : `${hostUrl}/`; - const url = `${host}api/v1/${path}`; - const headers: Record = { - "X-API-Key": process.env.PLANE_API_KEY || "", - }; - - // Only add Content-Type for non-GET requests - if (method.toUpperCase() !== "GET") { - headers["Content-Type"] = "application/json"; - } + + // Conditional API versioning: Pages use /api/, others use /api/v1/ + // Plane has mixed versioning - pages endpoints don't use version prefix + // Match pages-specific patterns to avoid false positives with future endpoints + const isPagesEndpoint = /\/pages\/|\/pages$|\/pages-summary\/|\/favorite-pages\/|\/pages\/[^/]+\/description\/|\/pages\/[^/]+\/versions\//.test(path); + const usesV1 = !isPagesEndpoint; + const apiPrefix = usesV1 ? 'api/v1/' : 'api/'; + const url = `${host}${apiPrefix}${path}`; + + // Pages endpoints require session authentication, others use API key + const requiresSession = isPagesEndpoint; + + await debugLog(`[REQUEST] ${method} ${url}`); + await debugLog(`[REQUEST] Auth mode: ${requiresSession ? 'session (cookies)' : 'api_key'} (prefix: ${apiPrefix})`); try { - const config: AxiosRequestConfig = { - url, - method, - headers, - }; - - // Only include body for non-GET requests - if (method.toUpperCase() !== "GET" && body !== null) { - config.data = body; + let response; + + if (requiresSession) { + // Use session authentication for pages endpoints + if (!isSessionAuthenticated()) { + throw new Error("Session authentication required. Please call plane_login first."); + } + + const sessionAxios = getAxiosInstance(); + + // Debug: Check what cookies are available + const jar = (sessionAxios.defaults as any).jar; + if (jar) { + const cookies = await jar.getCookies(url); + await debugLog(`[REQUEST] Cookies available for ${url}: ${cookies.map((c: any) => c.key).join(", ")}`); + await debugLog(`[REQUEST] Total cookies: ${cookies.length}`); + } else { + await debugLog(`[REQUEST] WARNING: No cookie jar found!`); + } + + const headers: Record = {}; + + // Get CSRF token from cookies for non-GET requests + if (method.toUpperCase() !== "GET") { + headers["Content-Type"] = "application/json"; + + const jar = (sessionAxios.defaults as any).jar; + if (jar) { + // Use full URL to match path-scoped cookies + const cookies = await jar.getCookies(url); + const csrfCookie = cookies.find((c: any) => ["csrftoken", "csrf", "XSRF-TOKEN"].includes(c.key)); + if (csrfCookie) { + headers["X-CSRFToken"] = csrfCookie.value; + await debugLog(`[REQUEST] CSRF token found`); + } else { + await debugLog(`[REQUEST] WARNING: No CSRF token found in cookies!`); + } + } + } + + const config: AxiosRequestConfig = { + url, + method, + headers, + }; + + // Include body for non-GET requests + if (method.toUpperCase() !== "GET" && body !== null) { + config.data = body; + } + + response = await sessionAxios(config); + } else { + // Use API key authentication for /api/v1/ endpoints + const headers: Record = {}; + + if (process.env.PLANE_API_KEY) { + headers["X-API-Key"] = process.env.PLANE_API_KEY; + } + + // Only add Content-Type for non-GET requests + if (method.toUpperCase() !== "GET") { + headers["Content-Type"] = "application/json"; + } + + const config: AxiosRequestConfig = { + url, + method, + headers, + }; + + // Only include body for non-GET requests + if (method.toUpperCase() !== "GET" && body !== null) { + config.data = body; + } + + response = await axios(config); } - const response = await axios(config); + await debugLog(`[REQUEST] Response status: ${response.status}`); return response.data; } catch (error) { if (axios.isAxiosError(error)) { - throw new Error(`Request failed: ${error.message}`); + await debugLog(`[REQUEST] Error: ${error.message}, status: ${error.response?.status}`); + + // Log full error response ONLY if VERBOSE debug mode is enabled + if (process.env.PLANE_MCP_DEBUG === 'verbose') { + await debugLog(`[REQUEST] Full error response: ${JSON.stringify(error.response?.data)}`); + } + + // Throw sanitized error without response data + throw new Error(`Request failed: ${error.message} (${error.response?.status})`); } throw error; } -} +} \ No newline at end of file diff --git a/src/schemas.ts b/src/schemas.ts index 78f510c..1544011 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -250,3 +250,30 @@ export const IssueWorkLog = z.object({ }); export type IssueWorkLog = z.infer; + +export const Page = z.object({ + id: z.string().uuid().readonly(), + name: z.string().max(255).optional(), + owned_by: z.string().uuid().readonly(), + access: z.number().int().gte(0).lte(1).optional().describe("0 = Public, 1 = Private"), + color: z.string().max(255).optional(), + labels: z.array(z.string().uuid()).optional(), + parent: z.string().uuid().optional(), + is_favorite: z.boolean().readonly(), + is_locked: z.boolean().optional(), + archived_at: z.string().datetime({ offset: true }).optional(), + workspace: z.string().uuid().readonly(), + created_at: z.string().datetime({ offset: true }).readonly(), + updated_at: z.string().datetime({ offset: true }).readonly(), + created_by: z.string().uuid().readonly(), + updated_by: z.string().uuid().readonly(), + view_props: z.any().optional(), + logo_props: z.any().optional(), + label_ids: z.array(z.string().uuid()).readonly(), + project_ids: z.array(z.string().uuid()).readonly(), + description_html: z.string().optional(), + description: z.any().optional(), + description_binary: z.string().optional(), +}); + +export type Page = z.infer; diff --git a/src/tools/auth.ts b/src/tools/auth.ts new file mode 100644 index 0000000..f115564 --- /dev/null +++ b/src/tools/auth.ts @@ -0,0 +1,117 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { authenticateWithPassword, isSessionAuthenticated, resetAuthentication } from "../common/auth.js"; + +/** + * Registers authentication-related MCP tools + * + * Provides tools for: + * - plane_login: Session authentication via email/password + * - plane_auth_status: Check current authentication state + * - plane_logout: Clear session and reset authentication + * + * @param server - MCP server instance to register tools with + */ +export const registerAuthTools = (server: McpServer) => { + server.tool( + "plane_login", + "Authenticate with Plane using email and password for session-based access to Pages and /api/ endpoints", + { + email: z.string().email().describe("Your Plane account email"), + password: z.string().describe("Your Plane account password"), + }, + async ({ email, password }) => { + const hostUrl = process.env.PLANE_API_HOST_URL || "https://api.plane.so/"; + const result = await authenticateWithPassword(email, password, hostUrl); + + if (result.success) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Successfully authenticated with Plane", + authenticated: true, + note: "Session authentication enables access to Pages and /api/ endpoints. Standard /api/v1/ endpoints (Issues, Projects, etc.) still require an API key if configured.", + }, + null, + 2 + ), + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Authentication failed", + authenticated: false, + error: result.error, + details: result.message, + }, + null, + 2 + ), + }, + ], + }; + } + } + ); + + server.tool( + "plane_auth_status", + "Check current Plane authentication status", + {}, + async () => { + const authenticated = isSessionAuthenticated(); + const hasApiKey = !!process.env.PLANE_API_KEY; + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + session_authenticated: authenticated, + api_key_configured: hasApiKey, + current_mode: authenticated ? "session (Pages + /api/ endpoints)" : hasApiKey ? "api_key (/api/v1/ endpoints)" : "unauthenticated", + note: authenticated + ? "Session active: Access to Pages and /api/ endpoints enabled. (API key required for /api/v1/ endpoints)" + : hasApiKey + ? "API Key active: Access to /api/v1/ endpoints only." + : "No authentication configured.", + }, + null, + 2 + ), + }, + ], + }; + } + ); + + server.tool("plane_logout", "Logout and clear Plane session", {}, async () => { + await resetAuthentication(); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Session cleared", + authenticated: false, + }, + null, + 2 + ), + }, + ], + }; + }); +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 13e9d53..077738d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,20 +1,24 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAuthTools } from "./auth.js"; import { registerCycleIssueTools } from "./cycle-issues.js"; import { registerCycleTools } from "./cycles.js"; import { registerIssueTools } from "./issues.js"; import { registerMetadataTools } from "./metadata.js"; import { registerModuleIssueTools } from "./module-issues.js"; import { registerModuleTools } from "./modules.js"; +import { registerPageTools } from "./pages.js"; import { registerProjectTools } from "./projects.js"; import { registerUserTools } from "./user.js"; import { registerWorkLogTools } from "./work-log.js"; export const registerTools = (server: McpServer) => { + registerAuthTools(server); registerMetadataTools(server); registerUserTools(server); registerProjectTools(server); + registerPageTools(server); registerModuleTools(server); registerModuleIssueTools(server); registerIssueTools(server); diff --git a/src/tools/pages.ts b/src/tools/pages.ts new file mode 100644 index 0000000..b5ae344 --- /dev/null +++ b/src/tools/pages.ts @@ -0,0 +1,556 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { makePlaneRequest } from "../common/request-helper.js"; +import { type Page } from "../schemas.js"; + +/** + * Validates that PLANE_WORKSPACE_SLUG environment variable is set + * @throws Error if PLANE_WORKSPACE_SLUG is not configured + */ +function validateWorkspaceSlug(): void { + if (!process.env.PLANE_WORKSPACE_SLUG) { + throw new Error( + "PLANE_WORKSPACE_SLUG environment variable is required for page operations. " + + "Please set it to your workspace slug." + ); + } +} + +/** + * Registers Plane Pages API tools + * + * Provides comprehensive page management tools including: + * - CRUD operations: list, get, create, update, delete + * - Access control: set_page_access + * - Organization: archive, unarchive, lock, unlock + * - Favorites: favorite_page, unfavorite_page + * - Templates: duplicate_page + * - Content: get_page_description, update_page_description + * - History: get_page_versions, get_page_version + * - Overview: get_pages_summary + * + * All page operations require session authentication via plane_login. + * + * @param server - MCP server instance to register tools with + */ +export const registerPageTools = (server: McpServer) => { + server.tool( + "list_pages", + "Get all pages for a specific project", + { + project_id: z.string().uuid().describe("The uuid identifier of the project to get pages for"), + }, + async ({ project_id }) => { + validateWorkspaceSlug(); + const pages: Page[] = await makePlaneRequest( + "GET", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/` + ); + + const simplifiedPages = pages.map((page) => ({ + id: page.id, + name: page.name, + owned_by: page.owned_by, + access: page.access, + is_locked: page.is_locked, + is_favorite: page.is_favorite, + parent: page.parent, + archived_at: page.archived_at, + created_at: page.created_at, + updated_at: page.updated_at, + })); + + return { + content: [ + { + type: "text", + text: JSON.stringify(simplifiedPages, null, 2), + }, + ], + }; + } + ); + + server.tool( + "get_page", + "Get details of a specific page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to get"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + const page = await makePlaneRequest( + "GET", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(page, null, 2), + }, + ], + }; + } + ); + + server.tool( + "create_page", + "Create a new page in a project", + { + project_id: z.string().uuid().describe("The uuid identifier of the project to create the page in"), + name: z.string().describe("The name of the page"), + description_html: z.string().optional().describe("The HTML content of the page"), + access: z.number().int().gte(0).lte(1).optional().describe("0 = Public, 1 = Private. Defaults to 0 (Public)"), + color: z.string().optional().describe("Color for the page"), + parent: z.string().uuid().optional().describe("Parent page ID if this is a sub-page"), + }, + async ({ project_id, name, description_html, access, color, parent }) => { + validateWorkspaceSlug(); + const pageData: any = { + name, + }; + + if (description_html !== undefined) { + pageData.description_html = description_html; + } + + if (access !== undefined) { + pageData.access = access; + } + + if (color !== undefined) { + pageData.color = color; + } + + if (parent !== undefined) { + pageData.parent = parent; + } + + const page = await makePlaneRequest( + "POST", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/`, + pageData + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(page, null, 2), + }, + ], + }; + } + ); + + server.tool( + "update_page", + "Update an existing page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to update"), + name: z.string().optional().describe("The name of the page"), + description_html: z.string().optional().describe("The HTML content of the page"), + access: z.number().int().gte(0).lte(1).optional().describe("0 = Public, 1 = Private"), + color: z.string().optional().describe("Color for the page"), + parent: z.string().uuid().optional().describe("Parent page ID if this is a sub-page"), + }, + async ({ project_id, page_id, name, description_html, access, color, parent }) => { + validateWorkspaceSlug(); + const updateData: any = {}; + + if (name !== undefined) { + updateData.name = name; + } + + if (description_html !== undefined) { + updateData.description_html = description_html; + } + + if (access !== undefined) { + updateData.access = access; + } + + if (color !== undefined) { + updateData.color = color; + } + + if (parent !== undefined) { + updateData.parent = parent; + } + + const page = await makePlaneRequest( + "PATCH", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/`, + updateData + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(page, null, 2), + }, + ], + }; + } + ); + + server.tool( + "delete_page", + "Delete a page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to delete"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + await makePlaneRequest( + "DELETE", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ message: "Page deleted successfully", page_id }, null, 2), + }, + ], + }; + } + ); + + server.tool( + "archive_page", + "Archive a page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to archive"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + await makePlaneRequest( + "POST", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/archive/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ message: "Page archived successfully", page_id }, null, 2), + }, + ], + }; + } + ); + + server.tool( + "unarchive_page", + "Unarchive a page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to unarchive"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + await makePlaneRequest( + "DELETE", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/archive/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ message: "Page unarchived successfully", page_id }, null, 2), + }, + ], + }; + } + ); + + server.tool( + "lock_page", + "Lock a page to prevent editing", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to lock"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + await makePlaneRequest( + "POST", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/lock/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ message: "Page locked successfully", page_id }, null, 2), + }, + ], + }; + } + ); + + server.tool( + "unlock_page", + "Unlock a page to allow editing", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to unlock"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + await makePlaneRequest( + "DELETE", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/lock/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ message: "Page unlocked successfully", page_id }, null, 2), + }, + ], + }; + } + ); + + server.tool( + "favorite_page", + "Mark a page as favorite for quick access", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to favorite"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + await makePlaneRequest( + "POST", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/favorite-pages/${page_id}/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ message: "Page marked as favorite", page_id }, null, 2), + }, + ], + }; + } + ); + + server.tool( + "unfavorite_page", + "Remove a page from favorites", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to unfavorite"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + await makePlaneRequest( + "DELETE", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/favorite-pages/${page_id}/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ message: "Page removed from favorites", page_id }, null, 2), + }, + ], + }; + } + ); + + server.tool( + "duplicate_page", + "Duplicate a page to create a template or copy", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to duplicate"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + const duplicatedPage = await makePlaneRequest( + "POST", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/duplicate/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(duplicatedPage, null, 2), + }, + ], + }; + } + ); + + server.tool( + "set_page_access", + "Set page access level (public or private)", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page to update"), + access: z.number().int().gte(0).lte(1).describe("0 = Public, 1 = Private"), + }, + async ({ project_id, page_id, access }) => { + validateWorkspaceSlug(); + const page = await makePlaneRequest( + "POST", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/access/`, + { access } + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(page, null, 2), + }, + ], + }; + } + ); + + server.tool( + "get_pages_summary", + "Get a summary view of pages (filtered list of root-level pages)", + { + project_id: z.string().uuid().describe("The uuid identifier of the project to get pages summary for"), + }, + async ({ project_id }) => { + validateWorkspaceSlug(); + const pages: Page[] = await makePlaneRequest( + "GET", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages-summary/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(pages, null, 2), + }, + ], + }; + } + ); + + server.tool( + "get_page_description", + "Get the description content of a specific page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + const description = await makePlaneRequest( + "GET", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/description/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(description, null, 2), + }, + ], + }; + } + ); + + server.tool( + "update_page_description", + "Update the description content of a specific page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page"), + description_html: z.string().describe("The HTML content for the page description"), + }, + async ({ project_id, page_id, description_html }) => { + validateWorkspaceSlug(); + const description = await makePlaneRequest( + "PATCH", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/description/`, + { description_html } + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(description, null, 2), + }, + ], + }; + } + ); + + server.tool( + "get_page_versions", + "Get version history for a specific page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page"), + }, + async ({ project_id, page_id }) => { + validateWorkspaceSlug(); + const versions = await makePlaneRequest( + "GET", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/versions/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(versions, null, 2), + }, + ], + }; + } + ); + + server.tool( + "get_page_version", + "Get a specific version of a page", + { + project_id: z.string().uuid().describe("The uuid identifier of the project containing the page"), + page_id: z.string().uuid().describe("The uuid identifier of the page"), + version_id: z.string().uuid().describe("The uuid identifier of the specific version"), + }, + async ({ project_id, page_id, version_id }) => { + validateWorkspaceSlug(); + const version = await makePlaneRequest( + "GET", + `workspaces/${process.env.PLANE_WORKSPACE_SLUG}/projects/${project_id}/pages/${page_id}/versions/${version_id}/` + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(version, null, 2), + }, + ], + }; + } + ); +};