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.

feat: add kaneo mcp app

Tin a9fffc22 aeb345ee

+1567
+21
apps/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.
+86
apps/mcp/README.md
··· 1 + # Kaneo MCP server 2 + 3 + Model Context Protocol (stdio) server that talks to a [Kaneo](https://github.com/usekaneo/kaneo) instance using the OAuth 2.0 device authorization flow (RFC 8628), then calls Kaneo’s REST API with `Authorization: Bearer <token>`. 4 + 5 + **How MCP fits here:** clients (Cursor, Claude Desktop, etc.) start a **local process** and talk to it over **stdin/stdout** using the MCP protocol. This package ships a **`bin`** (`kaneo-mcp`) for installable CLIs. 6 + 7 + This package lives in the Kaneo monorepo at **`apps/mcp`**. 8 + 9 + ## Prerequisites 10 + 11 + - Node.js 20+ 12 + - A running Kaneo API (for example `http://localhost:1337`) and web app (for device approval UI). 13 + 14 + On the Kaneo server, allow this MCP client’s ID: 15 + 16 + ```bash 17 + DEVICE_AUTH_CLIENT_IDS=kaneo-cli,kaneo-mcp 18 + ``` 19 + 20 + If you omit `DEVICE_AUTH_CLIENT_IDS`, Kaneo defaults to allowing `kaneo-cli` only—you must include `kaneo-mcp` (or set `KANEO_MCP_CLIENT_ID` to an ID you have allowlisted). 21 + 22 + ## Environment 23 + 24 + | Variable | Description | 25 + |----------|-------------| 26 + | `KANEO_API_URL` | Kaneo API origin (default `http://localhost:1337`). Do not include `/api`. | 27 + | `KANEO_MCP_CLIENT_ID` | Device-flow client id (default `kaneo-mcp`). Must match `DEVICE_AUTH_CLIENT_IDS` on the server. | 28 + 29 + ## Install (published package) 30 + 31 + After publishing `@kaneo/mcp` to npm (or a private registry): 32 + 33 + ```bash 34 + npm install -g @kaneo/mcp 35 + kaneo-mcp 36 + ``` 37 + 38 + Or: 39 + 40 + ```bash 41 + npx @kaneo/mcp 42 + ``` 43 + 44 + The published tarball includes **`dist/`** and dependencies; `prepublishOnly` runs the TypeScript build before publish. 45 + 46 + ## Develop from source (inside this monorepo) 47 + 48 + From the **`kaneo/`** directory: 49 + 50 + ```bash 51 + pnpm install 52 + pnpm --filter @kaneo/mcp run build 53 + pnpm --filter @kaneo/mcp run start 54 + pnpm --filter @kaneo/mcp run test 55 + ``` 56 + 57 + Or with paths: 58 + 59 + ```bash 60 + pnpm -C apps/mcp run build 61 + ``` 62 + 63 + The CLI entry is `kaneo-mcp` in `package.json` `bin` → `./dist/index.js`. 64 + For **Cursor**, point the MCP command at **`…/kaneo/apps/mcp/dist/index.js`** (absolute path after build). 65 + 66 + ## Authentication 67 + 68 + On the first tool call that needs Kaneo, the server: 69 + 70 + 1. Requests a device code from `POST /api/auth/device/code` 71 + 2. Prints the verification URL and user code to **stderr** (stdout stays clean for MCP) 72 + 3. Tries to open the browser 73 + 4. Polls `POST /api/auth/device/token` until approved 74 + 5. Stores the access token under `~/.config/kaneo-mcp/credentials.json` (mode `0600`) 75 + 76 + ## Tools 77 + 78 + Session: `whoami`, `list_workspaces` 79 + 80 + Projects: `list_projects`, `get_project`, `create_project`, `update_project` 81 + 82 + Tasks: `list_tasks`, `get_task`, `create_task`, `update_task`, `move_task`, `update_task_status` 83 + 84 + Comments: `list_task_comments`, `create_task_comment` 85 + 86 + Labels: `list_workspace_labels`, `create_label`, `attach_label_to_task`, `detach_label_from_task`
+41
apps/mcp/package.json
··· 1 + { 2 + "name": "@kaneo/mcp", 3 + "version": "0.1.0", 4 + "description": "Model Context Protocol (stdio) server for Kaneo — tasks, projects, labels, and device authorization", 5 + "license": "MIT", 6 + "type": "module", 7 + "bin": { 8 + "kaneo-mcp": "./dist/index.js" 9 + }, 10 + "files": [ 11 + "dist", 12 + "README.md", 13 + "LICENSE" 14 + ], 15 + "keywords": [ 16 + "mcp", 17 + "model-context-protocol", 18 + "kaneo", 19 + "stdio" 20 + ], 21 + "scripts": { 22 + "build": "tsc -p tsconfig.json", 23 + "prepublishOnly": "npm run build", 24 + "dev": "tsx watch src/index.ts", 25 + "start": "node dist/index.js", 26 + "test": "vitest run --config vitest.config.ts", 27 + "test:watch": "vitest --config vitest.config.ts" 28 + }, 29 + "dependencies": { 30 + "@modelcontextprotocol/sdk": "^1.26.0", 31 + "open": "^10.1.0", 32 + "zod": "^3.25.0" 33 + }, 34 + "devDependencies": { 35 + "@kaneo/typescript-config": "workspace:*", 36 + "@types/node": "^25.3.5", 37 + "tsx": "^4.21.0", 38 + "typescript": "^5.9.3", 39 + "vitest": "^4.1.2" 40 + } 41 + }
+95
apps/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 + export class AuthService { 16 + readonly baseUrl: string; 17 + readonly clientId: string; 18 + 19 + constructor(options: AuthServiceOptions) { 20 + this.baseUrl = options.baseUrl; 21 + this.clientId = options.clientId; 22 + } 23 + 24 + async clearToken(): Promise<void> { 25 + await clearCredentials(); 26 + } 27 + 28 + private async validateAccessToken(token: string): Promise<boolean> { 29 + const res = await fetch(`${this.baseUrl}/api/auth/get-session`, { 30 + headers: { Authorization: `Bearer ${token}` }, 31 + }); 32 + if (res.status === 401) { 33 + return false; 34 + } 35 + if (!res.ok) { 36 + return false; 37 + } 38 + const data = (await res.json().catch(() => null)) as { 39 + user?: { id?: string }; 40 + } | null; 41 + return Boolean(data?.user?.id); 42 + } 43 + 44 + private log(msg: string): void { 45 + console.error(`[kaneo-mcp] ${msg}`); 46 + } 47 + 48 + /** 49 + * Returns a valid access token, running the device authorization flow if needed. 50 + */ 51 + async getAccessToken(): Promise<string> { 52 + const cached = await loadCredentials(); 53 + if ( 54 + cached && 55 + cached.baseUrl === this.baseUrl && 56 + cached.clientId === this.clientId && 57 + cached.accessToken 58 + ) { 59 + const ok = await this.validateAccessToken(cached.accessToken); 60 + if (ok) { 61 + return cached.accessToken; 62 + } 63 + await this.clearToken(); 64 + } 65 + 66 + const code = await requestDeviceCode(this.baseUrl, this.clientId); 67 + const verifyUrl = code.verification_uri_complete || code.verification_uri; 68 + this.log( 69 + `Open ${verifyUrl} and approve this device. User code: ${code.user_code}`, 70 + ); 71 + 72 + try { 73 + await open(verifyUrl); 74 + } catch { 75 + this.log("Could not open a browser automatically; use the URL above."); 76 + } 77 + 78 + const accessToken = await pollDeviceAccessToken( 79 + this.baseUrl, 80 + this.clientId, 81 + code.device_code, 82 + code.interval, 83 + { log: (m) => this.log(m) }, 84 + ); 85 + 86 + const toStore: StoredCredentials = { 87 + version: 1, 88 + baseUrl: this.baseUrl, 89 + clientId: this.clientId, 90 + accessToken, 91 + }; 92 + await saveCredentials(toStore); 93 + return accessToken; 94 + } 95 + }
+144
apps/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 + { 35 + method: "POST", 36 + headers: { "Content-Type": "application/json" }, 37 + body: JSON.stringify({ client_id: "kaneo-mcp" }), 38 + }, 39 + ); 40 + }); 41 + 42 + it("throws when the response body is missing the device code", async () => { 43 + globalThis.fetch = vi.fn().mockResolvedValue( 44 + new Response(JSON.stringify({ user_code: "missing-device-code" }), { 45 + status: 200, 46 + }), 47 + ) as typeof fetch; 48 + 49 + await expect( 50 + requestDeviceCode("https://api.example.com", "kaneo-mcp"), 51 + ).rejects.toThrow(/unexpected response/); 52 + }); 53 + }); 54 + 55 + describe("pollDeviceAccessToken", () => { 56 + const originalFetch = globalThis.fetch; 57 + 58 + beforeEach(() => { 59 + vi.useFakeTimers(); 60 + }); 61 + 62 + afterEach(() => { 63 + globalThis.fetch = originalFetch; 64 + vi.useRealTimers(); 65 + vi.restoreAllMocks(); 66 + }); 67 + 68 + it("keeps polling through authorization_pending until an access token is returned", async () => { 69 + const log = vi.fn(); 70 + const fetchMock = vi 71 + .fn() 72 + .mockResolvedValueOnce( 73 + new Response(JSON.stringify({ error: "authorization_pending" }), { 74 + status: 400, 75 + }), 76 + ) 77 + .mockResolvedValueOnce( 78 + new Response(JSON.stringify({ access_token: "token-123" }), { 79 + status: 200, 80 + }), 81 + ); 82 + globalThis.fetch = fetchMock as typeof fetch; 83 + 84 + const promise = pollDeviceAccessToken( 85 + "https://api.example.com", 86 + "kaneo-mcp", 87 + "device-code", 88 + 1, 89 + { log }, 90 + ); 91 + 92 + await vi.runAllTimersAsync(); 93 + 94 + await expect(promise).resolves.toBe("token-123"); 95 + expect(fetchMock).toHaveBeenCalledTimes(2); 96 + expect(log).toHaveBeenCalledWith("Waiting for device approval…"); 97 + }); 98 + 99 + it("increases the polling interval after slow_down", async () => { 100 + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); 101 + const fetchMock = vi 102 + .fn() 103 + .mockResolvedValueOnce( 104 + new Response(JSON.stringify({ error: "slow_down" }), { 105 + status: 400, 106 + }), 107 + ) 108 + .mockResolvedValueOnce( 109 + new Response(JSON.stringify({ access_token: "token-123" }), { 110 + status: 200, 111 + }), 112 + ); 113 + globalThis.fetch = fetchMock as typeof fetch; 114 + 115 + const promise = pollDeviceAccessToken( 116 + "https://api.example.com", 117 + "kaneo-mcp", 118 + "device-code", 119 + 1, 120 + ); 121 + 122 + await vi.runAllTimersAsync(); 123 + 124 + await expect(promise).resolves.toBe("token-123"); 125 + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 6000); 126 + }); 127 + 128 + it("throws a helpful error when authorization is denied", async () => { 129 + globalThis.fetch = vi.fn().mockResolvedValue( 130 + new Response(JSON.stringify({ error: "access_denied" }), { 131 + status: 400, 132 + }), 133 + ) as typeof fetch; 134 + 135 + await expect( 136 + pollDeviceAccessToken( 137 + "https://api.example.com", 138 + "kaneo-mcp", 139 + "device-code", 140 + 1, 141 + ), 142 + ).rejects.toThrow("Device authorization was denied."); 143 + }); 144 + });
+106
apps/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 + function sleep(ms: number): Promise<void> { 16 + return new Promise((r) => setTimeout(r, ms)); 17 + } 18 + 19 + export async function requestDeviceCode( 20 + baseUrl: string, 21 + clientId: string, 22 + ): Promise<DeviceCodeResponse> { 23 + const res = await fetch(`${baseUrl}/api/auth/device/code`, { 24 + method: "POST", 25 + headers: { "Content-Type": "application/json" }, 26 + body: JSON.stringify({ client_id: clientId }), 27 + }); 28 + const body = (await res.json().catch(() => ({}))) as 29 + | DeviceCodeResponse 30 + | DeviceTokenErrorBody; 31 + if (!res.ok) { 32 + throw new Error( 33 + `device/code failed (${res.status}): ${JSON.stringify(body)}`, 34 + ); 35 + } 36 + if (!("device_code" in body) || typeof body.device_code !== "string") { 37 + throw new Error(`device/code: unexpected response ${JSON.stringify(body)}`); 38 + } 39 + return body; 40 + } 41 + 42 + /** 43 + * Polls `/api/auth/device/token` until success or terminal error. 44 + * First attempt is immediate; subsequent attempts wait `interval` seconds (increased on `slow_down`). 45 + */ 46 + export async function pollDeviceAccessToken( 47 + baseUrl: string, 48 + clientId: string, 49 + deviceCode: string, 50 + initialIntervalSec: number, 51 + options?: { maxWaitMs?: number; log?: (msg: string) => void }, 52 + ): Promise<string> { 53 + const maxWait = options?.maxWaitMs ?? 30 * 60 * 1000; 54 + const log = options?.log ?? (() => {}); 55 + const started = Date.now(); 56 + let intervalMs = Math.max(1000, initialIntervalSec * 1000); 57 + 58 + for (let attempt = 0; Date.now() - started < maxWait; attempt++) { 59 + if (attempt > 0) { 60 + await sleep(intervalMs); 61 + } 62 + 63 + const res = await fetch(`${baseUrl}/api/auth/device/token`, { 64 + method: "POST", 65 + headers: { "Content-Type": "application/json" }, 66 + body: JSON.stringify({ 67 + grant_type: "urn:ietf:params:oauth:grant-type:device_code", 68 + device_code: deviceCode, 69 + client_id: clientId, 70 + }), 71 + }); 72 + 73 + const body = (await res.json().catch(() => ({}))) as { 74 + access_token?: string; 75 + error?: string; 76 + error_description?: string; 77 + }; 78 + 79 + if (res.ok && typeof body.access_token === "string") { 80 + return body.access_token; 81 + } 82 + 83 + const err = body.error; 84 + if (err === "authorization_pending") { 85 + log("Waiting for device approval…"); 86 + continue; 87 + } 88 + if (err === "slow_down") { 89 + intervalMs += 5000; 90 + log(`Rate limited (slow_down); polling every ${intervalMs / 1000}s`); 91 + continue; 92 + } 93 + if (err === "access_denied") { 94 + throw new Error("Device authorization was denied."); 95 + } 96 + if (err === "expired_token") { 97 + throw new Error("Device code expired; start login again."); 98 + } 99 + 100 + throw new Error( 101 + `device/token failed (${res.status}): ${JSON.stringify(body)}`, 102 + ); 103 + } 104 + 105 + throw new Error("Device authorization timed out waiting for approval."); 106 + }
+63
apps/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 + }
+14
apps/mcp/src/index.ts
··· 1 + #!/usr/bin/env node 2 + import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 + import { createMcpServer } from "./server.js"; 4 + 5 + async function main(): Promise<void> { 6 + const server = createMcpServer(); 7 + const transport = new StdioServerTransport(); 8 + await server.connect(transport); 9 + } 10 + 11 + main().catch((err: unknown) => { 12 + console.error(err); 13 + process.exit(1); 14 + });
+91
apps/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 + });
+66
apps/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 res = await fetch(url, { ...init, headers }); 34 + 35 + if (res.status === 401 && !didRetry) { 36 + await this.auth.clearToken(); 37 + return this.authorizedFetch(path, init, true); 38 + } 39 + 40 + return res; 41 + } 42 + 43 + async json<T = Json>(path: string, init?: RequestInit): Promise<T> { 44 + const res = await this.authorizedFetch(path, init); 45 + const text = await res.text(); 46 + let body: unknown = null; 47 + if (text) { 48 + try { 49 + body = JSON.parse(text) as unknown; 50 + } catch { 51 + body = text; 52 + } 53 + } 54 + if (!res.ok) { 55 + const message = 56 + typeof body === "object" && 57 + body !== null && 58 + "message" in body && 59 + typeof (body as { message: unknown }).message === "string" 60 + ? (body as { message: string }).message 61 + : `HTTP ${res.status}`; 62 + throw new Error(`${path}: ${message} ${text}`); 63 + } 64 + return body as T; 65 + } 66 + }
+22
apps/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 + const body = buildFullTaskUpdateBody( 7 + { 8 + title: "T", 9 + description: "D", 10 + status: "open", 11 + priority: "low", 12 + projectId: "p1", 13 + position: 1, 14 + userId: "u1", 15 + }, 16 + { status: "done" }, 17 + ); 18 + expect(body.status).toBe("done"); 19 + expect(body.title).toBe("T"); 20 + expect(body.position).toBe(1); 21 + }); 22 + });
+127
apps/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 + const userId = 79 + patch.userId !== undefined 80 + ? patch.userId === null 81 + ? "" 82 + : patch.userId 83 + : typeof existing.userId === "string" 84 + ? existing.userId 85 + : undefined; 86 + 87 + const startDate = formatOptionalIso( 88 + patch.startDate !== undefined ? patch.startDate : existing.startDate, 89 + ); 90 + const dueDate = formatOptionalIso( 91 + patch.dueDate !== undefined ? patch.dueDate : existing.dueDate, 92 + ); 93 + 94 + const body: Record<string, string | number | undefined> = { 95 + title, 96 + description, 97 + status, 98 + priority: priorityRaw, 99 + projectId, 100 + position, 101 + }; 102 + 103 + if (startDate !== undefined) { 104 + body.startDate = startDate; 105 + } 106 + if (dueDate !== undefined) { 107 + body.dueDate = dueDate; 108 + } 109 + if (userId !== undefined) { 110 + body.userId = userId; 111 + } 112 + 113 + return body; 114 + } 115 + 116 + function formatOptionalIso(value: unknown): string | undefined { 117 + if (value === null || value === undefined) { 118 + return undefined; 119 + } 120 + if (value instanceof Date) { 121 + return value.toISOString(); 122 + } 123 + if (typeof value === "string") { 124 + return value; 125 + } 126 + return undefined; 127 + }
+20
apps/mcp/src/server.ts
··· 1 + import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 + import { AuthService } from "./auth/auth-service.js"; 3 + import { KaneoClient } from "./kaneo/client.js"; 4 + import { registerTools } from "./tools/register.js"; 5 + import { normalizeBaseUrl } from "./utils/normalize-base-url.js"; 6 + 7 + export function createMcpServer(): McpServer { 8 + const baseUrl = normalizeBaseUrl( 9 + process.env.KANEO_API_URL || "http://localhost:1337", 10 + ); 11 + const clientId = process.env.KANEO_MCP_CLIENT_ID || "kaneo-mcp"; 12 + const auth = new AuthService({ baseUrl, clientId }); 13 + const client = new KaneoClient({ baseUrl, auth }); 14 + const server = new McpServer({ 15 + name: "kaneo-mcp", 16 + version: "0.1.0", 17 + }); 18 + registerTools(server, { client }); 19 + return server; 20 + }
+141
apps/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 + handler: (args: Record<string, unknown>) => Promise<{ 7 + content: Array<{ type: string; text: string }>; 8 + isError?: boolean; 9 + }>; 10 + }; 11 + 12 + function createServerMock() { 13 + const tools = new Map<string, RegisteredTool["handler"]>(); 14 + 15 + return { 16 + server: { 17 + registerTool: vi.fn( 18 + ( 19 + name: string, 20 + _config: unknown, 21 + handler: RegisteredTool["handler"], 22 + ) => { 23 + tools.set(name, handler); 24 + }, 25 + ), 26 + }, 27 + tools, 28 + }; 29 + } 30 + 31 + describe("registerTools", () => { 32 + it("registers the MCP tools", () => { 33 + const { server } = createServerMock(); 34 + const client = { json: vi.fn() }; 35 + 36 + registerTools(server as never, { client: client as never }); 37 + 38 + expect(server.registerTool).toHaveBeenCalled(); 39 + expect(server.registerTool).toHaveBeenCalledWith( 40 + "whoami", 41 + expect.any(Object), 42 + expect.any(Function), 43 + ); 44 + expect(server.registerTool).toHaveBeenCalledWith( 45 + "update_task", 46 + expect.any(Object), 47 + expect.any(Function), 48 + ); 49 + }); 50 + 51 + it("builds the expected query string for list_tasks", async () => { 52 + const { server, tools } = createServerMock(); 53 + const client = { 54 + json: vi.fn().mockResolvedValue([{ id: "task-1" }]), 55 + }; 56 + 57 + registerTools(server as never, { client: client as never }); 58 + 59 + const result = await tools.get("list_tasks")?.({ 60 + projectId: "project 1", 61 + status: "open", 62 + page: 2, 63 + sortOrder: "desc", 64 + }); 65 + 66 + expect(client.json).toHaveBeenCalledWith( 67 + "/api/task/tasks/project%201?status=open&page=2&sortOrder=desc", 68 + { method: "GET" }, 69 + ); 70 + expect(result).toEqual({ 71 + content: [ 72 + { 73 + type: "text", 74 + text: JSON.stringify([{ id: "task-1" }], null, 2), 75 + }, 76 + ], 77 + isError: false, 78 + }); 79 + }); 80 + 81 + it("fetches the current task and sends a full body for update_task", async () => { 82 + const { server, tools } = createServerMock(); 83 + const client = { 84 + json: vi 85 + .fn() 86 + .mockResolvedValueOnce({ 87 + title: "Draft spec", 88 + description: "Write docs", 89 + status: "open", 90 + priority: "medium", 91 + projectId: "project-1", 92 + position: 4, 93 + }) 94 + .mockResolvedValueOnce({ id: "task-1", status: "done" }), 95 + }; 96 + 97 + registerTools(server as never, { client: client as never }); 98 + 99 + const result = await tools.get("update_task")?.({ 100 + taskId: "task-1", 101 + status: "done", 102 + }); 103 + 104 + expect(client.json).toHaveBeenNthCalledWith(1, "/api/task/task-1", { 105 + method: "GET", 106 + }); 107 + expect(client.json).toHaveBeenNthCalledWith(2, "/api/task/task-1", { 108 + method: "PUT", 109 + body: JSON.stringify({ 110 + title: "Draft spec", 111 + description: "Write docs", 112 + status: "done", 113 + priority: "medium", 114 + projectId: "project-1", 115 + position: 4, 116 + }), 117 + }); 118 + expect(result?.isError).toBe(false); 119 + }); 120 + 121 + it("returns an MCP error result when the client request fails", async () => { 122 + const { server, tools } = createServerMock(); 123 + const client = { 124 + json: vi.fn().mockRejectedValue(new Error("boom")), 125 + }; 126 + 127 + registerTools(server as never, { client: client as never }); 128 + 129 + const result = await tools.get("whoami")?.({}); 130 + 131 + expect(result).toEqual({ 132 + content: [ 133 + { 134 + type: "text", 135 + text: JSON.stringify({ error: "boom" }, null, 2), 136 + }, 137 + ], 138 + isError: true, 139 + }); 140 + }); 141 + });
+412
apps/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 + function run(fn: () => Promise<unknown>): Promise<CallToolResult> { 17 + return fn() 18 + .then((data) => textResult(data)) 19 + .catch((e: unknown) => 20 + errorResult(e instanceof Error ? e.message : String(e)), 21 + ); 22 + } 23 + 24 + export function registerTools( 25 + server: McpServer, 26 + ctx: { client: KaneoClient }, 27 + ): void { 28 + const { client } = ctx; 29 + 30 + server.registerTool( 31 + "whoami", 32 + { 33 + description: 34 + "Return the current Kaneo session and user for the cached device token.", 35 + inputSchema: z.object({}), 36 + }, 37 + async () => 38 + run(() => client.json("/api/auth/get-session", { method: "GET" })), 39 + ); 40 + 41 + server.registerTool( 42 + "list_workspaces", 43 + { 44 + description: 45 + "List workspaces (Better Auth organizations) the signed-in user can access.", 46 + inputSchema: z.object({}), 47 + }, 48 + async () => 49 + run(() => client.json("/api/auth/organization/list", { method: "GET" })), 50 + ); 51 + 52 + server.registerTool( 53 + "list_projects", 54 + { 55 + description: "List projects in a workspace.", 56 + inputSchema: z.object({ 57 + workspaceId: z.string().describe("Workspace ID"), 58 + includeArchived: z 59 + .boolean() 60 + .optional() 61 + .describe("Include archived projects"), 62 + }), 63 + }, 64 + async (args) => { 65 + const { workspaceId, includeArchived } = args; 66 + const qs = new URLSearchParams({ workspaceId }); 67 + if (includeArchived === true) { 68 + qs.set("includeArchived", "true"); 69 + } 70 + return run(() => 71 + client.json(`/api/project?${qs.toString()}`, { method: "GET" }), 72 + ); 73 + }, 74 + ); 75 + 76 + server.registerTool( 77 + "get_project", 78 + { 79 + description: "Get a single project by ID.", 80 + inputSchema: z.object({ id: z.string() }), 81 + }, 82 + async (args) => 83 + run(() => client.json(`/api/project/${encodeURIComponent(args.id)}`)), 84 + ); 85 + 86 + server.registerTool( 87 + "create_project", 88 + { 89 + description: "Create a project in a workspace.", 90 + inputSchema: z.object({ 91 + name: z.string(), 92 + workspaceId: z.string(), 93 + icon: z.string(), 94 + slug: z.string(), 95 + }), 96 + }, 97 + async (args) => 98 + run(() => 99 + client.json("/api/project", { 100 + method: "POST", 101 + body: JSON.stringify({ 102 + name: args.name, 103 + workspaceId: args.workspaceId, 104 + icon: args.icon, 105 + slug: args.slug, 106 + }), 107 + }), 108 + ), 109 + ); 110 + 111 + server.registerTool( 112 + "update_project", 113 + { 114 + description: "Update project metadata.", 115 + inputSchema: z.object({ 116 + id: z.string(), 117 + name: z.string(), 118 + icon: z.string(), 119 + slug: z.string(), 120 + description: z.string(), 121 + isPublic: z.boolean(), 122 + }), 123 + }, 124 + async (args) => 125 + run(() => 126 + client.json(`/api/project/${encodeURIComponent(args.id)}`, { 127 + method: "PUT", 128 + body: JSON.stringify({ 129 + name: args.name, 130 + icon: args.icon, 131 + slug: args.slug, 132 + description: args.description, 133 + isPublic: args.isPublic, 134 + }), 135 + }), 136 + ), 137 + ); 138 + 139 + const listTasksSchema = z.object({ 140 + projectId: z.string(), 141 + status: z.string().optional(), 142 + priority: z.string().optional(), 143 + assigneeId: z.string().optional(), 144 + page: z.number().int().positive().optional(), 145 + limit: z.number().int().positive().optional(), 146 + sortBy: z 147 + .enum(["createdAt", "priority", "dueDate", "position", "title", "number"]) 148 + .optional(), 149 + sortOrder: z.enum(["asc", "desc"]).optional(), 150 + dueBefore: z.string().optional(), 151 + dueAfter: z.string().optional(), 152 + }); 153 + 154 + server.registerTool( 155 + "list_tasks", 156 + { 157 + description: "List tasks for a project (optionally filtered/sorted).", 158 + inputSchema: listTasksSchema, 159 + }, 160 + async (args) => { 161 + const { projectId, ...rest } = args; 162 + const qs = new URLSearchParams(); 163 + for (const [k, v] of Object.entries(rest)) { 164 + if (v === undefined || v === null) { 165 + continue; 166 + } 167 + qs.set(k, String(v)); 168 + } 169 + const q = qs.toString(); 170 + const path = `/api/task/tasks/${encodeURIComponent(projectId)}${q ? `?${q}` : ""}`; 171 + return run(() => client.json(path, { method: "GET" })); 172 + }, 173 + ); 174 + 175 + server.registerTool( 176 + "get_task", 177 + { 178 + description: "Get a task by ID.", 179 + inputSchema: z.object({ taskId: z.string() }), 180 + }, 181 + async (args) => 182 + run(() => 183 + client.json(`/api/task/${encodeURIComponent(args.taskId)}`, { 184 + method: "GET", 185 + }), 186 + ), 187 + ); 188 + 189 + server.registerTool( 190 + "create_task", 191 + { 192 + description: "Create a task in a project.", 193 + inputSchema: z.object({ 194 + projectId: z.string(), 195 + title: z.string(), 196 + description: z.string(), 197 + priority: prioritySchema, 198 + status: z.string(), 199 + startDate: z.string().optional(), 200 + dueDate: z.string().optional(), 201 + userId: z.string().optional(), 202 + }), 203 + }, 204 + async (args) => { 205 + const body: Record<string, string | undefined> = { 206 + title: args.title, 207 + description: args.description, 208 + priority: args.priority, 209 + status: args.status, 210 + }; 211 + if (args.startDate !== undefined) { 212 + body.startDate = args.startDate; 213 + } 214 + if (args.dueDate !== undefined) { 215 + body.dueDate = args.dueDate; 216 + } 217 + if (args.userId !== undefined) { 218 + body.userId = args.userId; 219 + } 220 + return run(() => 221 + client.json(`/api/task/${encodeURIComponent(args.projectId)}`, { 222 + method: "POST", 223 + body: JSON.stringify(body), 224 + }), 225 + ); 226 + }, 227 + ); 228 + 229 + const updateTaskSchema = z.object({ 230 + taskId: z.string(), 231 + title: z.string().optional(), 232 + description: z.string().nullable().optional(), 233 + status: z.string().optional(), 234 + priority: prioritySchema.optional(), 235 + projectId: z.string().optional(), 236 + position: z.number().optional(), 237 + startDate: z.string().nullable().optional(), 238 + dueDate: z.string().nullable().optional(), 239 + userId: z.string().nullable().optional(), 240 + }); 241 + 242 + server.registerTool( 243 + "update_task", 244 + { 245 + description: 246 + "Update a task (fetches current task, merges fields, then full update).", 247 + inputSchema: updateTaskSchema, 248 + }, 249 + async (args) => { 250 + const { taskId, ...patch } = args; 251 + return run(async () => { 252 + const existing = (await client.json( 253 + `/api/task/${encodeURIComponent(taskId)}`, 254 + { method: "GET" }, 255 + )) as Record<string, unknown>; 256 + const body = buildFullTaskUpdateBody(existing, patch); 257 + return client.json(`/api/task/${encodeURIComponent(taskId)}`, { 258 + method: "PUT", 259 + body: JSON.stringify(body), 260 + }); 261 + }); 262 + }, 263 + ); 264 + 265 + server.registerTool( 266 + "move_task", 267 + { 268 + description: 269 + "Move a task to another project (and optional column status).", 270 + inputSchema: z.object({ 271 + taskId: z.string(), 272 + destinationProjectId: z.string(), 273 + destinationStatus: z.string().optional(), 274 + }), 275 + }, 276 + async (args) => 277 + run(() => 278 + client.json(`/api/task/move/${encodeURIComponent(args.taskId)}`, { 279 + method: "PUT", 280 + body: JSON.stringify({ 281 + destinationProjectId: args.destinationProjectId, 282 + ...(args.destinationStatus !== undefined 283 + ? { destinationStatus: args.destinationStatus } 284 + : {}), 285 + }), 286 + }), 287 + ), 288 + ); 289 + 290 + server.registerTool( 291 + "update_task_status", 292 + { 293 + description: "Update only the status (column) of a task.", 294 + inputSchema: z.object({ 295 + taskId: z.string(), 296 + status: z.string(), 297 + }), 298 + }, 299 + async (args) => 300 + run(() => 301 + client.json(`/api/task/status/${encodeURIComponent(args.taskId)}`, { 302 + method: "PUT", 303 + body: JSON.stringify({ status: args.status }), 304 + }), 305 + ), 306 + ); 307 + 308 + server.registerTool( 309 + "list_task_comments", 310 + { 311 + description: "List comments on a task.", 312 + inputSchema: z.object({ taskId: z.string() }), 313 + }, 314 + async (args) => 315 + run(() => 316 + client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, { 317 + method: "GET", 318 + }), 319 + ), 320 + ); 321 + 322 + server.registerTool( 323 + "create_task_comment", 324 + { 325 + description: "Add a comment to a task.", 326 + inputSchema: z.object({ 327 + taskId: z.string(), 328 + content: z.string().min(1), 329 + }), 330 + }, 331 + async (args) => 332 + run(() => 333 + client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, { 334 + method: "POST", 335 + body: JSON.stringify({ content: args.content }), 336 + }), 337 + ), 338 + ); 339 + 340 + server.registerTool( 341 + "list_workspace_labels", 342 + { 343 + description: "List labels defined in a workspace.", 344 + inputSchema: z.object({ workspaceId: z.string() }), 345 + }, 346 + async (args) => 347 + run(() => 348 + client.json( 349 + `/api/label/workspace/${encodeURIComponent(args.workspaceId)}`, 350 + { method: "GET" }, 351 + ), 352 + ), 353 + ); 354 + 355 + server.registerTool( 356 + "create_label", 357 + { 358 + description: 359 + "Create a label in a workspace (optionally attach to a task).", 360 + inputSchema: z.object({ 361 + name: z.string(), 362 + color: z.string(), 363 + workspaceId: z.string(), 364 + taskId: z.string().optional(), 365 + }), 366 + }, 367 + async (args) => 368 + run(() => 369 + client.json("/api/label", { 370 + method: "POST", 371 + body: JSON.stringify({ 372 + name: args.name, 373 + color: args.color, 374 + workspaceId: args.workspaceId, 375 + ...(args.taskId !== undefined ? { taskId: args.taskId } : {}), 376 + }), 377 + }), 378 + ), 379 + ); 380 + 381 + server.registerTool( 382 + "attach_label_to_task", 383 + { 384 + description: "Attach an existing label to a task.", 385 + inputSchema: z.object({ 386 + labelId: z.string(), 387 + taskId: z.string(), 388 + }), 389 + }, 390 + async (args) => 391 + run(() => 392 + client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, { 393 + method: "PUT", 394 + body: JSON.stringify({ taskId: args.taskId }), 395 + }), 396 + ), 397 + ); 398 + 399 + server.registerTool( 400 + "detach_label_from_task", 401 + { 402 + description: "Detach a label from its current task.", 403 + inputSchema: z.object({ labelId: z.string() }), 404 + }, 405 + async (args) => 406 + run(() => 407 + client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, { 408 + method: "DELETE", 409 + }), 410 + ), 411 + ); 412 + }
+17
apps/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 + return { 5 + content: [ 6 + { 7 + type: "text", 8 + text: typeof data === "string" ? data : JSON.stringify(data, null, 2), 9 + }, 10 + ], 11 + isError, 12 + }; 13 + } 14 + 15 + export function errorResult(message: string): CallToolResult { 16 + return textResult({ error: message }, true); 17 + }
+16
apps/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 + expect(normalizeBaseUrl("http://localhost:1337/")).toBe( 7 + "http://localhost:1337", 8 + ); 9 + }); 10 + 11 + it("removes /api suffix from path", () => { 12 + expect(normalizeBaseUrl("http://localhost:1337/api")).toBe( 13 + "http://localhost:1337", 14 + ); 15 + }); 16 + });
+17
apps/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
apps/mcp/tsconfig.json
··· 1 + { 2 + "extends": "../../packages/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
apps/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 + });
+47
pnpm-lock.yaml
··· 158 158 specifier: ^4.1.2 159 159 version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@25.4.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) 160 160 161 + apps/mcp: 162 + dependencies: 163 + '@modelcontextprotocol/sdk': 164 + specifier: ^1.26.0 165 + version: 1.26.0(zod@3.25.76) 166 + open: 167 + specifier: ^10.1.0 168 + version: 10.2.0 169 + zod: 170 + specifier: ^3.25.0 171 + version: 3.25.76 172 + devDependencies: 173 + '@kaneo/typescript-config': 174 + specifier: workspace:* 175 + version: link:../../packages/typescript-config 176 + '@types/node': 177 + specifier: ^25.3.5 178 + version: 25.5.0 179 + tsx: 180 + specifier: ^4.21.0 181 + version: 4.21.0 182 + typescript: 183 + specifier: ^5.9.3 184 + version: 5.9.3 185 + vitest: 186 + specifier: ^4.1.2 187 + 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)) 188 + 161 189 apps/site: 162 190 dependencies: 163 191 '@base-ui/react': ··· 6405 6433 oniguruma-to-es@4.3.4: 6406 6434 resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} 6407 6435 6436 + open@10.2.0: 6437 + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} 6438 + engines: {node: '>=18'} 6439 + 6408 6440 open@11.0.0: 6409 6441 resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} 6410 6442 engines: {node: '>=20'} ··· 7651 7683 optional: true 7652 7684 utf-8-validate: 7653 7685 optional: true 7686 + 7687 + wsl-utils@0.1.0: 7688 + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} 7689 + engines: {node: '>=18'} 7654 7690 7655 7691 wsl-utils@0.3.1: 7656 7692 resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} ··· 14933 14969 regex: 6.1.0 14934 14970 regex-recursion: 6.0.2 14935 14971 14972 + open@10.2.0: 14973 + dependencies: 14974 + default-browser: 5.5.0 14975 + define-lazy-prop: 3.0.0 14976 + is-inside-container: 1.0.0 14977 + wsl-utils: 0.1.0 14978 + 14936 14979 open@11.0.0: 14937 14980 dependencies: 14938 14981 default-browser: 5.5.0 ··· 16567 16610 wrappy@1.0.2: {} 16568 16611 16569 16612 ws@8.17.1: {} 16613 + 16614 + wsl-utils@0.1.0: 16615 + dependencies: 16616 + is-wsl: 3.1.1 16570 16617 16571 16618 wsl-utils@0.3.1: 16572 16619 dependencies: