kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1160 from tinsever/feat/add-mcp

feat: add mcp

authored by

Andrej and committed by
GitHub
7c33759e e15a68b9

+3004 -8
+73
.github/workflows/publish-mcp.yml
··· 1 + name: Publish MCP Package 2 + 3 + on: 4 + release: 5 + types: [published] 6 + 7 + permissions: 8 + contents: read 9 + id-token: write 10 + 11 + jobs: 12 + publish: 13 + runs-on: ubuntu-24.04 14 + if: ${{ startsWith(github.event.release.tag_name, 'mcp-v') }} 15 + 16 + steps: 17 + - name: Checkout repository 18 + uses: actions/checkout@v4 19 + 20 + - name: Setup pnpm 21 + uses: pnpm/action-setup@v4 22 + with: 23 + version: 10.32.1 24 + 25 + - name: Setup Node.js 26 + uses: actions/setup-node@v6 27 + with: 28 + node-version: 20.19.0 29 + cache: pnpm 30 + registry-url: https://registry.npmjs.org 31 + 32 + - name: Install dependencies 33 + run: pnpm install --frozen-lockfile 34 + 35 + - name: Run MCP checks 36 + run: | 37 + pnpm --filter @kaneo/mcp test 38 + pnpm --filter @kaneo/mcp build 39 + 40 + - name: Validate MCP release tag 41 + run: | 42 + PACKAGE_VERSION=$(node -p "require('./packages/mcp/package.json').version") 43 + TAG_VERSION="${GITHUB_REF_NAME#mcp-v}" 44 + 45 + if [ "${GITHUB_REF_NAME}" = "${TAG_VERSION}" ]; then 46 + echo "Release tag must start with mcp-v, got ${GITHUB_REF_NAME}" 47 + exit 1 48 + fi 49 + 50 + if [ "${TAG_VERSION}" != "${PACKAGE_VERSION}" ]; then 51 + echo "Release tag version (${TAG_VERSION}) does not match packages/mcp version (${PACKAGE_VERSION})" 52 + exit 1 53 + fi 54 + 55 + - name: Check whether @kaneo/mcp version is already published 56 + id: version_check 57 + run: | 58 + PACKAGE_VERSION=$(node -p "require('./packages/mcp/package.json').version") 59 + 60 + if npm view "@kaneo/mcp@${PACKAGE_VERSION}" version >/dev/null 2>&1; then 61 + echo "already_published=true" >> "$GITHUB_OUTPUT" 62 + echo "@kaneo/mcp@${PACKAGE_VERSION} is already published; skipping publish." 63 + else 64 + echo "already_published=false" >> "$GITHUB_OUTPUT" 65 + fi 66 + 67 + - name: Publish @kaneo/mcp 68 + if: ${{ steps.version_check.outputs.already_published == 'false' }} 69 + run: | 70 + cd packages/mcp 71 + npm publish --access public --provenance 72 + env: 73 + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+2 -2
ENVIRONMENT_SETUP.md
··· 26 26 - `KANEO_CLIENT_URL` - The URL of the web application (e.g., `http://localhost:5173`) 27 27 - `KANEO_API_URL` - The URL of the API (e.g., `http://localhost:1337`) 28 28 - `AUTH_SECRET` - Secret key for JWT token generation (**must be at least 32 characters long**; use a long, random value in production) 29 - - `DEVICE_AUTH_CLIENT_IDS` - Optional comma-separated list of allowed device-flow client IDs (for example `kaneo-cli,my-app`) 29 + - `DEVICE_AUTH_CLIENT_IDS` - **Optional.** Comma-separated list of allowed device-flow OAuth client IDs. When unset, Kaneo implicitly allows `kaneo-cli` and `kaneo-mcp` by default (no extra configuration for the CLI or MCP). Override only when you need additional trusted clients, for example `kaneo-cli,kaneo-mcp,my-desktop-app`. 30 30 - `DATABASE_URL` - PostgreSQL connection string 31 31 - `POSTGRES_DB` - PostgreSQL database name 32 32 - `POSTGRES_USER` - PostgreSQL username 33 33 - `POSTGRES_PASSWORD` - PostgreSQL password 34 34 35 - If you are testing a CLI or external app against your local Kaneo instance, set `DEVICE_AUTH_CLIENT_IDS` to include the client ID your app sends to `/api/auth/device/code`. 35 + If your app uses a device client ID that is not included in the defaults, set `DEVICE_AUTH_CLIENT_IDS` to the full comma-separated list of allowed IDs (including any defaults you still need), so it includes the client ID your app sends to `/api/auth/device/code`. 36 36 37 37 ### Development-Specific Variables 38 38
+1 -1
apps/api/src/auth.ts
··· 113 113 .filter(Boolean), 114 114 ); 115 115 } 116 - return new Set(["kaneo-cli"]); 116 + return new Set(["kaneo-cli", "kaneo-mcp"]); 117 117 } 118 118 119 119 function getDeviceAuthVerificationUri(): string {
+3 -1
apps/docs/api-reference/authentication.mdx
··· 97 97 98 98 ## Using Device Authorization 99 99 100 - Before using device authorization on a self-hosted Kaneo instance, the instance operator must allow your client ID through `DEVICE_AUTH_CLIENT_IDS`. 100 + By default, self-hosted Kaneo allows the built-in device clients `kaneo-cli` and `kaneo-mcp`. 101 + 102 + If you want to use a different device client ID, the instance operator must allow it through `DEVICE_AUTH_CLIENT_IDS`. 101 103 102 104 ### Flow overview 103 105
+2 -2
apps/docs/core/installation/environment-variables.mdx
··· 51 51 Name | Description | 52 52 --- | --- | 53 53 | `AUTH_SECRET` | The secret key for the JWT token. **Must be at least 32 characters long**, use a long, random value in production. Example: use `openssl rand -base64 32` to generate a secure key in Linux/macOS. 54 - | `DEVICE_AUTH_CLIENT_IDS` | Comma-separated list of allowed device authorization client IDs. When unset, `kaneo-cli` is allowed by default, so no extra configuration is required for the CLI. Use this to permit trusted external app identifiers such as `kaneo-cli,my-desktop-app`. | 54 + | `DEVICE_AUTH_CLIENT_IDS` | **Optional.** Comma-separated list of allowed device authorization client IDs. When unset, Kaneo implicitly allows `kaneo-cli` and `kaneo-mcp` by default, so no extra configuration is required for the CLI or MCP. Override only when you need additional trusted clients, for example `kaneo-cli,kaneo-mcp,my-desktop-app`. | 55 55 56 56 57 57 ## Optional variables ··· 153 153 - If you enable Discord SSO, you need to set up the Discord application which is used to authenticate users in the [Discord Developer Portal](https://discord.com/developers/applications). See the [Discord SSO guide](/core/social-providers/discord). 154 154 - If you enable Custom OAuth/OIDC, you need to configure your identity provider with the appropriate redirect URI. See the [Custom OAuth/OIDC guide](/core/social-providers/custom-oauth). 155 155 - If you have enabled SMTP, your sign in will be done via email using a magic link. 156 - - If you want to allow CLI or external-app sign-in through device authorization, set `DEVICE_AUTH_CLIENT_IDS` to the trusted client IDs for your deployment. 156 + - If you need device authorization for clients beyond the defaults (`kaneo-cli`, `kaneo-mcp` when `DEVICE_AUTH_CLIENT_IDS` is unset), set `DEVICE_AUTH_CLIENT_IDS` to the full comma-separated list of trusted client IDs for your deployment.
+96
apps/docs/core/integrations/mcp.mdx
··· 1 + --- 2 + title: MCP 3 + description: Connect Kaneo to MCP clients like Cursor and Claude Desktop using the official Kaneo MCP server. 4 + --- 5 + 6 + Kaneo ships an official MCP server package, `@kaneo/mcp`, so you can manage workspaces, projects, tasks, comments, and labels from MCP-compatible tools. 7 + 8 + The server runs locally over stdio and authenticates with Kaneo using device authorization. 9 + 10 + ## Prerequisites 11 + 12 + - Node.js 20 or newer 13 + - A running Kaneo API 14 + - Access to the Kaneo web app to approve device login 15 + 16 + By default, self-hosted Kaneo allows both `kaneo-cli` and `kaneo-mcp` as device auth client IDs. 17 + 18 + ## Install and register 19 + 20 + Use the recommended interactive installer: 21 + 22 + ```bash 23 + npx @kaneo/mcp 24 + ``` 25 + 26 + The installer lets you choose where to register the server configuration: 27 + 28 + - Cursor (user-wide) 29 + - Cursor (project-level) 30 + - Claude Desktop 31 + - Custom JSON config path 32 + 33 + You can also run the explicit install command: 34 + 35 + ```bash 36 + kaneo-mcp install --target cursor-user -y 37 + ``` 38 + 39 + Use `--help` to see all available targets and flags: 40 + 41 + ```bash 42 + kaneo-mcp install --help 43 + ``` 44 + 45 + ## Point to a self-hosted instance 46 + 47 + When generating config, pass your Kaneo base URL: 48 + 49 + ```bash 50 + kaneo-mcp install --target cursor-user -y --api-url https://kaneo.example.com 51 + ``` 52 + 53 + Or set it via environment variable: 54 + 55 + | Variable | Description | 56 + | --- | --- | 57 + | `KANEO_API_URL` | Kaneo API origin. Default: `http://localhost:1337`. Do not include `/api`. | 58 + | `KANEO_MCP_CLIENT_ID` | Device auth client ID. Default: `kaneo-mcp`. | 59 + 60 + If you override `KANEO_MCP_CLIENT_ID`, make sure it is included in [`DEVICE_AUTH_CLIENT_IDS`](/core/installation/environment-variables). 61 + 62 + ## Authentication flow 63 + 64 + On the first tool call that needs Kaneo access, the MCP server: 65 + 66 + 1. Requests a device code from Kaneo 67 + 2. Prints a verification URL and user code 68 + 3. Opens the browser when possible 69 + 4. Polls until you approve sign-in 70 + 5. Stores credentials in `~/.config/kaneo-mcp/credentials.json` 71 + 72 + ## Available tools 73 + 74 + Current MCP tools include: 75 + 76 + - Session: `whoami`, `list_workspaces` 77 + - Projects: `list_projects`, `get_project`, `create_project`, `update_project` 78 + - Tasks: `list_tasks`, `get_task`, `create_task`, `update_task`, `move_task`, `update_task_status` 79 + - Comments: `list_task_comments`, `create_task_comment` 80 + - Labels: `list_workspace_labels`, `create_label`, `attach_label_to_task`, `detach_label_from_task` 81 + 82 + ## Run manually for debugging 83 + 84 + To run the stdio server directly: 85 + 86 + ```bash 87 + npx @kaneo/mcp serve 88 + ``` 89 + 90 + When running from source in this monorepo: 91 + 92 + ```bash 93 + pnpm --filter @kaneo/mcp run build 94 + pnpm --filter @kaneo/mcp run start 95 + ``` 96 +
+1
apps/docs/docs.json
··· 87 87 { 88 88 "group": "Integrations", 89 89 "pages": [ 90 + "core/integrations/mcp", 90 91 "core/integrations/discord", 91 92 "core/integrations/slack", 92 93 "core/integrations/telegram",
+2 -1
apps/web/package.json
··· 73 73 "framer-motion": "^12.34.3", 74 74 "highlight.js": "^11.11.1", 75 75 "hono": "^4.12.4", 76 + "i18next": "^25.5.3", 76 77 "immer": "^11.1.4", 77 78 "input-otp": "^1.4.2", 78 - "i18next": "^25.5.3", 79 79 "lowlight": "^3.3.0", 80 80 "lucide-react": "^0.577.0", 81 81 "marked": "^17.0.4", 82 + "nanostores": "^1.1.1", 82 83 "radix-ui": "^1.4.3", 83 84 "react": "^19.2.4", 84 85 "react-day-picker": "9.14.0",
+21
packages/mcp/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Kaneo MCP contributors 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+115
packages/mcp/README.md
··· 1 + # Kaneo MCP server 2 + 3 + `@kaneo/mcp` is a local MCP server for Kaneo. 4 + 5 + It runs over stdio, signs in with Kaneo's device flow, and then calls the Kaneo API with a bearer token. The package lives in `packages/mcp` in this monorepo and exposes the `kaneo-mcp` CLI. 6 + 7 + ## Prerequisites 8 + 9 + - Node.js 20+ 10 + - A running Kaneo API (for example `http://localhost:1337`) and web app (for device approval UI). 11 + 12 + Kaneo allows `kaneo-cli` and `kaneo-mcp` by default, so you usually do not need extra server configuration. 13 + 14 + If you want to run this server with a different client ID, allow it on the Kaneo server: 15 + 16 + ```bash 17 + DEVICE_AUTH_CLIENT_IDS=kaneo-cli,kaneo-mcp,your-client-id 18 + ``` 19 + 20 + ## Environment 21 + 22 + | Variable | Description | 23 + |----------|-------------| 24 + | `KANEO_API_URL` | Kaneo API origin (default `http://localhost:1337`). Do not include `/api`. | 25 + | `KANEO_MCP_CLIENT_ID` | Device-flow client id (default `kaneo-mcp`). Must match `DEVICE_AUTH_CLIENT_IDS` on the server. | 26 + 27 + ## Install 28 + 29 + **Recommended (no global install):** run the interactive installer with npx: 30 + 31 + ```bash 32 + npx @kaneo/mcp 33 + ``` 34 + 35 + npm downloads the package, then an **interactive menu** (arrow keys + Enter) asks **where** to register the server (Cursor user-wide, Cursor project, Claude Desktop, or a custom JSON path). It then merges a `mcpServers` entry that points at this package’s `dist/index.js` with your current Node binary. 36 + 37 + In a normal terminal, `npx @kaneo/mcp` and `kaneo-mcp` with no subcommand both start the installer. When the process is **not** attached to a TTY (for example when Cursor launches the MCP server with a pipe), the same entry runs the stdio MCP server instead. 38 + 39 + To run the server manually from a shell (for example to debug stdio), use: 40 + 41 + ```bash 42 + npx @kaneo/mcp serve 43 + ``` 44 + 45 + If you prefer a global install: 46 + 47 + ```bash 48 + npm install -g @kaneo/mcp 49 + kaneo-mcp 50 + ``` 51 + 52 + (`kaneo-mcp install` is the same installer with an explicit subcommand.) 53 + 54 + Non-interactive example (Cursor user config, skip overwrite prompts): 55 + 56 + ```bash 57 + kaneo-mcp install --target cursor-user -y 58 + ``` 59 + 60 + Point at a self-hosted API when generating the config: 61 + 62 + ```bash 63 + kaneo-mcp install --target cursor-user -y --api-url https://kaneo.example.com 64 + ``` 65 + 66 + See all options: 67 + 68 + ```bash 69 + kaneo-mcp install --help 70 + ``` 71 + 72 + If you are currently inside the local `packages/mcp` package directory, npm may resolve the local workspace package instead of the published one and fail to expose the bin. In that case, either run `npx` from outside `packages/mcp`, or use a local build: 73 + 74 + ```bash 75 + node dist/index.js 76 + ``` 77 + 78 + The published package includes `dist/`. `prepublishOnly` runs the build before publish. 79 + 80 + ## Develop from source 81 + 82 + From the repo root: 83 + 84 + ```bash 85 + pnpm install 86 + pnpm --filter @kaneo/mcp run build 87 + pnpm --filter @kaneo/mcp run start 88 + pnpm --filter @kaneo/mcp run test 89 + ``` 90 + 91 + Or run it from the package directory: 92 + 93 + ```bash 94 + pnpm -C packages/mcp run build 95 + ``` 96 + 97 + The CLI entry points to `./dist/index.js`. Use `npx @kaneo/mcp` or `kaneo-mcp` after a global install so your IDE config points at the resolved path. 98 + 99 + ## Authentication 100 + 101 + On the first tool call that needs Kaneo, the server: 102 + 103 + 1. Requests a device code from `POST /api/auth/device/code` 104 + 2. Prints the verification URL and user code to `stderr` 105 + 3. Tries to open the browser 106 + 4. Polls `POST /api/auth/device/token` until approved 107 + 5. Stores the access token at `~/.config/kaneo-mcp/credentials.json` with mode `0600` 108 + 109 + ## Tools 110 + 111 + - Session: `whoami`, `list_workspaces` 112 + - Projects: `list_projects`, `get_project`, `create_project`, `update_project` 113 + - Tasks: `list_tasks`, `get_task`, `create_task`, `update_task`, `move_task`, `update_task_status` 114 + - Comments: `list_task_comments`, `create_task_comment` 115 + - Labels: `list_workspace_labels`, `create_label`, `attach_label_to_task`, `detach_label_from_task`
+53
packages/mcp/package.json
··· 1 + { 2 + "name": "@kaneo/mcp", 3 + "version": "0.1.5", 4 + "description": "Model Context Protocol (stdio) server for Kaneo — tasks, projects, labels, and device authorization", 5 + "license": "MIT", 6 + "homepage": "https://github.com/usekaneo/kaneo/", 7 + "repository": { 8 + "type": "git", 9 + "url": "https://github.com/usekaneo/kaneo/", 10 + "directory": "packages/mcp" 11 + }, 12 + "type": "module", 13 + "bin": { 14 + "kaneo-mcp": "dist/index.js" 15 + }, 16 + "publishConfig": { 17 + "access": "public" 18 + }, 19 + "files": [ 20 + "dist", 21 + "README.md", 22 + "LICENSE" 23 + ], 24 + "keywords": [ 25 + "mcp", 26 + "model-context-protocol", 27 + "kaneo", 28 + "stdio" 29 + ], 30 + "scripts": { 31 + "build": "tsc -p tsconfig.json", 32 + "prepublishOnly": "pnpm run build", 33 + "dev": "tsx watch src/index.ts", 34 + "lint": "biome check --write .", 35 + "start": "node dist/index.js", 36 + "test": "vitest run --config vitest.config.ts", 37 + "test:watch": "vitest --config vitest.config.ts" 38 + }, 39 + "dependencies": { 40 + "@modelcontextprotocol/sdk": "^1.26.0", 41 + "open": "^11.0.0", 42 + "prompts": "^2.4.2", 43 + "zod": "^4.3.6" 44 + }, 45 + "devDependencies": { 46 + "@kaneo/typescript-config": "workspace:*", 47 + "@types/node": "^25.3.5", 48 + "@types/prompts": "^2.4.9", 49 + "tsx": "^4.21.0", 50 + "typescript": "^5.9.3", 51 + "vitest": "^4.1.2" 52 + } 53 + }
+144
packages/mcp/src/auth/auth-service.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { AuthService } from "./auth-service.js"; 3 + 4 + const { 5 + loadCredentialsMock, 6 + clearCredentialsMock, 7 + saveCredentialsMock, 8 + requestDeviceCodeMock, 9 + pollDeviceAccessTokenMock, 10 + openMock, 11 + } = vi.hoisted(() => ({ 12 + loadCredentialsMock: vi.fn(), 13 + clearCredentialsMock: vi.fn(), 14 + saveCredentialsMock: vi.fn(), 15 + requestDeviceCodeMock: vi.fn(), 16 + pollDeviceAccessTokenMock: vi.fn(), 17 + openMock: vi.fn(), 18 + })); 19 + 20 + vi.mock("./token-store.js", () => ({ 21 + loadCredentials: loadCredentialsMock, 22 + clearCredentials: clearCredentialsMock, 23 + saveCredentials: saveCredentialsMock, 24 + })); 25 + 26 + vi.mock("./device-flow.js", () => ({ 27 + requestDeviceCode: requestDeviceCodeMock, 28 + pollDeviceAccessToken: pollDeviceAccessTokenMock, 29 + })); 30 + 31 + vi.mock("open", () => ({ 32 + default: openMock, 33 + })); 34 + 35 + describe("AuthService", () => { 36 + const originalFetch = globalThis.fetch; 37 + 38 + beforeEach(() => { 39 + loadCredentialsMock.mockReset(); 40 + clearCredentialsMock.mockReset(); 41 + saveCredentialsMock.mockReset(); 42 + requestDeviceCodeMock.mockReset(); 43 + pollDeviceAccessTokenMock.mockReset(); 44 + openMock.mockReset(); 45 + }); 46 + 47 + afterEach(() => { 48 + globalThis.fetch = originalFetch; 49 + vi.restoreAllMocks(); 50 + }); 51 + 52 + it("reuses the cached token when validation succeeds", async () => { 53 + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); 54 + loadCredentialsMock.mockResolvedValue({ 55 + version: 1, 56 + baseUrl: "https://api.example.com", 57 + clientId: "kaneo-mcp", 58 + accessToken: "cached-token", 59 + }); 60 + const fetchMock = vi.fn().mockResolvedValue( 61 + new Response(JSON.stringify({ user: { id: "user-1" } }), { 62 + status: 200, 63 + }), 64 + ); 65 + globalThis.fetch = fetchMock as typeof fetch; 66 + 67 + const service = new AuthService({ 68 + baseUrl: "https://api.example.com", 69 + clientId: "kaneo-mcp", 70 + }); 71 + 72 + await expect(service.getAccessToken()).resolves.toBe("cached-token"); 73 + expect(timeoutSpy).toHaveBeenCalledWith(10_000); 74 + expect(fetchMock).toHaveBeenCalledTimes(1); 75 + const requestInit = fetchMock.mock.calls[0]?.[1] as 76 + | { signal?: AbortSignal } 77 + | undefined; 78 + expect(requestInit?.signal).toBeInstanceOf(AbortSignal); 79 + expect(clearCredentialsMock).not.toHaveBeenCalled(); 80 + expect(requestDeviceCodeMock).not.toHaveBeenCalled(); 81 + }); 82 + 83 + it("keeps the cached token when validation cannot confirm validity", async () => { 84 + loadCredentialsMock.mockResolvedValue({ 85 + version: 1, 86 + baseUrl: "https://api.example.com", 87 + clientId: "kaneo-mcp", 88 + accessToken: "cached-token", 89 + }); 90 + globalThis.fetch = vi 91 + .fn() 92 + .mockRejectedValue( 93 + new Error("temporary network failure"), 94 + ) as typeof fetch; 95 + 96 + const service = new AuthService({ 97 + baseUrl: "https://api.example.com", 98 + clientId: "kaneo-mcp", 99 + }); 100 + 101 + await expect(service.getAccessToken()).resolves.toBe("cached-token"); 102 + expect(clearCredentialsMock).not.toHaveBeenCalled(); 103 + expect(requestDeviceCodeMock).not.toHaveBeenCalled(); 104 + }); 105 + 106 + it("clears the cached token and starts device auth after a 401", async () => { 107 + loadCredentialsMock.mockResolvedValue({ 108 + version: 1, 109 + baseUrl: "https://api.example.com", 110 + clientId: "kaneo-mcp", 111 + accessToken: "expired-token", 112 + }); 113 + globalThis.fetch = vi.fn().mockResolvedValue( 114 + new Response(JSON.stringify({ message: "Unauthorized" }), { 115 + status: 401, 116 + }), 117 + ) as typeof fetch; 118 + requestDeviceCodeMock.mockResolvedValue({ 119 + device_code: "device-code", 120 + user_code: "ABCD-EFGH", 121 + verification_uri: "https://verify.example.com", 122 + interval: 5, 123 + }); 124 + pollDeviceAccessTokenMock.mockResolvedValue("fresh-token"); 125 + 126 + const service = new AuthService({ 127 + baseUrl: "https://api.example.com", 128 + clientId: "kaneo-mcp", 129 + }); 130 + 131 + await expect(service.getAccessToken()).resolves.toBe("fresh-token"); 132 + expect(clearCredentialsMock).toHaveBeenCalledTimes(1); 133 + expect(requestDeviceCodeMock).toHaveBeenCalledWith( 134 + "https://api.example.com", 135 + "kaneo-mcp", 136 + ); 137 + expect(saveCredentialsMock).toHaveBeenCalledWith({ 138 + version: 1, 139 + baseUrl: "https://api.example.com", 140 + clientId: "kaneo-mcp", 141 + accessToken: "fresh-token", 142 + }); 143 + }); 144 + });
+122
packages/mcp/src/auth/auth-service.ts
··· 1 + import open from "open"; 2 + import { pollDeviceAccessToken, requestDeviceCode } from "./device-flow.js"; 3 + import { 4 + clearCredentials, 5 + loadCredentials, 6 + type StoredCredentials, 7 + saveCredentials, 8 + } from "./token-store.js"; 9 + 10 + export type AuthServiceOptions = { 11 + baseUrl: string; 12 + clientId: string; 13 + }; 14 + 15 + type TokenValidationResult = "valid" | "invalid" | "unknown"; 16 + 17 + export class AuthService { 18 + readonly baseUrl: string; 19 + readonly clientId: string; 20 + private activeGetAccessTokenPromise?: Promise<string>; 21 + 22 + constructor(options: AuthServiceOptions) { 23 + this.baseUrl = options.baseUrl; 24 + this.clientId = options.clientId; 25 + } 26 + 27 + async clearToken(): Promise<void> { 28 + await clearCredentials(); 29 + } 30 + 31 + private async validateAccessToken( 32 + token: string, 33 + ): Promise<TokenValidationResult> { 34 + try { 35 + const signal = AbortSignal.timeout(10_000); 36 + const res = await fetch(`${this.baseUrl}/api/auth/get-session`, { 37 + headers: { Authorization: `Bearer ${token}` }, 38 + signal, 39 + }); 40 + if (res.status === 401) { 41 + return "invalid"; 42 + } 43 + if (!res.ok) { 44 + return "unknown"; 45 + } 46 + const data = (await res.json().catch(() => null)) as { 47 + user?: { id?: string }; 48 + } | null; 49 + return data?.user?.id ? "valid" : "unknown"; 50 + } catch { 51 + return "unknown"; 52 + } 53 + } 54 + 55 + private log(msg: string): void { 56 + console.error(`[kaneo-mcp] ${msg}`); 57 + } 58 + 59 + /** 60 + * Returns a valid access token, running the device authorization flow if needed. 61 + */ 62 + async getAccessToken(): Promise<string> { 63 + if (this.activeGetAccessTokenPromise) { 64 + return await this.activeGetAccessTokenPromise; 65 + } 66 + 67 + this.activeGetAccessTokenPromise = (async () => { 68 + const cached = await loadCredentials(); 69 + if ( 70 + cached && 71 + cached.baseUrl === this.baseUrl && 72 + cached.clientId === this.clientId && 73 + cached.accessToken 74 + ) { 75 + const validation = await this.validateAccessToken(cached.accessToken); 76 + // Fail-open for "unknown": validateAccessToken returns "unknown" on transient HTTP/network 77 + // errors or ambiguous responses. Treating "unknown" like "valid" avoids clearToken() and a full 78 + // device re-auth so flaky connectivity does not wipe cached.accessToken; only "invalid" forces 79 + // a fresh login. Tests should cover both unknown and invalid paths. 80 + if (validation === "valid" || validation === "unknown") { 81 + return cached.accessToken; 82 + } 83 + await this.clearToken(); 84 + } 85 + 86 + const code = await requestDeviceCode(this.baseUrl, this.clientId); 87 + const verifyUrl = code.verification_uri_complete || code.verification_uri; 88 + this.log( 89 + `Open ${verifyUrl} and approve this device. User code: ${code.user_code}`, 90 + ); 91 + 92 + try { 93 + await open(verifyUrl); 94 + } catch { 95 + this.log("Could not open a browser automatically; use the URL above."); 96 + } 97 + 98 + const accessToken = await pollDeviceAccessToken( 99 + this.baseUrl, 100 + this.clientId, 101 + code.device_code, 102 + code.interval, 103 + { log: (m) => this.log(m) }, 104 + ); 105 + 106 + const toStore: StoredCredentials = { 107 + version: 1, 108 + baseUrl: this.baseUrl, 109 + clientId: this.clientId, 110 + accessToken, 111 + }; 112 + await saveCredentials(toStore); 113 + return accessToken; 114 + })(); 115 + 116 + try { 117 + return await this.activeGetAccessTokenPromise; 118 + } finally { 119 + this.activeGetAccessTokenPromise = undefined; 120 + } 121 + } 122 + }
+145
packages/mcp/src/auth/device-flow.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { pollDeviceAccessToken, requestDeviceCode } from "./device-flow.js"; 3 + 4 + describe("requestDeviceCode", () => { 5 + const originalFetch = globalThis.fetch; 6 + 7 + afterEach(() => { 8 + globalThis.fetch = originalFetch; 9 + }); 10 + 11 + it("posts the client id and returns the parsed device code response", async () => { 12 + const fetchMock = vi.fn().mockResolvedValue( 13 + new Response( 14 + JSON.stringify({ 15 + device_code: "device-code", 16 + user_code: "user-code", 17 + verification_uri: "https://verify.example.com", 18 + interval: 5, 19 + expires_in: 1800, 20 + }), 21 + { status: 200 }, 22 + ), 23 + ); 24 + globalThis.fetch = fetchMock as typeof fetch; 25 + 26 + const result = await requestDeviceCode( 27 + "https://api.example.com", 28 + "kaneo-mcp", 29 + ); 30 + 31 + expect(result.device_code).toBe("device-code"); 32 + expect(fetchMock).toHaveBeenCalledWith( 33 + "https://api.example.com/api/auth/device/code", 34 + expect.objectContaining({ 35 + method: "POST", 36 + headers: { "Content-Type": "application/json" }, 37 + body: JSON.stringify({ client_id: "kaneo-mcp" }), 38 + signal: expect.any(AbortSignal), 39 + }), 40 + ); 41 + }); 42 + 43 + it("throws when the response body is missing the device code", async () => { 44 + globalThis.fetch = vi.fn().mockResolvedValue( 45 + new Response(JSON.stringify({ user_code: "missing-device-code" }), { 46 + status: 200, 47 + }), 48 + ) as typeof fetch; 49 + 50 + await expect( 51 + requestDeviceCode("https://api.example.com", "kaneo-mcp"), 52 + ).rejects.toThrow(/unexpected response/); 53 + }); 54 + }); 55 + 56 + describe("pollDeviceAccessToken", () => { 57 + const originalFetch = globalThis.fetch; 58 + 59 + beforeEach(() => { 60 + vi.useFakeTimers(); 61 + }); 62 + 63 + afterEach(() => { 64 + globalThis.fetch = originalFetch; 65 + vi.useRealTimers(); 66 + vi.restoreAllMocks(); 67 + }); 68 + 69 + it("keeps polling through authorization_pending until an access token is returned", async () => { 70 + const log = vi.fn(); 71 + const fetchMock = vi 72 + .fn() 73 + .mockResolvedValueOnce( 74 + new Response(JSON.stringify({ error: "authorization_pending" }), { 75 + status: 400, 76 + }), 77 + ) 78 + .mockResolvedValueOnce( 79 + new Response(JSON.stringify({ access_token: "token-123" }), { 80 + status: 200, 81 + }), 82 + ); 83 + globalThis.fetch = fetchMock as typeof fetch; 84 + 85 + const promise = pollDeviceAccessToken( 86 + "https://api.example.com", 87 + "kaneo-mcp", 88 + "device-code", 89 + 1, 90 + { log }, 91 + ); 92 + 93 + await vi.runAllTimersAsync(); 94 + 95 + await expect(promise).resolves.toBe("token-123"); 96 + expect(fetchMock).toHaveBeenCalledTimes(2); 97 + expect(log).toHaveBeenCalledWith("Waiting for device approval…"); 98 + }); 99 + 100 + it("increases the polling interval after slow_down", async () => { 101 + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); 102 + const fetchMock = vi 103 + .fn() 104 + .mockResolvedValueOnce( 105 + new Response(JSON.stringify({ error: "slow_down" }), { 106 + status: 400, 107 + }), 108 + ) 109 + .mockResolvedValueOnce( 110 + new Response(JSON.stringify({ access_token: "token-123" }), { 111 + status: 200, 112 + }), 113 + ); 114 + globalThis.fetch = fetchMock as typeof fetch; 115 + 116 + const promise = pollDeviceAccessToken( 117 + "https://api.example.com", 118 + "kaneo-mcp", 119 + "device-code", 120 + 1, 121 + ); 122 + 123 + await vi.runAllTimersAsync(); 124 + 125 + await expect(promise).resolves.toBe("token-123"); 126 + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 6000); 127 + }); 128 + 129 + it("throws a helpful error when authorization is denied", async () => { 130 + globalThis.fetch = vi.fn().mockResolvedValue( 131 + new Response(JSON.stringify({ error: "access_denied" }), { 132 + status: 400, 133 + }), 134 + ) as typeof fetch; 135 + 136 + await expect( 137 + pollDeviceAccessToken( 138 + "https://api.example.com", 139 + "kaneo-mcp", 140 + "device-code", 141 + 1, 142 + ), 143 + ).rejects.toThrow("Device authorization was denied."); 144 + }); 145 + });
+194
packages/mcp/src/auth/device-flow.ts
··· 1 + export type DeviceCodeResponse = { 2 + device_code: string; 3 + user_code: string; 4 + verification_uri: string; 5 + verification_uri_complete?: string; 6 + interval: number; 7 + expires_in: number; 8 + }; 9 + 10 + export type DeviceTokenErrorBody = { 11 + error?: string; 12 + error_description?: string; 13 + }; 14 + 15 + const REQUEST_TIMEOUT_MS = 10_000; 16 + 17 + function sleep(ms: number): Promise<void> { 18 + return new Promise((r) => setTimeout(r, ms)); 19 + } 20 + 21 + function isAbortError(err: unknown): boolean { 22 + return err instanceof DOMException 23 + ? err.name === "AbortError" 24 + : err instanceof Error && err.name === "AbortError"; 25 + } 26 + 27 + function isObjectRecord(value: unknown): value is Record<string, unknown> { 28 + return value !== null && typeof value === "object"; 29 + } 30 + 31 + async function fetchWithTimeout( 32 + input: string, 33 + init: RequestInit, 34 + timeoutMs = REQUEST_TIMEOUT_MS, 35 + ): Promise<Response> { 36 + const controller = new AbortController(); 37 + const timeout = setTimeout(() => controller.abort(), timeoutMs); 38 + try { 39 + return await fetch(input, { ...init, signal: controller.signal }); 40 + } finally { 41 + clearTimeout(timeout); 42 + } 43 + } 44 + 45 + export async function requestDeviceCode( 46 + baseUrl: string, 47 + clientId: string, 48 + ): Promise<DeviceCodeResponse> { 49 + let res: Response; 50 + try { 51 + res = await fetchWithTimeout(`${baseUrl}/api/auth/device/code`, { 52 + method: "POST", 53 + headers: { "Content-Type": "application/json" }, 54 + body: JSON.stringify({ client_id: clientId }), 55 + }); 56 + } catch (err) { 57 + if (isAbortError(err)) { 58 + throw new Error("device/code request timed out after 10s."); 59 + } 60 + throw err; 61 + } 62 + const parsedBody: unknown = await res.json().catch(() => ({})); 63 + if (!isObjectRecord(parsedBody)) { 64 + throw new Error( 65 + `device/code: unexpected response ${JSON.stringify(parsedBody)}`, 66 + ); 67 + } 68 + const body = parsedBody; 69 + if (!res.ok) { 70 + throw new Error( 71 + `device/code failed (${res.status}): ${JSON.stringify(body)}`, 72 + ); 73 + } 74 + if (typeof body.device_code !== "string") { 75 + throw new Error(`device/code: unexpected response ${JSON.stringify(body)}`); 76 + } 77 + if ( 78 + typeof body.user_code !== "string" || 79 + typeof body.verification_uri !== "string" 80 + ) { 81 + throw new Error( 82 + `device/code: missing user_code or verification_uri ${JSON.stringify(body)}`, 83 + ); 84 + } 85 + const interval = toFiniteNumber(body.interval); 86 + const expiresIn = toFiniteNumber(body.expires_in); 87 + if (interval === undefined || expiresIn === undefined) { 88 + throw new Error( 89 + `device/code: invalid interval or expires_in ${JSON.stringify(body)}`, 90 + ); 91 + } 92 + return { 93 + ...body, 94 + interval, 95 + expires_in: expiresIn, 96 + } as DeviceCodeResponse; 97 + } 98 + 99 + function toFiniteNumber(v: unknown): number | undefined { 100 + if (typeof v === "number" && Number.isFinite(v)) { 101 + return v; 102 + } 103 + if (typeof v === "string" && v.trim() !== "") { 104 + const n = Number(v); 105 + if (Number.isFinite(n)) { 106 + return n; 107 + } 108 + } 109 + return undefined; 110 + } 111 + 112 + /** 113 + * Polls `/api/auth/device/token` until success or terminal error. 114 + * First attempt is immediate; subsequent attempts wait `interval` seconds (increased on `slow_down`). 115 + */ 116 + export async function pollDeviceAccessToken( 117 + baseUrl: string, 118 + clientId: string, 119 + deviceCode: string, 120 + initialIntervalSec: number, 121 + options?: { maxWaitMs?: number; log?: (msg: string) => void }, 122 + ): Promise<string> { 123 + const maxWait = options?.maxWaitMs ?? 30 * 60 * 1000; 124 + const log = options?.log ?? (() => {}); 125 + const started = Date.now(); 126 + let intervalMs = Math.max(1000, initialIntervalSec * 1000); 127 + 128 + for (let attempt = 0; Date.now() - started < maxWait; attempt++) { 129 + if (attempt > 0) { 130 + await sleep(intervalMs); 131 + if (Date.now() - started >= maxWait) { 132 + break; 133 + } 134 + } 135 + 136 + let res: Response; 137 + try { 138 + res = await fetchWithTimeout(`${baseUrl}/api/auth/device/token`, { 139 + method: "POST", 140 + headers: { "Content-Type": "application/json" }, 141 + body: JSON.stringify({ 142 + grant_type: "urn:ietf:params:oauth:grant-type:device_code", 143 + device_code: deviceCode, 144 + client_id: clientId, 145 + }), 146 + }); 147 + } catch (err) { 148 + if (isAbortError(err)) { 149 + log("Device token poll request timed out; retrying."); 150 + continue; 151 + } 152 + throw err; 153 + } 154 + 155 + if (Date.now() - started >= maxWait) { 156 + throw new Error("Device authorization timed out waiting for approval."); 157 + } 158 + 159 + const parsedBody: unknown = await res.json().catch(() => ({})); 160 + if (!isObjectRecord(parsedBody)) { 161 + throw new Error( 162 + `device/token failed (${res.status}): ${JSON.stringify(parsedBody)}`, 163 + ); 164 + } 165 + const body = parsedBody; 166 + 167 + if (res.ok && typeof body.access_token === "string") { 168 + return body.access_token; 169 + } 170 + 171 + const err = typeof body.error === "string" ? body.error : undefined; 172 + if (err === "authorization_pending") { 173 + log("Waiting for device approval…"); 174 + continue; 175 + } 176 + if (err === "slow_down") { 177 + intervalMs += 5000; 178 + log(`Rate limited (slow_down); polling every ${intervalMs / 1000}s`); 179 + continue; 180 + } 181 + if (err === "access_denied") { 182 + throw new Error("Device authorization was denied."); 183 + } 184 + if (err === "expired_token") { 185 + throw new Error("Device code expired; start login again."); 186 + } 187 + 188 + throw new Error( 189 + `device/token failed (${res.status}): ${JSON.stringify(body)}`, 190 + ); 191 + } 192 + 193 + throw new Error("Device authorization timed out waiting for approval."); 194 + }
+63
packages/mcp/src/auth/token-store.ts
··· 1 + import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; 2 + import { homedir } from "node:os"; 3 + import path from "node:path"; 4 + 5 + export type StoredCredentials = { 6 + version: 1; 7 + baseUrl: string; 8 + clientId: string; 9 + accessToken: string; 10 + }; 11 + 12 + const FILE_MODE = 0o600; 13 + const DIR_MODE = 0o700; 14 + 15 + function configDir(): string { 16 + const base = 17 + process.env.XDG_CONFIG_HOME?.trim() || path.join(homedir(), ".config"); 18 + return path.join(base, "kaneo-mcp"); 19 + } 20 + 21 + export function credentialsPath(): string { 22 + return path.join(configDir(), "credentials.json"); 23 + } 24 + 25 + export async function loadCredentials(): Promise<StoredCredentials | null> { 26 + try { 27 + const raw = await readFile(credentialsPath(), "utf8"); 28 + const parsed = JSON.parse(raw) as StoredCredentials; 29 + if ( 30 + parsed?.version === 1 && 31 + typeof parsed.baseUrl === "string" && 32 + typeof parsed.clientId === "string" && 33 + typeof parsed.accessToken === "string" 34 + ) { 35 + return parsed; 36 + } 37 + return null; 38 + } catch { 39 + return null; 40 + } 41 + } 42 + 43 + export async function saveCredentials(data: StoredCredentials): Promise<void> { 44 + const dir = configDir(); 45 + await mkdir(dir, { recursive: true, mode: DIR_MODE }); 46 + const file = credentialsPath(); 47 + await writeFile(file, `${JSON.stringify(data, null, 2)}\n`, { 48 + mode: FILE_MODE, 49 + }); 50 + try { 51 + await chmod(file, FILE_MODE); 52 + } catch { 53 + /* ignore chmod failures on some FS */ 54 + } 55 + } 56 + 57 + export async function clearCredentials(): Promise<void> { 58 + try { 59 + await unlink(credentialsPath()); 60 + } catch { 61 + /* noop */ 62 + } 63 + }
+59
packages/mcp/src/cli.ts
··· 1 + import { stdin as input } from "node:process"; 2 + import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 + import { runInstall } from "./install/index.js"; 4 + import { createMcpServer } from "./server.js"; 5 + 6 + const SERVE_ALIASES = new Set(["serve", "server", "stdio", "run"]); 7 + 8 + export async function runCli(): Promise<void> { 9 + const argv = process.argv.slice(2); 10 + if (argv[0] === "-h" || argv[0] === "--help" || argv[0] === "help") { 11 + printMainHelp(); 12 + return; 13 + } 14 + if (argv[0] === "install" || argv[0] === "setup") { 15 + await runInstall(argv.slice(1)); 16 + return; 17 + } 18 + if (argv[0] !== undefined) { 19 + if (SERVE_ALIASES.has(argv[0])) { 20 + await startMcpServer(); 21 + return; 22 + } 23 + console.error(`Unknown command: ${argv[0]}`); 24 + printMainHelp(); 25 + process.exitCode = 1; 26 + return; 27 + } 28 + if (input.isTTY) { 29 + await runInstall([]); 30 + return; 31 + } 32 + await startMcpServer(); 33 + } 34 + 35 + async function startMcpServer(): Promise<void> { 36 + const server = createMcpServer(); 37 + const transport = new StdioServerTransport(); 38 + await server.connect(transport); 39 + } 40 + 41 + function printMainHelp(): void { 42 + console.log(`kaneo-mcp — Kaneo MCP server (stdio transport) 43 + 44 + Usage: 45 + npx @kaneo/mcp Interactive installer (terminal only; no global install) 46 + kaneo-mcp Same: installer in a TTY; MCP server when stdin is piped 47 + kaneo-mcp install Register in Cursor / Claude / a custom path (explicit) 48 + kaneo-mcp serve Run the MCP server (use from a terminal to test stdio) 49 + kaneo-mcp help 50 + 51 + Options: 52 + -h, --help Show this help 53 + 54 + MCP clients (Cursor, etc.) run this process with a pipe, so they get the server. 55 + In a normal terminal, the default is the interactive installer. 56 + 57 + See also: kaneo-mcp install --help 58 + `); 59 + }
+7
packages/mcp/src/index.ts
··· 1 + #!/usr/bin/env node 2 + import { runCli } from "./cli.js"; 3 + 4 + runCli().catch((err: unknown) => { 5 + console.error(err); 6 + process.exit(1); 7 + });
+350
packages/mcp/src/install/index.ts
··· 1 + import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; 2 + import { dirname } from "node:path"; 3 + import { stdin as input, stdout as output } from "node:process"; 4 + import { type McpServerEntry, mergeMcpServerEntry } from "./merge-config.js"; 5 + import { resolvePackageEntryPath } from "./resolve-entry.js"; 6 + import { 7 + INSTALL_TARGETS, 8 + type InstallTargetId, 9 + resolveTargetConfigPath, 10 + validateCustomConfigPathInput, 11 + } from "./targets.js"; 12 + import { 13 + promptConfirmOverwrite, 14 + promptCustomConfigPath, 15 + promptTargetSelect, 16 + } from "./wizard.js"; 17 + 18 + /** True if `v` looks like a CLI flag (`--long` or single-letter `-y`), not a value like `-my-server`. */ 19 + function isFlagLikeToken(v: string): boolean { 20 + if (v.startsWith("--")) { 21 + return true; 22 + } 23 + return /^-[A-Za-z]$/.test(v); 24 + } 25 + 26 + export type ParsedInstallArgs = { 27 + target?: string; 28 + output?: string; 29 + name: string; 30 + yes: boolean; 31 + apiUrl?: string; 32 + projectDir: string; 33 + help: boolean; 34 + }; 35 + 36 + export function parseInstallArgs(argv: string[]): ParsedInstallArgs { 37 + let target: string | undefined; 38 + let output: string | undefined; 39 + let name = "kaneo"; 40 + let yes = false; 41 + let apiUrl: string | undefined; 42 + let projectDir = process.cwd(); 43 + let help = false; 44 + 45 + for (let i = 0; i < argv.length; i++) { 46 + const a = argv[i]; 47 + if (a === undefined) { 48 + continue; 49 + } 50 + if (a === "-h" || a === "--help") { 51 + help = true; 52 + continue; 53 + } 54 + if (a === "-y" || a === "--yes") { 55 + yes = true; 56 + continue; 57 + } 58 + if (a === "--target") { 59 + const v = argv[i + 1]; 60 + if (!v || isFlagLikeToken(v)) { 61 + throw new Error("--target requires a value"); 62 + } 63 + target = v; 64 + i++; 65 + continue; 66 + } 67 + if (a.startsWith("--target=")) { 68 + target = a.slice("--target=".length); 69 + continue; 70 + } 71 + if (a === "--output") { 72 + const v = argv[i + 1]; 73 + if (!v || isFlagLikeToken(v)) { 74 + throw new Error("--output requires a value"); 75 + } 76 + output = v; 77 + i++; 78 + continue; 79 + } 80 + if (a.startsWith("--output=")) { 81 + output = a.slice("--output=".length); 82 + continue; 83 + } 84 + if (a === "--name") { 85 + const v = argv[i + 1]; 86 + if (!v || isFlagLikeToken(v)) { 87 + throw new Error("--name requires a value"); 88 + } 89 + name = v; 90 + i++; 91 + continue; 92 + } 93 + if (a.startsWith("--name=")) { 94 + name = a.slice("--name=".length); 95 + continue; 96 + } 97 + if (a === "--api-url") { 98 + const v = argv[i + 1]; 99 + if (!v || isFlagLikeToken(v)) { 100 + throw new Error("--api-url requires a value"); 101 + } 102 + apiUrl = v; 103 + i++; 104 + continue; 105 + } 106 + if (a.startsWith("--api-url=")) { 107 + apiUrl = a.slice("--api-url=".length); 108 + continue; 109 + } 110 + if (a === "--project-dir") { 111 + const v = argv[i + 1]; 112 + if (!v || isFlagLikeToken(v)) { 113 + throw new Error("--project-dir requires a value"); 114 + } 115 + projectDir = v; 116 + i++; 117 + continue; 118 + } 119 + if (a.startsWith("--project-dir=")) { 120 + projectDir = a.slice("--project-dir=".length); 121 + continue; 122 + } 123 + throw new Error(`Unknown option: ${a}`); 124 + } 125 + 126 + return { target, output, name, yes, apiUrl, projectDir, help }; 127 + } 128 + 129 + function hasExistingServerEntry( 130 + jsonText: string | null, 131 + serverName: string, 132 + ): boolean { 133 + if (!jsonText) { 134 + return false; 135 + } 136 + try { 137 + const root = JSON.parse(jsonText) as unknown; 138 + if (!root || typeof root !== "object" || Array.isArray(root)) { 139 + return false; 140 + } 141 + const m = (root as { mcpServers?: unknown }).mcpServers; 142 + if (!m || typeof m !== "object" || Array.isArray(m)) { 143 + return false; 144 + } 145 + return Object.hasOwn(m as object, serverName); 146 + } catch { 147 + return false; 148 + } 149 + } 150 + 151 + const VALID_TARGETS: readonly InstallTargetId[] = INSTALL_TARGETS.map( 152 + (t) => t.id, 153 + ); 154 + 155 + function isInstallTargetId(s: string): s is InstallTargetId { 156 + return (VALID_TARGETS as readonly string[]).includes(s); 157 + } 158 + 159 + export async function runInstall(argv: string[]): Promise<void> { 160 + let parsed: ParsedInstallArgs; 161 + try { 162 + parsed = parseInstallArgs(argv); 163 + } catch (e) { 164 + console.error(e instanceof Error ? e.message : String(e)); 165 + printInstallHelp(); 166 + process.exitCode = 1; 167 + return; 168 + } 169 + 170 + if (parsed.help) { 171 + printInstallHelp(); 172 + return; 173 + } 174 + 175 + if (parsed.target !== undefined && !isInstallTargetId(parsed.target)) { 176 + console.error(`Invalid --target. Use one of: ${VALID_TARGETS.join(", ")}`); 177 + process.exitCode = 1; 178 + return; 179 + } 180 + 181 + if (!parsed.name.trim()) { 182 + console.error("--name must be a non-empty string."); 183 + process.exitCode = 1; 184 + return; 185 + } 186 + 187 + let targetIds = parsed.target 188 + ? [parsed.target as InstallTargetId] 189 + : undefined; 190 + const needsInteractive = 191 + input.isTTY && output.isTTY && !parsed.yes && targetIds === undefined; 192 + 193 + if (needsInteractive) { 194 + targetIds = await promptTargetSelect(); 195 + } else if (targetIds === undefined) { 196 + console.error( 197 + "Non-interactive mode: specify --target (e.g. --target cursor-user) and use -y to confirm.", 198 + ); 199 + printInstallHelp(); 200 + process.exitCode = 1; 201 + return; 202 + } 203 + 204 + let customPath = parsed.output?.trim(); 205 + if (targetIds.includes("custom")) { 206 + if (!customPath) { 207 + if (input.isTTY && output.isTTY) { 208 + customPath = await promptCustomConfigPath(); 209 + } else { 210 + console.error("Custom target requires --output <path>"); 211 + process.exitCode = 1; 212 + return; 213 + } 214 + } else { 215 + const validation = validateCustomConfigPathInput(customPath); 216 + if (!validation.ok) { 217 + console.error(`Invalid --output: ${validation.message}`); 218 + process.exitCode = 1; 219 + return; 220 + } 221 + customPath = validation.path; 222 + } 223 + } 224 + 225 + const entryPath = resolvePackageEntryPath(); 226 + const env = 227 + parsed.apiUrl !== undefined && parsed.apiUrl.length > 0 228 + ? { KANEO_API_URL: parsed.apiUrl } 229 + : undefined; 230 + 231 + const serverConfig: McpServerEntry = { 232 + command: process.execPath, 233 + args: [entryPath], 234 + ...(env ? { env } : {}), 235 + }; 236 + 237 + const writtenPaths: string[] = []; 238 + const pending: Array<{ configPath: string; existingText: string | null }> = 239 + []; 240 + 241 + for (const targetId of targetIds) { 242 + const configPath = resolveTargetConfigPath(targetId, { 243 + cwd: parsed.projectDir, 244 + customPath, 245 + }); 246 + 247 + let existingText: string | null = null; 248 + try { 249 + existingText = await readFile(configPath, "utf8"); 250 + } catch (err: unknown) { 251 + const code = 252 + err && 253 + typeof err === "object" && 254 + "code" in err && 255 + typeof (err as NodeJS.ErrnoException).code === "string" 256 + ? (err as NodeJS.ErrnoException).code 257 + : undefined; 258 + if (code === "ENOENT") { 259 + existingText = null; 260 + } else { 261 + throw err; 262 + } 263 + } 264 + 265 + const already = hasExistingServerEntry(existingText, parsed.name); 266 + 267 + if (already && !parsed.yes) { 268 + if (input.isTTY && output.isTTY) { 269 + const ok = await promptConfirmOverwrite(parsed.name, configPath); 270 + if (!ok) { 271 + console.log(`Skipped:\n ${configPath}`); 272 + continue; 273 + } 274 + } else { 275 + console.error( 276 + `Entry "${parsed.name}" already exists. Pass -y to overwrite.`, 277 + ); 278 + process.exitCode = 1; 279 + return; 280 + } 281 + } 282 + 283 + pending.push({ configPath, existingText }); 284 + } 285 + 286 + const mergedWrites: Array<{ configPath: string; merged: string }> = []; 287 + for (const p of pending) { 288 + let merged: string; 289 + try { 290 + merged = mergeMcpServerEntry(p.existingText, parsed.name, serverConfig); 291 + } catch (err: unknown) { 292 + const message = err instanceof Error ? err.message : String(err); 293 + const stack = err instanceof Error ? err.stack : undefined; 294 + console.error( 295 + `Cannot update config at ${p.configPath}: ${message}${stack ? `\n${stack}` : ""}`, 296 + ); 297 + process.exitCode = 1; 298 + return; 299 + } 300 + mergedWrites.push({ configPath: p.configPath, merged }); 301 + } 302 + 303 + for (const w of mergedWrites) { 304 + await mkdir(dirname(w.configPath), { recursive: true }); 305 + await writeFile(w.configPath, w.merged, { encoding: "utf8", mode: 0o600 }); 306 + try { 307 + await chmod(w.configPath, 0o600); 308 + } catch { 309 + /* ignore chmod failures on some FS */ 310 + } 311 + writtenPaths.push(w.configPath); 312 + } 313 + 314 + if (writtenPaths.length === 0) { 315 + console.log("No config files were updated."); 316 + return; 317 + } 318 + 319 + console.log(`Wrote MCP server "${parsed.name}" to:`); 320 + for (const configPath of writtenPaths) { 321 + console.log(` ${configPath}`); 322 + } 323 + console.log("\nRestart your MCP client (or reload the window) if needed."); 324 + } 325 + 326 + function printInstallHelp(): void { 327 + console.log(`kaneo-mcp install — register Kaneo in an MCP client config 328 + 329 + Usage: 330 + kaneo-mcp install [options] 331 + 332 + Without options, runs interactively (pick Cursor / Claude / custom path). 333 + 334 + Options: 335 + --target <id> ${VALID_TARGETS.join(" | ")} 336 + --output <path> Required for --target custom (absolute path to JSON file) 337 + --project-dir <dir> Base directory for cursor-project (default: current dir) 338 + --name <string> MCP server key under mcpServers (default: kaneo) 339 + --api-url <url> Set KANEO_API_URL in the generated entry (optional) 340 + -y, --yes Overwrite existing entry without prompting 341 + -h, --help Show this help 342 + 343 + Examples: 344 + npm install -g @kaneo/mcp 345 + kaneo-mcp install 346 + 347 + kaneo-mcp install --target cursor-user -y 348 + kaneo-mcp install --target custom --output /path/to/mcp.json -y 349 + `); 350 + }
+53
packages/mcp/src/install/merge-config.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { mergeMcpServerEntry } from "./merge-config.js"; 3 + 4 + describe("mergeMcpServerEntry", () => { 5 + it("creates mcpServers when file is empty", () => { 6 + const out = mergeMcpServerEntry(null, "kaneo", { 7 + command: "/usr/bin/node", 8 + args: ["/app/index.js"], 9 + }); 10 + expect(JSON.parse(out)).toEqual({ 11 + mcpServers: { 12 + kaneo: { 13 + command: "/usr/bin/node", 14 + args: ["/app/index.js"], 15 + }, 16 + }, 17 + }); 18 + }); 19 + 20 + it("rejects empty string existing config as invalid JSON", () => { 21 + expect(() => 22 + mergeMcpServerEntry("", "kaneo", { 23 + command: "/usr/bin/node", 24 + args: ["/app/index.js"], 25 + }), 26 + ).toThrow("Existing MCP config is not valid JSON"); 27 + }); 28 + 29 + it("merges without removing other servers or top-level keys", () => { 30 + const existing = JSON.stringify({ 31 + other: true, 32 + mcpServers: { 33 + other: { command: "x", args: ["y"] }, 34 + }, 35 + }); 36 + const out = mergeMcpServerEntry(existing, "kaneo", { 37 + command: "/usr/bin/node", 38 + args: ["/app/index.js"], 39 + env: { KANEO_API_URL: "http://localhost:1337" }, 40 + }); 41 + expect(JSON.parse(out)).toEqual({ 42 + other: true, 43 + mcpServers: { 44 + other: { command: "x", args: ["y"] }, 45 + kaneo: { 46 + command: "/usr/bin/node", 47 + args: ["/app/index.js"], 48 + env: { KANEO_API_URL: "http://localhost:1337" }, 49 + }, 50 + }, 51 + }); 52 + }); 53 + });
+66
packages/mcp/src/install/merge-config.ts
··· 1 + export type McpServerEntry = { 2 + command: string; 3 + args: string[]; 4 + env?: Record<string, string>; 5 + }; 6 + 7 + const RESERVED_MCP_SERVER_KEYS = new Set([ 8 + "__proto__", 9 + "constructor", 10 + "prototype", 11 + ]); 12 + 13 + function isPlainObject(v: unknown): v is Record<string, unknown> { 14 + return typeof v === "object" && v !== null && !Array.isArray(v); 15 + } 16 + 17 + /** 18 + * Merges or replaces `mcpServers[serverName]` and returns formatted JSON. 19 + * Preserves other top-level keys and other MCP server entries. 20 + */ 21 + export function mergeMcpServerEntry( 22 + existingJson: string | null, 23 + serverName: string, 24 + serverConfig: McpServerEntry, 25 + ): string { 26 + if (RESERVED_MCP_SERVER_KEYS.has(serverName)) { 27 + throw new Error( 28 + `Refusing MCP server name "${serverName}" (reserved key; use a different --name).`, 29 + ); 30 + } 31 + 32 + let root: Record<string, unknown> = {}; 33 + if (existingJson !== null) { 34 + let parsed: unknown; 35 + try { 36 + parsed = JSON.parse(existingJson); 37 + } catch { 38 + throw new Error("Existing MCP config is not valid JSON."); 39 + } 40 + if (!isPlainObject(parsed)) { 41 + throw new Error( 42 + "Existing MCP config must be a JSON object (not an array or primitive).", 43 + ); 44 + } 45 + root = { ...parsed }; 46 + } 47 + 48 + const mcpServers = (() => { 49 + const map = Object.create(null) as Record<string, unknown>; 50 + const raw = root.mcpServers; 51 + if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) { 52 + for (const key of Object.keys(raw as object)) { 53 + if (RESERVED_MCP_SERVER_KEYS.has(key)) { 54 + continue; 55 + } 56 + map[key] = (raw as Record<string, unknown>)[key]; 57 + } 58 + } 59 + return map; 60 + })(); 61 + 62 + mcpServers[serverName] = serverConfig; 63 + root.mcpServers = mcpServers; 64 + 65 + return `${JSON.stringify(root, null, 2)}\n`; 66 + }
+62
packages/mcp/src/install/parse-install-args.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { parseInstallArgs } from "./index.js"; 3 + 4 + describe("parseInstallArgs", () => { 5 + it("parses flags", () => { 6 + // Arrange 7 + const argv = [ 8 + "--target", 9 + "cursor-user", 10 + "--name", 11 + "my-kaneo", 12 + "-y", 13 + "--api-url", 14 + "http://example.com", 15 + ]; 16 + const expected = { 17 + target: "cursor-user", 18 + output: undefined, 19 + name: "my-kaneo", 20 + yes: true, 21 + apiUrl: "http://example.com", 22 + projectDir: process.cwd(), 23 + help: false, 24 + }; 25 + 26 + // Act 27 + const result = parseInstallArgs(argv); 28 + 29 + // Assert 30 + expect(result).toEqual(expected); 31 + }); 32 + 33 + it("throws on unknown option", () => { 34 + // Arrange 35 + const argv = ["--nope"]; 36 + // Act 37 + const act = () => parseInstallArgs(argv); 38 + 39 + // Assert 40 + expect(act).toThrow("Unknown option"); 41 + }); 42 + 43 + it("allows --target values that start with '-' when they are not flags", () => { 44 + // Arrange 45 + const argv = ["--target", "-my-server", "-y"]; 46 + const expected = { 47 + target: "-my-server", 48 + output: undefined, 49 + name: "kaneo", 50 + yes: true, 51 + apiUrl: undefined, 52 + projectDir: process.cwd(), 53 + help: false, 54 + }; 55 + 56 + // Act 57 + const result = parseInstallArgs(argv); 58 + 59 + // Assert 60 + expect(result).toEqual(expected); 61 + }); 62 + });
+10
packages/mcp/src/install/resolve-entry.ts
··· 1 + import { dirname, join } from "node:path"; 2 + import { fileURLToPath } from "node:url"; 3 + 4 + /** 5 + * Absolute path to this package's `dist/index.js` (the MCP stdio entry). 6 + * Resolved from `dist/install/resolve-entry.js` at runtime. 7 + */ 8 + export function resolvePackageEntryPath(): string { 9 + return join(dirname(fileURLToPath(import.meta.url)), "../index.js"); 10 + }
+99
packages/mcp/src/install/targets.ts
··· 1 + import { homedir } from "node:os"; 2 + import path, { join } from "node:path"; 3 + 4 + export type InstallTargetId = 5 + | "cursor-user" 6 + | "cursor-project" 7 + | "claude-desktop" 8 + | "custom"; 9 + 10 + export type InstallTarget = { 11 + id: InstallTargetId; 12 + label: string; 13 + description: string; 14 + }; 15 + 16 + /** 17 + * Validates a non-interactive custom MCP config path (same rules as the install wizard). 18 + * Returns the trimmed absolute path on success. 19 + */ 20 + export function validateCustomConfigPathInput( 21 + raw: string, 22 + ): { ok: true; path: string } | { ok: false; message: string } { 23 + const trimmed = raw.trim(); 24 + if (trimmed.length === 0) { 25 + return { ok: false, message: "Path is required" }; 26 + } 27 + if (!path.isAbsolute(trimmed)) { 28 + return { ok: false, message: "Path must be absolute" }; 29 + } 30 + if (!trimmed.toLowerCase().endsWith(".json")) { 31 + return { ok: false, message: "Path must end with .json" }; 32 + } 33 + return { ok: true, path: trimmed }; 34 + } 35 + 36 + export const INSTALL_TARGETS = [ 37 + { 38 + id: "cursor-user", 39 + label: "Cursor (user-wide)", 40 + description: "~/.cursor/mcp.json — available in all projects", 41 + }, 42 + { 43 + id: "cursor-project", 44 + label: "Cursor (this project only)", 45 + description: ".cursor/mcp.json in the current directory", 46 + }, 47 + { 48 + id: "claude-desktop", 49 + label: "Claude Desktop", 50 + description: "claude_desktop_config.json for the Claude app", 51 + }, 52 + { 53 + id: "custom", 54 + label: "Custom file path", 55 + description: "Any JSON file you choose (advanced)", 56 + }, 57 + ] as const satisfies readonly InstallTarget[]; 58 + 59 + export function getClaudeDesktopConfigPath(): string { 60 + const platform = process.platform; 61 + if (platform === "darwin") { 62 + return join( 63 + homedir(), 64 + "Library", 65 + "Application Support", 66 + "Claude", 67 + "claude_desktop_config.json", 68 + ); 69 + } 70 + if (platform === "win32") { 71 + const appData = process.env.APPDATA; 72 + if (!appData) { 73 + throw new Error("APPDATA is not set; cannot resolve Claude Desktop path"); 74 + } 75 + return join(appData, "Claude", "claude_desktop_config.json"); 76 + } 77 + return join(homedir(), ".config", "Claude", "claude_desktop_config.json"); 78 + } 79 + 80 + export function resolveTargetConfigPath( 81 + id: InstallTargetId, 82 + options: { cwd: string; customPath?: string }, 83 + ): string { 84 + switch (id) { 85 + case "cursor-user": 86 + return join(homedir(), ".cursor", "mcp.json"); 87 + case "cursor-project": 88 + return join(options.cwd, ".cursor", "mcp.json"); 89 + case "claude-desktop": 90 + return getClaudeDesktopConfigPath(); 91 + case "custom": { 92 + const result = validateCustomConfigPathInput(options.customPath ?? ""); 93 + if (!result.ok) { 94 + throw new Error(result.message); 95 + } 96 + return result.path; 97 + } 98 + } 99 + }
+85
packages/mcp/src/install/wizard.ts
··· 1 + import prompts from "prompts"; 2 + import { 3 + INSTALL_TARGETS, 4 + type InstallTargetId, 5 + validateCustomConfigPathInput, 6 + } from "./targets.js"; 7 + 8 + function onCancel(): never { 9 + console.log("\nCancelled."); 10 + process.exit(0); 11 + } 12 + 13 + export async function promptTargetSelect(): Promise<InstallTargetId[]> { 14 + const answer = await prompts( 15 + { 16 + type: "multiselect", 17 + name: "targets", 18 + message: "Where should Kaneo register this MCP server?", 19 + choices: INSTALL_TARGETS.map((t) => ({ 20 + title: t.label, 21 + description: t.description, 22 + value: t.id, 23 + })), 24 + hint: "- Space to select. Enter to confirm.", 25 + min: 1, 26 + instructions: false, 27 + }, 28 + { onCancel }, 29 + ); 30 + 31 + if ( 32 + answer.targets === undefined || 33 + !Array.isArray(answer.targets) || 34 + answer.targets.length === 0 35 + ) { 36 + onCancel(); 37 + } 38 + 39 + return answer.targets as InstallTargetId[]; 40 + } 41 + 42 + export async function promptCustomConfigPath(): Promise<string> { 43 + const answer = await prompts( 44 + { 45 + type: "text", 46 + name: "path", 47 + message: "Absolute path to the JSON config file (create or update):", 48 + validate: (v) => { 49 + if (typeof v !== "string") { 50 + return "Path is required"; 51 + } 52 + const result = validateCustomConfigPathInput(v); 53 + return result.ok ? true : result.message; 54 + }, 55 + }, 56 + { onCancel }, 57 + ); 58 + 59 + if (answer.path === undefined) { 60 + onCancel(); 61 + } 62 + 63 + return String(answer.path).trim(); 64 + } 65 + 66 + export async function promptConfirmOverwrite( 67 + serverName: string, 68 + configPath: string, 69 + ): Promise<boolean> { 70 + const answer = await prompts( 71 + { 72 + type: "confirm", 73 + name: "overwrite", 74 + message: `MCP server "${serverName}" is already in this file (${configPath}). Overwrite it?`, 75 + initial: false, 76 + }, 77 + { onCancel }, 78 + ); 79 + 80 + if (answer.overwrite === undefined) { 81 + onCancel(); 82 + } 83 + 84 + return Boolean(answer.overwrite); 85 + }
+91
packages/mcp/src/kaneo/client.test.ts
··· 1 + import { afterEach, describe, expect, it, vi } from "vitest"; 2 + import { KaneoClient } from "./client.js"; 3 + 4 + describe("KaneoClient", () => { 5 + const originalFetch = globalThis.fetch; 6 + 7 + afterEach(() => { 8 + globalThis.fetch = originalFetch; 9 + vi.restoreAllMocks(); 10 + }); 11 + 12 + it("adds auth and json headers for requests with a body", async () => { 13 + const auth = { 14 + getAccessToken: vi.fn().mockResolvedValue("token-123"), 15 + clearToken: vi.fn().mockResolvedValue(undefined), 16 + }; 17 + const fetchMock = vi 18 + .fn() 19 + .mockResolvedValue( 20 + new Response(JSON.stringify({ ok: true }), { status: 200 }), 21 + ); 22 + globalThis.fetch = fetchMock as typeof fetch; 23 + 24 + const client = new KaneoClient({ 25 + baseUrl: "https://api.example.com", 26 + auth: auth as never, 27 + }); 28 + 29 + await expect( 30 + client.json("/api/project", { 31 + method: "POST", 32 + body: JSON.stringify({ name: "Inbox" }), 33 + }), 34 + ).resolves.toEqual({ ok: true }); 35 + 36 + expect(fetchMock).toHaveBeenCalledTimes(1); 37 + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; 38 + const headers = new Headers(init.headers); 39 + expect(headers.get("Authorization")).toBe("Bearer token-123"); 40 + expect(headers.get("Content-Type")).toBe("application/json"); 41 + }); 42 + 43 + it("clears the cached token and retries once after a 401", async () => { 44 + const auth = { 45 + getAccessToken: vi 46 + .fn() 47 + .mockResolvedValueOnce("expired-token") 48 + .mockResolvedValueOnce("fresh-token"), 49 + clearToken: vi.fn().mockResolvedValue(undefined), 50 + }; 51 + const fetchMock = vi 52 + .fn() 53 + .mockResolvedValueOnce(new Response("unauthorized", { status: 401 })) 54 + .mockResolvedValueOnce( 55 + new Response(JSON.stringify({ id: "task-1" }), { status: 200 }), 56 + ); 57 + globalThis.fetch = fetchMock as typeof fetch; 58 + 59 + const client = new KaneoClient({ 60 + baseUrl: "https://api.example.com", 61 + auth: auth as never, 62 + }); 63 + 64 + await expect(client.json("/api/task/task-1")).resolves.toEqual({ 65 + id: "task-1", 66 + }); 67 + expect(auth.clearToken).toHaveBeenCalledTimes(1); 68 + expect(fetchMock).toHaveBeenCalledTimes(2); 69 + }); 70 + 71 + it("surfaces api error messages when a request fails", async () => { 72 + const auth = { 73 + getAccessToken: vi.fn().mockResolvedValue("token-123"), 74 + clearToken: vi.fn().mockResolvedValue(undefined), 75 + }; 76 + globalThis.fetch = vi.fn().mockResolvedValue( 77 + new Response(JSON.stringify({ message: "Task not found" }), { 78 + status: 404, 79 + }), 80 + ) as typeof fetch; 81 + 82 + const client = new KaneoClient({ 83 + baseUrl: "https://api.example.com", 84 + auth: auth as never, 85 + }); 86 + 87 + await expect(client.json("/api/task/missing")).rejects.toThrow( 88 + "/api/task/missing: Task not found", 89 + ); 90 + }); 91 + });
+76
packages/mcp/src/kaneo/client.ts
··· 1 + import type { AuthService } from "../auth/auth-service.js"; 2 + 3 + export type Json = 4 + | null 5 + | boolean 6 + | number 7 + | string 8 + | Json[] 9 + | { [key: string]: Json }; 10 + 11 + export class KaneoClient { 12 + readonly baseUrl: string; 13 + private readonly auth: AuthService; 14 + 15 + constructor(options: { baseUrl: string; auth: AuthService }) { 16 + this.baseUrl = options.baseUrl; 17 + this.auth = options.auth; 18 + } 19 + 20 + private async authorizedFetch( 21 + path: string, 22 + init?: RequestInit, 23 + didRetry = false, 24 + ): Promise<Response> { 25 + const token = await this.auth.getAccessToken(); 26 + const headers = new Headers(init?.headers); 27 + headers.set("Authorization", `Bearer ${token}`); 28 + if (init?.body != null && !headers.has("Content-Type")) { 29 + headers.set("Content-Type", "application/json"); 30 + } 31 + 32 + const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; 33 + const timeoutSignal = AbortSignal.timeout(10_000); 34 + const signal = init?.signal 35 + ? AbortSignal.any([init.signal, timeoutSignal]) 36 + : timeoutSignal; 37 + const res = await fetch(url, { ...init, headers, signal }); 38 + 39 + if (res.status === 401 && !didRetry) { 40 + await this.auth.clearToken(); 41 + return this.authorizedFetch(path, init, true); 42 + } 43 + 44 + return res; 45 + } 46 + 47 + async json<T = Json>(path: string, init?: RequestInit): Promise<T> { 48 + const res = await this.authorizedFetch(path, init); 49 + const text = await res.text(); 50 + let body: unknown = null; 51 + if (text) { 52 + try { 53 + body = JSON.parse(text) as unknown; 54 + } catch { 55 + body = text; 56 + } 57 + } 58 + if (!res.ok) { 59 + let detail: string; 60 + if ( 61 + typeof body === "object" && 62 + body !== null && 63 + "message" in body && 64 + typeof (body as { message: unknown }).message === "string" 65 + ) { 66 + detail = (body as { message: string }).message; 67 + } else if (typeof body === "string" && body.length > 0) { 68 + detail = body.length > 500 ? `${body.slice(0, 500)}…` : body; 69 + } else { 70 + detail = `HTTP ${res.status}`; 71 + } 72 + throw new Error(`${path}: ${detail}`); 73 + } 74 + return body as T; 75 + } 76 + }
+26
packages/mcp/src/kaneo/task-helpers.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { buildFullTaskUpdateBody } from "./task-helpers.js"; 3 + 4 + describe("buildFullTaskUpdateBody", () => { 5 + it("merges patch onto existing task", () => { 6 + // Arrange 7 + const original = { 8 + title: "T", 9 + description: "D", 10 + status: "open", 11 + priority: "low", 12 + projectId: "p1", 13 + position: 1, 14 + userId: "u1", 15 + }; 16 + const patch = { status: "done" as const }; 17 + 18 + // Act 19 + const body = buildFullTaskUpdateBody(original, patch); 20 + 21 + // Assert 22 + expect(body.status).toBe("done"); 23 + expect(body.title).toBe("T"); 24 + expect(body.position).toBe(1); 25 + }); 26 + });
+130
packages/mcp/src/kaneo/task-helpers.ts
··· 1 + const PRIORITIES = ["no-priority", "low", "medium", "high", "urgent"] as const; 2 + 3 + export type TaskPriority = (typeof PRIORITIES)[number]; 4 + 5 + export function isTaskPriority(v: string): v is TaskPriority { 6 + return (PRIORITIES as readonly string[]).includes(v); 7 + } 8 + 9 + export type TaskUpdatePatch = { 10 + title?: string; 11 + description?: string | null; 12 + status?: string; 13 + priority?: TaskPriority; 14 + projectId?: string; 15 + position?: number; 16 + startDate?: string | null; 17 + dueDate?: string | null; 18 + userId?: string | null; 19 + }; 20 + 21 + /** 22 + * Builds the JSON body for `PUT /api/task/:id` from an existing task plus a patch. 23 + */ 24 + export function buildFullTaskUpdateBody( 25 + existing: Record<string, unknown>, 26 + patch: TaskUpdatePatch, 27 + ): Record<string, string | number | undefined> { 28 + const positionRaw = patch.position ?? existing.position; 29 + const position = 30 + typeof positionRaw === "number" 31 + ? positionRaw 32 + : typeof positionRaw === "string" 33 + ? Number(positionRaw) 34 + : Number.NaN; 35 + if (!Number.isFinite(position)) { 36 + throw new Error( 37 + "Cannot update task: missing numeric `position` on existing task.", 38 + ); 39 + } 40 + 41 + const title = 42 + patch.title ?? 43 + (typeof existing.title === "string" ? existing.title : undefined); 44 + if (!title) { 45 + throw new Error("Cannot update task: missing title."); 46 + } 47 + 48 + const description = 49 + patch.description !== undefined 50 + ? patch.description === null 51 + ? "" 52 + : String(patch.description) 53 + : existing.description == null 54 + ? "" 55 + : String(existing.description); 56 + 57 + const status = 58 + patch.status ?? 59 + (typeof existing.status === "string" ? existing.status : undefined); 60 + if (!status) { 61 + throw new Error("Cannot update task: missing status."); 62 + } 63 + 64 + const priorityRaw = 65 + patch.priority ?? 66 + (typeof existing.priority === "string" ? existing.priority : undefined); 67 + if (!priorityRaw || !isTaskPriority(priorityRaw)) { 68 + throw new Error("Cannot update task: invalid or missing priority."); 69 + } 70 + 71 + const projectId = 72 + patch.projectId ?? 73 + (typeof existing.projectId === "string" ? existing.projectId : undefined); 74 + if (!projectId) { 75 + throw new Error("Cannot update task: missing projectId."); 76 + } 77 + 78 + // When patch.userId is explicitly null, we set userId to "" so the API clears assignee; the API 79 + // treats "" as unassigned (userId || null). When patch.userId is undefined, keep existing.userId 80 + // unchanged (leave assignee as-is). 81 + const userId = 82 + patch.userId !== undefined 83 + ? patch.userId === null 84 + ? "" 85 + : patch.userId 86 + : typeof existing.userId === "string" 87 + ? existing.userId 88 + : undefined; 89 + 90 + const startDate = formatOptionalIso( 91 + patch.startDate !== undefined ? patch.startDate : existing.startDate, 92 + ); 93 + const dueDate = formatOptionalIso( 94 + patch.dueDate !== undefined ? patch.dueDate : existing.dueDate, 95 + ); 96 + 97 + const body: Record<string, string | number | undefined> = { 98 + title, 99 + description, 100 + status, 101 + priority: priorityRaw, 102 + projectId, 103 + position, 104 + }; 105 + 106 + if (startDate !== undefined) { 107 + body.startDate = startDate; 108 + } 109 + if (dueDate !== undefined) { 110 + body.dueDate = dueDate; 111 + } 112 + if (userId !== undefined) { 113 + body.userId = userId; 114 + } 115 + 116 + return body; 117 + } 118 + 119 + function formatOptionalIso(value: unknown): string | undefined { 120 + if (value === null || value === undefined) { 121 + return undefined; 122 + } 123 + if (value instanceof Date) { 124 + return value.toISOString(); 125 + } 126 + if (typeof value === "string") { 127 + return value; 128 + } 129 + return undefined; 130 + }
+26
packages/mcp/src/server.ts
··· 1 + import { createRequire } from "node:module"; 2 + import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 3 + import { AuthService } from "./auth/auth-service.js"; 4 + import { KaneoClient } from "./kaneo/client.js"; 5 + import { registerTools } from "./tools/register.js"; 6 + import { normalizeBaseUrl } from "./utils/normalize-base-url.js"; 7 + 8 + const require = createRequire(import.meta.url); 9 + const { version: packageVersion } = require("../package.json") as { 10 + version: string; 11 + }; 12 + 13 + export function createMcpServer(): McpServer { 14 + const baseUrl = normalizeBaseUrl( 15 + process.env.KANEO_API_URL || "http://localhost:1337", 16 + ); 17 + const clientId = process.env.KANEO_MCP_CLIENT_ID || "kaneo-mcp"; 18 + const auth = new AuthService({ baseUrl, clientId }); 19 + const client = new KaneoClient({ baseUrl, auth }); 20 + const server = new McpServer({ 21 + name: "kaneo-mcp", 22 + version: packageVersion, 23 + }); 24 + registerTools(server, { client }); 25 + return server; 26 + }
+216
packages/mcp/src/tools/register.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { registerTools } from "./register.js"; 3 + 4 + type RegisteredTool = { 5 + name: string; 6 + config: { inputSchema?: { parse: (args: unknown) => unknown } }; 7 + handler: (args: Record<string, unknown>) => Promise<{ 8 + content: Array<{ type: string; text: string }>; 9 + isError?: boolean; 10 + }>; 11 + }; 12 + 13 + function createServerMock() { 14 + const tools = new Map<string, RegisteredTool>(); 15 + 16 + return { 17 + server: { 18 + registerTool: vi.fn( 19 + ( 20 + name: string, 21 + config: RegisteredTool["config"], 22 + handler: RegisteredTool["handler"], 23 + ) => { 24 + tools.set(name, { name, config, handler }); 25 + }, 26 + ), 27 + }, 28 + tools, 29 + }; 30 + } 31 + 32 + describe("registerTools", () => { 33 + it("registers the MCP tools", () => { 34 + const { server } = createServerMock(); 35 + const client = { json: vi.fn() }; 36 + 37 + registerTools(server as never, { client: client as never }); 38 + 39 + expect(server.registerTool).toHaveBeenCalled(); 40 + expect(server.registerTool).toHaveBeenCalledWith( 41 + "whoami", 42 + expect.any(Object), 43 + expect.any(Function), 44 + ); 45 + expect(server.registerTool).toHaveBeenCalledWith( 46 + "update_task", 47 + expect.any(Object), 48 + expect.any(Function), 49 + ); 50 + }); 51 + 52 + it("builds the expected query string for list_tasks", async () => { 53 + const { server, tools } = createServerMock(); 54 + const client = { 55 + json: vi.fn().mockResolvedValue([{ id: "task-1" }]), 56 + }; 57 + 58 + registerTools(server as never, { client: client as never }); 59 + 60 + const result = await tools.get("list_tasks")?.handler({ 61 + projectId: "project 1", 62 + status: "open", 63 + page: 2, 64 + sortOrder: "desc", 65 + }); 66 + 67 + expect(client.json).toHaveBeenCalledWith( 68 + "/api/task/tasks/project%201?status=open&page=2&sortOrder=desc", 69 + { method: "GET" }, 70 + ); 71 + expect(result).toEqual({ 72 + content: [ 73 + { 74 + type: "text", 75 + text: JSON.stringify([{ id: "task-1" }], null, 2), 76 + }, 77 + ], 78 + isError: false, 79 + }); 80 + }); 81 + 82 + it("fetches the current task and sends a full body for update_task", async () => { 83 + const { server, tools } = createServerMock(); 84 + const client = { 85 + json: vi 86 + .fn() 87 + .mockResolvedValueOnce({ 88 + title: "Draft spec", 89 + description: "Write docs", 90 + status: "open", 91 + priority: "medium", 92 + projectId: "project-1", 93 + position: 4, 94 + }) 95 + .mockResolvedValueOnce({ id: "task-1", status: "done" }), 96 + }; 97 + 98 + registerTools(server as never, { client: client as never }); 99 + 100 + const result = await tools.get("update_task")?.handler({ 101 + taskId: "task-1", 102 + status: "done", 103 + }); 104 + 105 + expect(client.json).toHaveBeenNthCalledWith(1, "/api/task/task-1", { 106 + method: "GET", 107 + }); 108 + const putCall = client.json.mock.calls[1]; 109 + expect(putCall?.[0]).toBe("/api/task/task-1"); 110 + const putBody = JSON.parse( 111 + String((putCall?.[1] as { body?: string })?.body ?? "{}"), 112 + ); 113 + expect(putBody).toEqual( 114 + expect.objectContaining({ 115 + title: "Draft spec", 116 + description: "Write docs", 117 + status: "done", 118 + priority: "medium", 119 + projectId: "project-1", 120 + position: 4, 121 + }), 122 + ); 123 + expect(result?.isError).toBe(false); 124 + }); 125 + 126 + it("fetches the current project and sends a full body for update_project", async () => { 127 + const { server, tools } = createServerMock(); 128 + const client = { 129 + json: vi 130 + .fn() 131 + .mockResolvedValueOnce({ 132 + name: "Roadmap", 133 + slug: "roadmap", 134 + }) 135 + .mockResolvedValueOnce({ id: "project-1", name: "Roadmap v2" }), 136 + }; 137 + 138 + registerTools(server as never, { client: client as never }); 139 + 140 + const result = await tools.get("update_project")?.handler({ 141 + id: "project-1", 142 + name: "Roadmap v2", 143 + }); 144 + 145 + expect(client.json).toHaveBeenNthCalledWith(1, "/api/project/project-1", { 146 + method: "GET", 147 + }); 148 + const putCall = client.json.mock.calls[1]; 149 + expect(putCall?.[0]).toBe("/api/project/project-1"); 150 + const putBody = JSON.parse( 151 + String((putCall?.[1] as { body?: string })?.body ?? "{}"), 152 + ); 153 + expect(putBody).toEqual({ 154 + name: "Roadmap v2", 155 + icon: "Layout", 156 + slug: "roadmap", 157 + description: "", 158 + isPublic: false, 159 + }); 160 + expect(result?.isError).toBe(false); 161 + }); 162 + 163 + it("returns an MCP error result when the client request fails", async () => { 164 + const { server, tools } = createServerMock(); 165 + const client = { 166 + json: vi.fn().mockRejectedValue(new Error("boom")), 167 + }; 168 + 169 + registerTools(server as never, { client: client as never }); 170 + 171 + const result = await tools.get("whoami")?.handler({}); 172 + 173 + expect(result).toEqual({ 174 + content: [ 175 + { 176 + type: "text", 177 + text: JSON.stringify({ error: "boom" }, null, 2), 178 + }, 179 + ], 180 + isError: true, 181 + }); 182 + }); 183 + 184 + it("validates label colors as hex values", () => { 185 + const { server, tools } = createServerMock(); 186 + const client = { json: vi.fn() }; 187 + 188 + registerTools(server as never, { client: client as never }); 189 + 190 + const schema = tools.get("create_label")?.config.inputSchema; 191 + expect(schema).toBeDefined(); 192 + expect(() => 193 + schema?.parse({ 194 + name: "Bug", 195 + color: "red", 196 + workspaceId: "workspace-1", 197 + }), 198 + ).toThrow(/hex color/i); 199 + }); 200 + 201 + it("validates task date filters as ISO datetimes with timezone", () => { 202 + const { server, tools } = createServerMock(); 203 + const client = { json: vi.fn() }; 204 + 205 + registerTools(server as never, { client: client as never }); 206 + 207 + const schema = tools.get("list_tasks")?.config.inputSchema; 208 + expect(schema).toBeDefined(); 209 + expect(() => 210 + schema?.parse({ 211 + projectId: "project-1", 212 + dueBefore: "2026-04-04", 213 + }), 214 + ).toThrow(); 215 + }); 216 + });
+461
packages/mcp/src/tools/register.ts
··· 1 + import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 + import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 3 + import { z } from "zod"; 4 + import type { KaneoClient } from "../kaneo/client.js"; 5 + import { buildFullTaskUpdateBody } from "../kaneo/task-helpers.js"; 6 + import { errorResult, textResult } from "../utils/mcp-result.js"; 7 + 8 + const prioritySchema = z.enum([ 9 + "no-priority", 10 + "low", 11 + "medium", 12 + "high", 13 + "urgent", 14 + ]); 15 + 16 + const nonEmptyString = z.string().trim().min(1); 17 + const optionalNonEmptyString = nonEmptyString.optional(); 18 + const nullableOptionalNonEmptyString = nonEmptyString.nullable().optional(); 19 + const isoDateTimeSchema = z.string().datetime({ offset: true }); 20 + const optionalIsoDateTimeSchema = isoDateTimeSchema.optional(); 21 + const nullableOptionalIsoDateTimeSchema = isoDateTimeSchema 22 + .nullable() 23 + .optional(); 24 + const hexColorSchema = z 25 + .string() 26 + .regex( 27 + /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 28 + "Expected a hex color like #FF6600", 29 + ); 30 + 31 + function run(fn: () => Promise<unknown>): Promise<CallToolResult> { 32 + return fn() 33 + .then((data) => textResult(data)) 34 + .catch((e: unknown) => 35 + errorResult(e instanceof Error ? e.message : String(e)), 36 + ); 37 + } 38 + 39 + export function registerTools( 40 + server: McpServer, 41 + ctx: { client: KaneoClient }, 42 + ): void { 43 + const { client } = ctx; 44 + 45 + server.registerTool( 46 + "whoami", 47 + { 48 + description: 49 + "Return the current Kaneo session and user for the cached device token.", 50 + inputSchema: z.object({}), 51 + }, 52 + async () => 53 + run(() => client.json("/api/auth/get-session", { method: "GET" })), 54 + ); 55 + 56 + server.registerTool( 57 + "list_workspaces", 58 + { 59 + description: 60 + "List workspaces (Better Auth organizations) the signed-in user can access.", 61 + inputSchema: z.object({}), 62 + }, 63 + async () => 64 + run(() => client.json("/api/auth/organization/list", { method: "GET" })), 65 + ); 66 + 67 + server.registerTool( 68 + "list_projects", 69 + { 70 + description: "List projects in a workspace.", 71 + inputSchema: z.object({ 72 + workspaceId: nonEmptyString.describe("Workspace ID"), 73 + includeArchived: z 74 + .boolean() 75 + .optional() 76 + .describe("Include archived projects"), 77 + }), 78 + }, 79 + async (args) => { 80 + const { workspaceId, includeArchived } = args; 81 + const qs = new URLSearchParams({ workspaceId }); 82 + if (includeArchived === true) { 83 + qs.set("includeArchived", "true"); 84 + } 85 + return run(() => 86 + client.json(`/api/project?${qs.toString()}`, { method: "GET" }), 87 + ); 88 + }, 89 + ); 90 + 91 + server.registerTool( 92 + "get_project", 93 + { 94 + description: "Get a single project by ID.", 95 + inputSchema: z.object({ id: nonEmptyString }), 96 + }, 97 + async (args) => 98 + run(() => client.json(`/api/project/${encodeURIComponent(args.id)}`)), 99 + ); 100 + 101 + server.registerTool( 102 + "create_project", 103 + { 104 + description: "Create a project in a workspace.", 105 + inputSchema: z.object({ 106 + name: nonEmptyString, 107 + workspaceId: nonEmptyString, 108 + icon: nonEmptyString, 109 + slug: nonEmptyString, 110 + }), 111 + }, 112 + async (args) => 113 + run(() => 114 + client.json("/api/project", { 115 + method: "POST", 116 + body: JSON.stringify({ 117 + name: args.name, 118 + workspaceId: args.workspaceId, 119 + icon: args.icon, 120 + slug: args.slug, 121 + }), 122 + }), 123 + ), 124 + ); 125 + 126 + server.registerTool( 127 + "update_project", 128 + { 129 + description: 130 + "Update project metadata (PATCH-style: only provided fields are changed).", 131 + inputSchema: z.object({ 132 + id: nonEmptyString, 133 + name: optionalNonEmptyString, 134 + icon: z.string().optional(), 135 + slug: optionalNonEmptyString, 136 + description: z.string().optional(), 137 + isPublic: z.boolean().optional(), 138 + }), 139 + }, 140 + async (args) => { 141 + const { id, ...patch } = args; 142 + return run(async () => { 143 + const existing = (await client.json( 144 + `/api/project/${encodeURIComponent(id)}`, 145 + { method: "GET" }, 146 + )) as Record<string, unknown>; 147 + const name = 148 + patch.name ?? 149 + (typeof existing.name === "string" ? existing.name : ""); 150 + if (!name) { 151 + throw new Error("Cannot update project: missing name."); 152 + } 153 + const icon = 154 + patch.icon !== undefined 155 + ? patch.icon 156 + : typeof existing.icon === "string" 157 + ? existing.icon 158 + : "Layout"; 159 + const slug = 160 + patch.slug ?? 161 + (typeof existing.slug === "string" ? existing.slug : ""); 162 + if (!slug) { 163 + throw new Error("Cannot update project: missing slug."); 164 + } 165 + const description = 166 + patch.description !== undefined 167 + ? patch.description 168 + : typeof existing.description === "string" 169 + ? existing.description 170 + : ""; 171 + const isPublic = 172 + patch.isPublic !== undefined 173 + ? patch.isPublic 174 + : typeof existing.isPublic === "boolean" 175 + ? existing.isPublic 176 + : false; 177 + 178 + const body = { name, icon, slug, description, isPublic }; 179 + 180 + return client.json(`/api/project/${encodeURIComponent(id)}`, { 181 + method: "PUT", 182 + body: JSON.stringify(body), 183 + }); 184 + }); 185 + }, 186 + ); 187 + 188 + const listTasksSchema = z.object({ 189 + projectId: nonEmptyString, 190 + status: optionalNonEmptyString, 191 + priority: prioritySchema.optional(), 192 + assigneeId: optionalNonEmptyString, 193 + page: z.number().int().positive().optional(), 194 + limit: z.number().int().positive().optional(), 195 + sortBy: z 196 + .enum(["createdAt", "priority", "dueDate", "position", "title", "number"]) 197 + .optional(), 198 + sortOrder: z.enum(["asc", "desc"]).optional(), 199 + dueBefore: optionalIsoDateTimeSchema, 200 + dueAfter: optionalIsoDateTimeSchema, 201 + }); 202 + 203 + server.registerTool( 204 + "list_tasks", 205 + { 206 + description: "List tasks for a project (optionally filtered/sorted).", 207 + inputSchema: listTasksSchema, 208 + }, 209 + async (args) => { 210 + const { projectId, ...rest } = args; 211 + const qs = new URLSearchParams(); 212 + for (const [k, v] of Object.entries(rest)) { 213 + if (v === undefined || v === null) { 214 + continue; 215 + } 216 + qs.set(k, String(v)); 217 + } 218 + const q = qs.toString(); 219 + const path = `/api/task/tasks/${encodeURIComponent(projectId)}${q ? `?${q}` : ""}`; 220 + return run(() => client.json(path, { method: "GET" })); 221 + }, 222 + ); 223 + 224 + server.registerTool( 225 + "get_task", 226 + { 227 + description: "Get a task by ID.", 228 + inputSchema: z.object({ taskId: nonEmptyString }), 229 + }, 230 + async (args) => 231 + run(() => 232 + client.json(`/api/task/${encodeURIComponent(args.taskId)}`, { 233 + method: "GET", 234 + }), 235 + ), 236 + ); 237 + 238 + server.registerTool( 239 + "create_task", 240 + { 241 + description: "Create a task in a project.", 242 + inputSchema: z.object({ 243 + projectId: nonEmptyString, 244 + title: nonEmptyString, 245 + description: z.string(), 246 + priority: prioritySchema, 247 + status: nonEmptyString, 248 + startDate: optionalIsoDateTimeSchema, 249 + dueDate: optionalIsoDateTimeSchema, 250 + userId: optionalNonEmptyString, 251 + }), 252 + }, 253 + async (args) => { 254 + const body: Record<string, string | undefined> = { 255 + title: args.title, 256 + description: args.description, 257 + priority: args.priority, 258 + status: args.status, 259 + }; 260 + if (args.startDate !== undefined) { 261 + body.startDate = args.startDate; 262 + } 263 + if (args.dueDate !== undefined) { 264 + body.dueDate = args.dueDate; 265 + } 266 + if (args.userId !== undefined) { 267 + body.userId = args.userId; 268 + } 269 + return run(() => 270 + client.json(`/api/task/${encodeURIComponent(args.projectId)}`, { 271 + method: "POST", 272 + body: JSON.stringify(body), 273 + }), 274 + ); 275 + }, 276 + ); 277 + 278 + const updateTaskSchema = z.object({ 279 + taskId: nonEmptyString, 280 + title: optionalNonEmptyString, 281 + description: z.string().nullable().optional(), 282 + status: optionalNonEmptyString, 283 + priority: prioritySchema.optional(), 284 + projectId: optionalNonEmptyString, 285 + position: z.number().optional(), 286 + startDate: nullableOptionalIsoDateTimeSchema, 287 + dueDate: nullableOptionalIsoDateTimeSchema, 288 + userId: nullableOptionalNonEmptyString, 289 + }); 290 + 291 + server.registerTool( 292 + "update_task", 293 + { 294 + description: 295 + "Update a task (fetches current task, merges fields, then full update).", 296 + inputSchema: updateTaskSchema, 297 + }, 298 + async (args) => { 299 + const { taskId, ...patch } = args; 300 + return run(async () => { 301 + const existing = (await client.json( 302 + `/api/task/${encodeURIComponent(taskId)}`, 303 + { method: "GET" }, 304 + )) as Record<string, unknown>; 305 + const body = buildFullTaskUpdateBody(existing, patch); 306 + return client.json(`/api/task/${encodeURIComponent(taskId)}`, { 307 + method: "PUT", 308 + body: JSON.stringify(body), 309 + }); 310 + }); 311 + }, 312 + ); 313 + 314 + server.registerTool( 315 + "move_task", 316 + { 317 + description: 318 + "Move a task to another project (and optional column status).", 319 + inputSchema: z.object({ 320 + taskId: nonEmptyString, 321 + destinationProjectId: nonEmptyString, 322 + destinationStatus: optionalNonEmptyString, 323 + }), 324 + }, 325 + async (args) => 326 + run(() => 327 + client.json(`/api/task/move/${encodeURIComponent(args.taskId)}`, { 328 + method: "PUT", 329 + body: JSON.stringify({ 330 + destinationProjectId: args.destinationProjectId, 331 + ...(args.destinationStatus !== undefined 332 + ? { destinationStatus: args.destinationStatus } 333 + : {}), 334 + }), 335 + }), 336 + ), 337 + ); 338 + 339 + server.registerTool( 340 + "update_task_status", 341 + { 342 + description: "Update only the status (column) of a task.", 343 + inputSchema: z.object({ 344 + taskId: nonEmptyString, 345 + status: nonEmptyString, 346 + }), 347 + }, 348 + async (args) => 349 + run(() => 350 + client.json(`/api/task/status/${encodeURIComponent(args.taskId)}`, { 351 + method: "PUT", 352 + body: JSON.stringify({ status: args.status }), 353 + }), 354 + ), 355 + ); 356 + 357 + server.registerTool( 358 + "list_task_comments", 359 + { 360 + description: "List comments on a task.", 361 + inputSchema: z.object({ taskId: nonEmptyString }), 362 + }, 363 + async (args) => 364 + run(() => 365 + client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, { 366 + method: "GET", 367 + }), 368 + ), 369 + ); 370 + 371 + server.registerTool( 372 + "create_task_comment", 373 + { 374 + description: "Add a comment to a task.", 375 + inputSchema: z.object({ 376 + taskId: nonEmptyString, 377 + content: nonEmptyString, 378 + }), 379 + }, 380 + async (args) => 381 + run(() => 382 + client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, { 383 + method: "POST", 384 + body: JSON.stringify({ content: args.content }), 385 + }), 386 + ), 387 + ); 388 + 389 + server.registerTool( 390 + "list_workspace_labels", 391 + { 392 + description: "List labels defined in a workspace.", 393 + inputSchema: z.object({ workspaceId: nonEmptyString }), 394 + }, 395 + async (args) => 396 + run(() => 397 + client.json( 398 + `/api/label/workspace/${encodeURIComponent(args.workspaceId)}`, 399 + { method: "GET" }, 400 + ), 401 + ), 402 + ); 403 + 404 + server.registerTool( 405 + "create_label", 406 + { 407 + description: 408 + "Create a label in a workspace (optionally attach to a task).", 409 + inputSchema: z.object({ 410 + name: nonEmptyString, 411 + color: hexColorSchema, 412 + workspaceId: nonEmptyString, 413 + taskId: optionalNonEmptyString, 414 + }), 415 + }, 416 + async (args) => 417 + run(() => 418 + client.json("/api/label", { 419 + method: "POST", 420 + body: JSON.stringify({ 421 + name: args.name, 422 + color: args.color, 423 + workspaceId: args.workspaceId, 424 + ...(args.taskId !== undefined ? { taskId: args.taskId } : {}), 425 + }), 426 + }), 427 + ), 428 + ); 429 + 430 + server.registerTool( 431 + "attach_label_to_task", 432 + { 433 + description: "Attach an existing label to a task.", 434 + inputSchema: z.object({ 435 + labelId: nonEmptyString, 436 + taskId: nonEmptyString, 437 + }), 438 + }, 439 + async (args) => 440 + run(() => 441 + client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, { 442 + method: "PUT", 443 + body: JSON.stringify({ taskId: args.taskId }), 444 + }), 445 + ), 446 + ); 447 + 448 + server.registerTool( 449 + "detach_label_from_task", 450 + { 451 + description: "Detach a label from its current task.", 452 + inputSchema: z.object({ labelId: nonEmptyString }), 453 + }, 454 + async (args) => 455 + run(() => 456 + client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, { 457 + method: "DELETE", 458 + }), 459 + ), 460 + ); 461 + }
+19
packages/mcp/src/utils/mcp-result.ts
··· 1 + import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 2 + 3 + export function textResult(data: unknown, isError = false): CallToolResult { 4 + const text = 5 + typeof data === "string" ? data : (JSON.stringify(data, null, 2) ?? ""); 6 + return { 7 + content: [ 8 + { 9 + type: "text", 10 + text, 11 + }, 12 + ], 13 + isError, 14 + }; 15 + } 16 + 17 + export function errorResult(message: string): CallToolResult { 18 + return textResult({ error: message }, true); 19 + }
+26
packages/mcp/src/utils/normalize-base-url.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { normalizeBaseUrl } from "./normalize-base-url.js"; 3 + 4 + describe("normalizeBaseUrl", () => { 5 + it("strips trailing slashes", () => { 6 + // Arrange 7 + const input = "http://localhost:1337/"; 8 + 9 + // Act 10 + const result = normalizeBaseUrl(input); 11 + 12 + // Assert 13 + expect(result).toBe("http://localhost:1337"); 14 + }); 15 + 16 + it("removes /api suffix from path", () => { 17 + // Arrange 18 + const input = "http://localhost:1337/api"; 19 + 20 + // Act 21 + const result = normalizeBaseUrl(input); 22 + 23 + // Assert 24 + expect(result).toBe("http://localhost:1337"); 25 + }); 26 + });
+17
packages/mcp/src/utils/normalize-base-url.ts
··· 1 + /** 2 + * Kaneo API base URL without trailing slash and without `/api` suffix. 3 + */ 4 + export function normalizeBaseUrl(raw: string): string { 5 + const trimmed = raw.trim().replace(/\/+$/, ""); 6 + try { 7 + const u = new URL(trimmed); 8 + let path = u.pathname.replace(/\/+$/, ""); 9 + if (path === "/api" || path.endsWith("/api")) { 10 + path = path.replace(/\/?api$/, "") || "/"; 11 + u.pathname = path; 12 + } 13 + return `${u.protocol}//${u.host}${path === "/" ? "" : path}`; 14 + } catch { 15 + return trimmed.replace(/\/api\/?$/, "").replace(/\/+$/, ""); 16 + } 17 + }
+12
packages/mcp/tsconfig.json
··· 1 + { 2 + "extends": "../typescript-config/base.json", 3 + "compilerOptions": { 4 + "outDir": "dist", 5 + "rootDir": "src", 6 + "noEmit": false, 7 + "declaration": false, 8 + "declarationMap": false 9 + }, 10 + "include": ["src/**/*.ts"], 11 + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] 12 + }
+9
packages/mcp/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: false, 6 + environment: "node", 7 + include: ["src/**/*.test.ts"], 8 + }, 9 + });
+67 -1
pnpm-lock.yaml
··· 422 422 marked: 423 423 specifier: ^17.0.4 424 424 version: 17.0.4 425 + nanostores: 426 + specifier: ^1.1.1 427 + version: 1.1.1 425 428 radix-ui: 426 429 specifier: ^1.4.3 427 430 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ··· 593 596 specifier: ^4.1.2 594 597 version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) 595 598 599 + packages/mcp: 600 + dependencies: 601 + '@modelcontextprotocol/sdk': 602 + specifier: ^1.26.0 603 + version: 1.26.0(zod@4.3.6) 604 + open: 605 + specifier: ^11.0.0 606 + version: 11.0.0 607 + prompts: 608 + specifier: ^2.4.2 609 + version: 2.4.2 610 + zod: 611 + specifier: ^4.3.6 612 + version: 4.3.6 613 + devDependencies: 614 + '@kaneo/typescript-config': 615 + specifier: workspace:* 616 + version: link:../typescript-config 617 + '@types/node': 618 + specifier: ^25.3.5 619 + version: 25.5.0 620 + '@types/prompts': 621 + specifier: ^2.4.9 622 + version: 2.4.9 623 + tsx: 624 + specifier: ^4.21.0 625 + version: 4.21.0 626 + typescript: 627 + specifier: ^5.9.3 628 + version: 5.9.3 629 + vitest: 630 + specifier: ^4.1.2 631 + version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) 632 + 596 633 packages/typescript-config: {} 597 634 598 635 packages: ··· 4206 4243 4207 4244 '@types/pg@8.18.0': 4208 4245 resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} 4246 + 4247 + '@types/prompts@2.4.9': 4248 + resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} 4209 4249 4210 4250 '@types/react-dom@19.2.3': 4211 4251 resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} ··· 9258 9298 transitivePeerDependencies: 9259 9299 - supports-color 9260 9300 9301 + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': 9302 + dependencies: 9303 + '@hono/node-server': 1.19.10(hono@4.12.7) 9304 + ajv: 8.18.0 9305 + ajv-formats: 3.0.1(ajv@8.18.0) 9306 + content-type: 1.0.5 9307 + cors: 2.8.5 9308 + cross-spawn: 7.0.6 9309 + eventsource: 3.0.7 9310 + eventsource-parser: 3.0.6 9311 + express: 5.2.1 9312 + express-rate-limit: 8.2.2(express@5.2.1) 9313 + hono: 4.12.7 9314 + jose: 6.1.3 9315 + json-schema-typed: 8.0.2 9316 + pkce-challenge: 5.0.1 9317 + raw-body: 3.0.2 9318 + zod: 4.3.6 9319 + zod-to-json-schema: 3.25.1(zod@4.3.6) 9320 + transitivePeerDependencies: 9321 + - supports-color 9322 + 9261 9323 '@mongodb-js/saslprep@1.4.6': 9262 9324 dependencies: 9263 9325 sparse-bitfield: 3.0.3 ··· 12622 12684 '@types/node': 25.4.0 12623 12685 pg-protocol: 1.12.0 12624 12686 pg-types: 2.2.0 12687 + 12688 + '@types/prompts@2.4.9': 12689 + dependencies: 12690 + '@types/node': 25.5.0 12691 + kleur: 3.0.3 12625 12692 12626 12693 '@types/react-dom@19.2.3(@types/react@19.2.14)': 12627 12694 dependencies: ··· 16615 16682 zod-to-json-schema@3.25.1(zod@4.3.6): 16616 16683 dependencies: 16617 16684 zod: 4.3.6 16618 - optional: true 16619 16685 16620 16686 zod@3.25.76: {} 16621 16687