diff --git a/README.md b/README.md index d179613..553d68f 100644 --- a/README.md +++ b/README.md @@ -324,21 +324,126 @@ This server unlocks all sorts of useful capabilities for anyone working with Pla - Parameters: - `project_id` (string, required): UUID of the project - `issue_id` (string, required): UUID of the issue - - `worklog_id` (string, required): UUID of the worklog + - `worklog_id` (string, required): UUID of the worklog + +### Pages (Session Authentication Required) + +**Note:** Pages API tools require session authentication. Use `plane_login` first with your email and password. + +- `plane_login` + - Authenticate with Plane using email and password + - Parameters: + - `email` (string, required): Your Plane account email + - `password` (string, required): Your Plane account password + - `api_host_url` (string, optional): Plane API URL (defaults to https://api.plane.so/) + +- `list_pages` + - List all pages in a project + - Parameters: + - `project_id` (string, required): UUID of the project + +- `get_page` + - Get details of a specific page + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + +- `create_page` + - Create a new page + - Parameters: + - `project_id` (string, required): UUID of the project + - `name` (string, required): Page name + - `description` (string, optional): Page description + - `access` (integer, optional): Access level (0=public, 1=private) + +- `update_page` + - Update a page's properties + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + - `name` (string, optional): New page name + - `description` (string, optional): New description + - `access` (integer, optional): New access level + +- `delete_page` + - Delete a page permanently + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + +- `lock_page` / `unlock_page` + - Lock or unlock a page for editing + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + +- `favorite_page` / `unfavorite_page` + - Add or remove page from favorites + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + +- `archive_page` / `unarchive_page` + - Archive or restore a page + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + +- `duplicate_page` + - Create a copy of a page + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page to duplicate + +- `get_page_description` / `update_page_description` + - Get or update page HTML content + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + - `description_html` (string, required for update): HTML content + +- `get_page_versions` / `get_page_version` + - Get page version history + - Parameters: + - `project_id` (string, required): UUID of the project + - `page_id` (string, required): UUID of the page + - `version_id` (string, required for specific version): UUID of the version + +- `get_pages_summary` + - Get pages statistics for a project + - Parameters: + - `project_id` (string, required): UUID of the project ## Configuration Parameters +### For API Key Authentication (Most Tools) - `PLANE_API_KEY` - Your Plane API token. You can generate one from the Workspace Settings > API Tokens page (`/settings/api-tokens/`) in the Plane app. - `PLANE_WORKSPACE_SLUG` - The workspace slug for your Plane instance. The workspace-slug represents the unique workspace identifier for a workspace in Plane. It can be found in the URL. - `PLANE_API_HOST_URL` (optional) - The host URL of the Plane API Server. Defaults to https://api.plane.so/ +### For Session Authentication (Pages API Only) +Pages API tools require session authentication using email/password instead of API key. Use the `plane_login` tool before accessing Pages tools. + +**Environment variables for Pages authentication:** +- `PLANE_EMAIL` (optional) - Your Plane account email for session authentication +- `PLANE_PASSWORD` (optional) - Your Plane account password for session authentication + +**Note:** You can either: +1. Set these environment variables in your MCP client configuration, OR +2. Call `plane_login` tool manually with email/password when needed + +**Authentication methods by feature:** +- **Projects, Issues, Modules, Cycles, Labels, States, Work Logs**: API Key (PLANE_API_KEY) +- **Pages**: Session Auth (email/password via `plane_login` tool) + ## Usage ### Claude Desktop You can add Plane to [Claude Desktop](https://modelcontextprotocol.io/quickstart/user) by updating your `claude_desktop_config.json`: +**For standard API key authentication (Projects, Issues, etc.):** ```json { "mcpServers": { @@ -358,10 +463,35 @@ You can add Plane to [Claude Desktop](https://modelcontextprotocol.io/quickstart } ``` +**To also use Pages API (with session authentication):** +```json +{ + "mcpServers": { + "plane": { + "command": "npx", + "args": [ + "-y", + "@makeplane/plane-mcp-server" + ], + "env": { + "PLANE_API_KEY": "", + "PLANE_API_HOST_URL": "", + "PLANE_WORKSPACE_SLUG": "", + "PLANE_EMAIL": "", + "PLANE_PASSWORD": "" + } + } + } +} +``` + +**Note:** If you don't set `PLANE_EMAIL` and `PLANE_PASSWORD`, you can still use Pages tools by calling `plane_login` manually in your conversation. + ### VSCode You can also connect Plane to [VSCode](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) by editing your `.vscode.json` or `mcp.json` file: +**For standard API key authentication:** ```json { "servers": { @@ -379,7 +509,28 @@ You can also connect Plane to [VSCode](https://code.visualstudio.com/docs/copilo } } } +``` +**To also use Pages API (with session authentication):** +```json +{ + "servers": { + "plane": { + "command": "npx", + "args": [ + "-y", + "@makeplane/plane-mcp-server" + ], + "env": { + "PLANE_API_KEY": "", + "PLANE_API_HOST_URL": "", + "PLANE_WORKSPACE_SLUG": "", + "PLANE_EMAIL": "", + "PLANE_PASSWORD": "" + } + } + } +} ``` ## License diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..5aaaf97 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,133 @@ +# Plane MCP Server - Pages API Setup Guide + +## Quick Start + +### 1. Install +```bash +npm install -g @makeplane/plane-mcp-server +``` + +### 2. Configure Your MCP Client + +#### For Claude Desktop + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "plane": { + "command": "npx", + "args": ["-y", "@makeplane/plane-mcp-server"], + "env": { + "PLANE_API_KEY": "your-api-key-here", + "PLANE_WORKSPACE_SLUG": "your-workspace-slug", + "PLANE_API_HOST_URL": "https://api.plane.so/", + "PLANE_EMAIL": "your-email@example.com", + "PLANE_PASSWORD": "your-password" + } + } + } +} +``` + +#### For VSCode + +Edit `~/.vscode/mcp.json`: + +```json +{ + "servers": { + "plane": { + "command": "npx", + "args": ["-y", "@makeplane/plane-mcp-server"], + "env": { + "PLANE_API_KEY": "your-api-key-here", + "PLANE_WORKSPACE_SLUG": "your-workspace-slug", + "PLANE_API_HOST_URL": "https://api.plane.so/", + "PLANE_EMAIL": "your-email@example.com", + "PLANE_PASSWORD": "your-password" + } + } + } +} +``` + +### 3. Environment Variables + +#### Required for All Features +- `PLANE_API_KEY` - Get from Workspace Settings > API Tokens in Plane +- `PLANE_WORKSPACE_SLUG` - Found in your Plane workspace URL + +#### Optional +- `PLANE_API_HOST_URL` - Defaults to `https://api.plane.so/` (set for self-hosted) + +#### For Pages API Only +- `PLANE_EMAIL` - Your Plane account email +- `PLANE_PASSWORD` - Your Plane account password + +**Note:** If you don't set email/password, you can still use Pages tools by calling `plane_login` manually in your conversation. + +## Authentication Methods + +### API Key Authentication +Used for most tools: +- Projects, Issues, Modules, Cycles +- Labels, States, Issue Types +- Work Logs + +### Session Authentication +Required for Pages API tools: +- All 18 Pages API tools +- Use `plane_login` tool or set `PLANE_EMAIL`/`PLANE_PASSWORD` env vars + +## Testing Your Setup + +### Test API Key Authentication +Ask your AI assistant: +``` +"List my Plane projects" +``` + +### Test Pages Authentication +Ask your AI assistant: +``` +"Login to Plane with my credentials and list pages in project " +``` + +## Troubleshooting + +### Pages API Not Working +1. Verify you've called `plane_login` or set `PLANE_EMAIL`/`PLANE_PASSWORD` +2. Check your password is correct (no SSO - must be email/password account) +3. For cloud instances, ensure you're using `https://api.plane.so/` + +### API Key Authentication Failing +1. Verify your API key is valid in Plane settings +2. Check `PLANE_WORKSPACE_SLUG` matches your workspace URL +3. For self-hosted, verify `PLANE_API_HOST_URL` is correct + +## Self-Hosted Plane + +Set `PLANE_API_HOST_URL` to your instance: +```json +{ + "env": { + "PLANE_API_HOST_URL": "http://your-plane-instance.com/", + ... + } +} +``` + +## Security Notes + +- Store credentials securely in your MCP client config +- Don't share your API keys or passwords +- Use environment-specific credentials (dev vs prod) +- Consider using separate API keys for different tools + +## Getting Help + +- [Plane Documentation](https://docs.plane.so) +- [MCP Documentation](https://modelcontextprotocol.io) +- [GitHub Issues](https://github.com/makeplane/plane-mcp-server/issues) 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..35bb548 --- /dev/null +++ b/src/common/auth.ts @@ -0,0 +1,362 @@ +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, { + headers: { + "Referer": host.includes('api.plane.so') ? 'https://app.plane.so/' : host, + "Origin": host.includes('api.plane.so') ? 'https://app.plane.so' : host.replace(/\/$/, ''), + } + }); + 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", + // Referer and Origin headers are required for cloud instances to return cookies + "Referer": host.includes('api.plane.so') ? 'https://app.plane.so/' : host, + "Origin": host.includes('api.plane.so') ? 'https://app.plane.so' : host.replace(/\/$/, ''), + }, + 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"); + } +} + +/** + * Import cookies from browser session for cloud SSO authentication + * + * This function allows users to import their browser cookies when password + * authentication doesn't work (e.g., for SSO-created accounts). + * + * Steps for users: + * 1. Install a cookie export extension (e.g., "EditThisCookie", "Cookie-Editor") + * 2. Visit your Plane instance in the browser + * 3. Export cookies as JSON + * 4. Pass the JSON string to this function + * + * @param cookiesJson - JSON string containing exported cookies + * @param hostUrl - Plane server URL + * @returns Import result with success status + */ +export async function importCookies( + cookiesJson: string, + hostUrl: string +): Promise<{success: boolean; message?: string; cookiesImported?: number}> { + try { + const instance = getAxiosInstance(); + const host = hostUrl.endsWith("/") ? hostUrl : `${hostUrl}/`; + + await debugLog("[AUTH] Importing cookies from JSON..."); + + // Parse cookies JSON + let cookies; + try { + cookies = JSON.parse(cookiesJson); + } catch (parseError) { + return { success: false, message: "Invalid JSON format" }; + } + + // Handle both array and object formats + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + + const maybeJar = (instance.defaults as Record).jar; + if (!(maybeJar instanceof CookieJar)) { + return { success: false, message: "Cookie jar not available" }; + } + const jar = maybeJar; + + let imported = 0; + for (const cookie of cookieArray) { + try { + // Handle different cookie export formats + const cookieStr = cookie.name && cookie.value + ? `${cookie.name}=${cookie.value}; Domain=${cookie.domain || new URL(host).hostname}; Path=${cookie.path || '/'}; ${cookie.secure ? 'Secure;' : ''} ${cookie.httpOnly ? 'HttpOnly;' : ''}` + : cookie; + + await jar.setCookie(cookieStr, host); + imported++; + } catch (cookieError) { + await debugLog(`[AUTH] Failed to import cookie: ${cookieError}`); + } + } + + await debugLog(`[AUTH] Imported ${imported} cookies`); + + if (imported === 0) { + return { success: false, message: "No valid cookies found in JSON" }; + } + + // Verify the session works + try { + const verifyUrl = `${host}api/users/me/`; + const verifyResponse = await instance.get(verifyUrl); + + if (verifyResponse.status === 200) { + isAuthenticated = true; + authenticationTime = Date.now(); + await debugLog("[AUTH] Session verified with imported cookies"); + return { success: true, cookiesImported: imported }; + } else { + return { success: false, message: "Session verification failed - cookies may be invalid or expired" }; + } + } catch (verifyError) { + return { success: false, message: "Could not verify session with imported cookies" }; + } + } catch (error) { + await debugLog(`[AUTH] Cookie import failed: ${error}`); + return { success: false, message: String(error) }; + } +} 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..0e3da5f --- /dev/null +++ b/src/tools/auth.ts @@ -0,0 +1,224 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + authenticateWithPassword, + isSessionAuthenticated, + resetAuthentication, + importCookies +} from "../common/auth.js"; + +/** + * Registers authentication-related MCP tools + * + * Provides tools for: + * - plane_login: Session authentication via email/password + * - plane_import_cookies: Import browser cookies for cloud SSO accounts + * - 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. Note: Cloud accounts with SSO may need to use plane_import_cookies instead.", + { + email: z.string().email().describe("Your Plane account email"), + password: z.string().describe("Your Plane account password"), + host: z.string().url().optional().describe("Plane host URL (defaults to PLANE_API_HOST_URL or https://api.plane.so/)"), + }, + async ({ email, password, host }) => { + const hostUrl = host || 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 { + // Enhanced error messaging for cloud SSO users + let troubleshooting = result.message; + if (result.error === 'credentials' && hostUrl.includes('api.plane.so')) { + troubleshooting += "\n\nNote for Cloud users: If you signed up with Google/GitHub SSO, password authentication may not work. You have two options:\n" + + "1. Set a password in your Plane account settings, OR\n" + + "2. Use 'plane_import_cookies' to import your browser session cookies"; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Authentication failed", + authenticated: false, + error: result.error, + details: troubleshooting, + }, + null, + 2 + ), + }, + ], + }; + } + } + ); + + server.tool( + "plane_import_cookies", + "Import browser cookies for Plane cloud SSO authentication. This allows you to use your existing browser session. Export cookies from your browser using a cookie export extension, then paste the JSON here.", + { + cookies_json: z.string().describe("JSON string containing exported cookies from your browser session"), + host: z.string().url().optional().describe("Plane host URL (defaults to PLANE_API_HOST_URL or https://api.plane.so/)"), + }, + async ({ cookies_json, host }) => { + const hostUrl = host || process.env.PLANE_API_HOST_URL || "https://api.plane.so/"; + + try { + const result = await importCookies(cookies_json, hostUrl); + + if (result.success) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Successfully imported browser cookies", + authenticated: true, + cookies_imported: result.cookiesImported, + note: "You can now access Pages API using your browser session", + }, + null, + 2 + ), + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Failed to import cookies", + authenticated: false, + error: result.message, + }, + null, + 2 + ), + }, + ], + }; + } + } catch (error) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Error importing cookies", + authenticated: false, + error: error instanceof Error ? error.message : String(error), + }, + null, + 2 + ), + }, + ], + }; + } + } + ); + + server.tool( + "plane_auth_status", + "Check current Plane authentication status", + {}, + async () => { + const authenticated = isSessionAuthenticated(); + const hasApiKey = !!process.env.PLANE_API_KEY; + + let currentMode = ""; + if (authenticated && hasApiKey) { + currentMode = "session + API key (full access)"; + } else if (authenticated) { + currentMode = "session (Pages + /api/ endpoints)"; + } else if (hasApiKey) { + currentMode = "API key only (/api/v1/ endpoints)"; + } else { + currentMode = "not authenticated"; + } + + let note = ""; + if (authenticated) { + note = "Session active: Access to Pages and /api/ endpoints enabled. (API key required for /api/v1/ endpoints)"; + } else if (hasApiKey) { + note = "API key configured: Access to /api/v1/ endpoints (Projects, Issues, etc.). Use plane_login or plane_import_cookies for Pages access."; + } else { + note = "No authentication configured. Set PLANE_API_KEY for /api/v1/ access, or use plane_login/plane_import_cookies for Pages access."; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + session_authenticated: authenticated, + api_key_configured: hasApiKey, + current_mode: currentMode, + note: note, + }, + null, + 2 + ), + }, + ], + }; + } + ); + + server.tool( + "plane_logout", + "Log out and clear all authentication state", + {}, + async () => { + await resetAuthentication(); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + message: "Successfully logged out", + authenticated: false, + note: "All session cookies have been cleared", + }, + 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), + }, + ], + }; + } + ); +};