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(mcp): harden install/auth tools and align device auth docs

- Optional DEVICE_AUTH_CLIENT_IDS docs; prepublishOnly via pnpm
- Install: flag parsing, merge-all-then-write, resilient JSON parse
- Auth: fail-open comment; device poll timeout after fetch
- Tools: partial update_project; stable MCP text results; tests AAA
- Web: add nanostores for better-auth org client resolution in Vite

Tin d7b4cda2 f5b726ba

+219 -79
+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
+2 -2
apps/docs/core/installation/environment-variables.mdx
··· 50 50 Name | Description | 51 51 --- | --- | 52 52 | `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. 53 - | `DEVICE_AUTH_CLIENT_IDS` | Comma-separated list of allowed device authorization client IDs. When unset, Kaneo allows `kaneo-cli` and `kaneo-mcp` by default. Use this to permit trusted external app identifiers such as `kaneo-cli,kaneo-mcp,my-desktop-app`. | 53 + | `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`. | 54 54 55 55 56 56 ## Optional variables ··· 139 139 - 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). 140 140 - 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). 141 141 - If you have enabled SMTP, your sign in will be done via email using a magic link. 142 - - 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. 142 + - 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.
+1 -1
apps/mcp/package.json
··· 28 28 ], 29 29 "scripts": { 30 30 "build": "tsc -p tsconfig.json", 31 - "prepublishOnly": "npm run build", 31 + "prepublishOnly": "pnpm run build", 32 32 "dev": "tsx watch src/index.ts", 33 33 "lint": "biome check --write .", 34 34 "start": "node dist/index.js",
+4
apps/mcp/src/auth/auth-service.ts
··· 65 65 cached.accessToken 66 66 ) { 67 67 const validation = await this.validateAccessToken(cached.accessToken); 68 + // Fail-open for "unknown": validateAccessToken returns "unknown" on transient HTTP/network 69 + // errors or ambiguous responses. Treating "unknown" like "valid" avoids clearToken() and a full 70 + // device re-auth so flaky connectivity does not wipe cached.accessToken; only "invalid" forces 71 + // a fresh login. Tests should cover both unknown and invalid paths. 68 72 if (validation === "valid" || validation === "unknown") { 69 73 return cached.accessToken; 70 74 }
+4
apps/mcp/src/auth/device-flow.ts
··· 70 70 }), 71 71 }); 72 72 73 + if (Date.now() - started > maxWait) { 74 + throw new Error("Device authorization timed out waiting for approval."); 75 + } 76 + 73 77 const body = (await res.json().catch(() => ({}))) as { 74 78 access_token?: string; 75 79 error?: string;
+28 -10
apps/mcp/src/install/index.ts
··· 10 10 promptTargetSelect, 11 11 } from "./wizard.js"; 12 12 13 + /** True if `v` looks like a CLI flag (`--long` or single-letter `-y`), not a value like `-my-server`. */ 14 + function isFlagLikeToken(v: string): boolean { 15 + if (v.startsWith("--")) { 16 + return true; 17 + } 18 + return /^-[A-Za-z]$/.test(v); 19 + } 20 + 13 21 export type ParsedInstallArgs = { 14 22 target?: string; 15 23 output?: string; ··· 44 52 } 45 53 if (a === "--target") { 46 54 const v = argv[i + 1]; 47 - if (!v || v.startsWith("-")) { 55 + if (!v || isFlagLikeToken(v)) { 48 56 throw new Error("--target requires a value"); 49 57 } 50 58 target = v; ··· 57 65 } 58 66 if (a === "--output") { 59 67 const v = argv[i + 1]; 60 - if (!v || v.startsWith("-")) { 68 + if (!v || isFlagLikeToken(v)) { 61 69 throw new Error("--output requires a value"); 62 70 } 63 71 output = v; ··· 70 78 } 71 79 if (a === "--name") { 72 80 const v = argv[i + 1]; 73 - if (!v || v.startsWith("-")) { 81 + if (!v || isFlagLikeToken(v)) { 74 82 throw new Error("--name requires a value"); 75 83 } 76 84 name = v; ··· 83 91 } 84 92 if (a === "--api-url") { 85 93 const v = argv[i + 1]; 86 - if (!v || v.startsWith("-")) { 94 + if (!v || isFlagLikeToken(v)) { 87 95 throw new Error("--api-url requires a value"); 88 96 } 89 97 apiUrl = v; ··· 96 104 } 97 105 if (a === "--project-dir") { 98 106 const v = argv[i + 1]; 99 - if (!v || v.startsWith("-")) { 107 + if (!v || isFlagLikeToken(v)) { 100 108 throw new Error("--project-dir requires a value"); 101 109 } 102 110 projectDir = v; ··· 217 225 }; 218 226 219 227 const writtenPaths: string[] = []; 228 + const pending: Array<{ configPath: string; existingText: string | null }> = 229 + []; 220 230 221 231 for (const targetId of targetIds) { 222 232 const configPath = resolveTargetConfigPath(targetId, { ··· 249 259 } 250 260 } 251 261 262 + pending.push({ configPath, existingText }); 263 + } 264 + 265 + const mergedWrites: Array<{ configPath: string; merged: string }> = []; 266 + for (const p of pending) { 252 267 let merged: string; 253 268 try { 254 - merged = mergeMcpServerEntry(existingText, parsed.name, serverConfig); 269 + merged = mergeMcpServerEntry(p.existingText, parsed.name, serverConfig); 255 270 } catch { 256 271 console.error( 257 - `Cannot update config (invalid JSON or merge error): ${configPath}`, 272 + `Cannot update config (invalid JSON or merge error): ${p.configPath}`, 258 273 ); 259 274 process.exitCode = 1; 260 275 return; 261 276 } 277 + mergedWrites.push({ configPath: p.configPath, merged }); 278 + } 262 279 263 - await mkdir(dirname(configPath), { recursive: true }); 264 - await writeFile(configPath, merged, { encoding: "utf8", mode: 0o600 }); 265 - writtenPaths.push(configPath); 280 + for (const w of mergedWrites) { 281 + await mkdir(dirname(w.configPath), { recursive: true }); 282 + await writeFile(w.configPath, w.merged, { encoding: "utf8", mode: 0o600 }); 283 + writtenPaths.push(w.configPath); 266 284 } 267 285 268 286 if (writtenPaths.length === 0) {
+13 -7
apps/mcp/src/install/merge-config.ts
··· 15 15 ): string { 16 16 let root: Record<string, unknown> = {}; 17 17 if (existingJson) { 18 - const parsed: unknown = JSON.parse(existingJson); 19 - if ( 20 - typeof parsed === "object" && 21 - parsed !== null && 22 - !Array.isArray(parsed) 23 - ) { 24 - root = { ...(parsed as Record<string, unknown>) }; 18 + try { 19 + const parsed: unknown = JSON.parse(existingJson); 20 + if ( 21 + typeof parsed === "object" && 22 + parsed !== null && 23 + !Array.isArray(parsed) 24 + ) { 25 + root = { ...(parsed as Record<string, unknown>) }; 26 + } 27 + } catch { 28 + console.warn( 29 + "[kaneo-mcp] Existing MCP config is not valid JSON; overwriting with a fresh object.", 30 + ); 25 31 } 26 32 } 27 33
+45 -13
apps/mcp/src/install/parse-install-args.test.ts
··· 3 3 4 4 describe("parseInstallArgs", () => { 5 5 it("parses flags", () => { 6 - expect( 7 - parseInstallArgs([ 8 - "--target", 9 - "cursor-user", 10 - "--name", 11 - "my-kaneo", 12 - "-y", 13 - "--api-url", 14 - "http://example.com", 15 - ]), 16 - ).toEqual({ 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 17 target: "cursor-user", 18 18 output: undefined, 19 19 name: "my-kaneo", ··· 21 21 apiUrl: "http://example.com", 22 22 projectDir: process.cwd(), 23 23 help: false, 24 - }); 24 + }; 25 + 26 + // Act 27 + const result = parseInstallArgs(argv); 28 + 29 + // Assert 30 + expect(result).toEqual(expected); 25 31 }); 26 32 27 33 it("throws on unknown option", () => { 28 - expect(() => parseInstallArgs(["--nope"])).toThrow("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); 29 61 }); 30 62 });
+17 -4
apps/mcp/src/install/wizard.ts
··· 1 + import path from "node:path"; 1 2 import prompts from "prompts"; 2 3 import { INSTALL_TARGETS, type InstallTargetId } from "./targets.js"; 3 4 ··· 41 42 type: "text", 42 43 name: "path", 43 44 message: "Absolute path to the JSON config file (create or update):", 44 - validate: (v) => 45 - typeof v === "string" && v.trim().length > 0 46 - ? true 47 - : "Path is required", 45 + validate: (v) => { 46 + if (typeof v !== "string") { 47 + return "Path is required"; 48 + } 49 + const trimmed = v.trim(); 50 + if (trimmed.length === 0) { 51 + return "Path is required"; 52 + } 53 + if (!path.isAbsolute(trimmed)) { 54 + return "Path must be absolute"; 55 + } 56 + if (!trimmed.toLowerCase().endsWith(".json")) { 57 + return "Path must end with .json"; 58 + } 59 + return true; 60 + }, 48 61 }, 49 62 { onCancel }, 50 63 );
+16 -12
apps/mcp/src/kaneo/task-helpers.test.ts
··· 3 3 4 4 describe("buildFullTaskUpdateBody", () => { 5 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 - ); 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 18 22 expect(body.status).toBe("done"); 19 23 expect(body.title).toBe("T"); 20 24 expect(body.position).toBe(1);
+3
apps/mcp/src/kaneo/task-helpers.ts
··· 75 75 throw new Error("Cannot update task: missing projectId."); 76 76 } 77 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). 78 81 const userId = 79 82 patch.userId !== undefined 80 83 ? patch.userId === null
+8 -4
apps/mcp/src/tools/register.test.ts
··· 105 105 expect(client.json).toHaveBeenNthCalledWith(1, "/api/task/task-1", { 106 106 method: "GET", 107 107 }); 108 - expect(client.json).toHaveBeenNthCalledWith(2, "/api/task/task-1", { 109 - method: "PUT", 110 - body: JSON.stringify({ 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({ 111 115 title: "Draft spec", 112 116 description: "Write docs", 113 117 status: "done", ··· 115 119 projectId: "project-1", 116 120 position: 4, 117 121 }), 118 - }); 122 + ); 119 123 expect(result?.isError).toBe(false); 120 124 }); 121 125
+52 -16
apps/mcp/src/tools/register.ts
··· 126 126 server.registerTool( 127 127 "update_project", 128 128 { 129 - description: "Update project metadata.", 129 + description: 130 + "Update project metadata (PATCH-style: only provided fields are changed).", 130 131 inputSchema: z.object({ 131 132 id: nonEmptyString, 132 - name: nonEmptyString, 133 - icon: nonEmptyString, 134 - slug: nonEmptyString, 135 - description: z.string(), 136 - isPublic: z.boolean(), 133 + name: optionalNonEmptyString, 134 + icon: z.string().optional(), 135 + slug: optionalNonEmptyString, 136 + description: z.string().optional(), 137 + isPublic: z.boolean().optional(), 137 138 }), 138 139 }, 139 - async (args) => 140 - run(() => 141 - client.json(`/api/project/${encodeURIComponent(args.id)}`, { 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 + : existing.icon != null 157 + ? String(existing.icon) 158 + : ""; 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 + : Boolean(existing.isPublic); 175 + 176 + return client.json(`/api/project/${encodeURIComponent(id)}`, { 142 177 method: "PUT", 143 178 body: JSON.stringify({ 144 - name: args.name, 145 - icon: args.icon, 146 - slug: args.slug, 147 - description: args.description, 148 - isPublic: args.isPublic, 179 + name, 180 + icon, 181 + slug, 182 + description, 183 + isPublic, 149 184 }), 150 - }), 151 - ), 185 + }); 186 + }); 187 + }, 152 188 ); 153 189 154 190 const listTasksSchema = z.object({
+3 -1
apps/mcp/src/utils/mcp-result.ts
··· 1 1 import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 2 2 3 3 export function textResult(data: unknown, isError = false): CallToolResult { 4 + const text = 5 + typeof data === "string" ? data : (JSON.stringify(data, null, 2) ?? ""); 4 6 return { 5 7 content: [ 6 8 { 7 9 type: "text", 8 - text: typeof data === "string" ? data : JSON.stringify(data, null, 2), 10 + text, 9 11 }, 10 12 ], 11 13 isError,
+16 -6
apps/mcp/src/utils/normalize-base-url.test.ts
··· 3 3 4 4 describe("normalizeBaseUrl", () => { 5 5 it("strips trailing slashes", () => { 6 - expect(normalizeBaseUrl("http://localhost:1337/")).toBe( 7 - "http://localhost:1337", 8 - ); 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"); 9 14 }); 10 15 11 16 it("removes /api suffix from path", () => { 12 - expect(normalizeBaseUrl("http://localhost:1337/api")).toBe( 13 - "http://localhost:1337", 14 - ); 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"); 15 25 }); 16 26 });
+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",
+3
pnpm-lock.yaml
··· 456 456 marked: 457 457 specifier: ^17.0.4 458 458 version: 17.0.4 459 + nanostores: 460 + specifier: ^1.1.1 461 + version: 1.1.1 459 462 radix-ui: 460 463 specifier: ^1.4.3 461 464 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)