@kaneo/mcp 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Kaneo MCP server
2
2
 
3
- `@kaneo/mcp` is a local MCP server for Kaneo.
3
+ `@kaneo/mcp` is a local stdio MCP server for Kaneo.
4
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 `apps/mcp` in this monorepo and exposes the `kaneo-mcp` CLI.
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
+ > **Tip:** Every Kaneo instance also ships a built-in HTTP MCP endpoint at `/api/mcp`. If your MCP client supports Streamable HTTP transport (e.g. Claude Code), you can connect directly without this package. See the [MCP docs](https://docs.kaneo.app/core/integrations/mcp) for details.
6
8
 
7
9
  ## Prerequisites
8
10
 
@@ -23,6 +25,7 @@ DEVICE_AUTH_CLIENT_IDS=kaneo-cli,kaneo-mcp,your-client-id
23
25
  |----------|-------------|
24
26
  | `KANEO_API_URL` | Kaneo API origin (default `http://localhost:1337`). Do not include `/api`. |
25
27
  | `KANEO_MCP_CLIENT_ID` | Device-flow client id (default `kaneo-mcp`). Must match `DEVICE_AUTH_CLIENT_IDS` on the server. |
28
+ | `KANEO_API_KEY` | **Optional.** A Kaneo API key (create one under Settings → Account → Developer). When set, the server authenticates with it as a Bearer token and skips the interactive device flow. Use this for headless/Docker setups. |
26
29
 
27
30
  ## Install
28
31
 
@@ -69,7 +72,7 @@ See all options:
69
72
  kaneo-mcp install --help
70
73
  ```
71
74
 
72
- If you are currently inside the local `apps/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 `apps/mcp`, or use a local build:
75
+ 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
76
 
74
77
  ```bash
75
78
  node dist/index.js
@@ -91,7 +94,7 @@ pnpm --filter @kaneo/mcp run test
91
94
  Or run it from the package directory:
92
95
 
93
96
  ```bash
94
- pnpm -C apps/mcp run build
97
+ pnpm -C packages/mcp run build
95
98
  ```
96
99
 
97
100
  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.
@@ -106,10 +109,21 @@ On the first tool call that needs Kaneo, the server:
106
109
  4. Polls `POST /api/auth/device/token` until approved
107
110
  5. Stores the access token at `~/.config/kaneo-mcp/credentials.json` with mode `0600`
108
111
 
112
+ ### Non-interactive (API key)
113
+
114
+ For headless or sandboxed environments where opening a browser is impractical, set `KANEO_API_KEY` to a key created under Settings → Account → Developer. The server sends it as a Bearer token on every request and skips the device flow entirely, so no token is cached to disk.
115
+
109
116
  ## Tools
110
117
 
111
118
  - Session: `whoami`, `list_workspaces`
112
119
  - Projects: `list_projects`, `get_project`, `create_project`, `update_project`
113
120
  - 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`
121
+ - Comments: `list_task_comments`, `create_task_comment`, `update_task_comment`, `delete_task_comment`
122
+ - Labels: `list_workspace_labels`, `create_label`, `attach_label_to_task`, `detach_label_from_task`, `delete_label`
123
+ - Task relations: `create_task_relation`, `get_task_relations`, `delete_task_relation`
124
+
125
+ ## Releasing
126
+
127
+ Bump `version` in `packages/mcp/package.json` and merge to `main`. The [publish workflow](../../.github/workflows/publish-mcp.yml) runs the package tests, publishes the new version to npm, and creates a `mcp-v<version>` GitHub release. Nothing is published while the version stays the same, so tool changes reach npm only once the version is bumped.
128
+
129
+ Publishing a GitHub release manually also works: tag it `mcp-v<version>` with the tag version matching `packages/mcp/package.json` on the tagged commit.
@@ -0,0 +1,24 @@
1
+ export type AuthServiceOptions = {
2
+ baseUrl: string;
3
+ clientId: string;
4
+ apiKey?: string;
5
+ };
6
+ export declare class AuthService {
7
+ readonly baseUrl: string;
8
+ readonly clientId: string;
9
+ private readonly apiKey?;
10
+ private activeGetAccessTokenPromise?;
11
+ constructor(options: AuthServiceOptions);
12
+ /**
13
+ * True when a pre-created Kaneo API key is used instead of the interactive
14
+ * device flow. Callers use this to avoid clearing/retrying auth on a 401.
15
+ */
16
+ get usingApiKey(): boolean;
17
+ clearToken(): Promise<void>;
18
+ private validateAccessToken;
19
+ private log;
20
+ /**
21
+ * Returns a valid access token, running the device authorization flow if needed.
22
+ */
23
+ getAccessToken(): Promise<string>;
24
+ }
@@ -4,17 +4,32 @@ import { clearCredentials, loadCredentials, saveCredentials, } from "./token-sto
4
4
  export class AuthService {
5
5
  baseUrl;
6
6
  clientId;
7
+ apiKey;
8
+ activeGetAccessTokenPromise;
7
9
  constructor(options) {
8
10
  this.baseUrl = options.baseUrl;
9
11
  this.clientId = options.clientId;
12
+ this.apiKey = options.apiKey;
13
+ }
14
+ /**
15
+ * True when a pre-created Kaneo API key is used instead of the interactive
16
+ * device flow. Callers use this to avoid clearing/retrying auth on a 401.
17
+ */
18
+ get usingApiKey() {
19
+ return Boolean(this.apiKey);
10
20
  }
11
21
  async clearToken() {
22
+ // Nothing to clear for API-key auth; the key is static config.
23
+ if (this.apiKey)
24
+ return;
12
25
  await clearCredentials();
13
26
  }
14
27
  async validateAccessToken(token) {
15
28
  try {
29
+ const signal = AbortSignal.timeout(10_000);
16
30
  const res = await fetch(`${this.baseUrl}/api/auth/get-session`, {
17
31
  headers: { Authorization: `Bearer ${token}` },
32
+ signal,
18
33
  });
19
34
  if (res.status === 401) {
20
35
  return "invalid";
@@ -36,38 +51,55 @@ export class AuthService {
36
51
  * Returns a valid access token, running the device authorization flow if needed.
37
52
  */
38
53
  async getAccessToken() {
39
- const cached = await loadCredentials();
40
- if (cached &&
41
- cached.baseUrl === this.baseUrl &&
42
- cached.clientId === this.clientId &&
43
- cached.accessToken) {
44
- const validation = await this.validateAccessToken(cached.accessToken);
45
- // Fail-open for "unknown": validateAccessToken returns "unknown" on transient HTTP/network
46
- // errors or ambiguous responses. Treating "unknown" like "valid" avoids clearToken() and a full
47
- // device re-auth so flaky connectivity does not wipe cached.accessToken; only "invalid" forces
48
- // a fresh login. Tests should cover both unknown and invalid paths.
49
- if (validation === "valid" || validation === "unknown") {
50
- return cached.accessToken;
51
- }
52
- await this.clearToken();
54
+ // Non-interactive auth: a pre-created Kaneo API key (KANEO_API_KEY) is sent
55
+ // as a Bearer token, which the REST API already accepts. This skips the
56
+ // device flow so the MCP server works in headless/Docker environments.
57
+ if (this.apiKey) {
58
+ return this.apiKey;
59
+ }
60
+ if (this.activeGetAccessTokenPromise) {
61
+ return await this.activeGetAccessTokenPromise;
53
62
  }
54
- const code = await requestDeviceCode(this.baseUrl, this.clientId);
55
- const verifyUrl = code.verification_uri_complete || code.verification_uri;
56
- this.log(`Open ${verifyUrl} and approve this device. User code: ${code.user_code}`);
63
+ this.activeGetAccessTokenPromise = (async () => {
64
+ const cached = await loadCredentials();
65
+ if (cached &&
66
+ cached.baseUrl === this.baseUrl &&
67
+ cached.clientId === this.clientId &&
68
+ cached.accessToken) {
69
+ const validation = await this.validateAccessToken(cached.accessToken);
70
+ // Fail-open for "unknown": validateAccessToken returns "unknown" on transient HTTP/network
71
+ // errors or ambiguous responses. Treating "unknown" like "valid" avoids clearToken() and a full
72
+ // device re-auth so flaky connectivity does not wipe cached.accessToken; only "invalid" forces
73
+ // a fresh login. Tests should cover both unknown and invalid paths.
74
+ if (validation === "valid" || validation === "unknown") {
75
+ return cached.accessToken;
76
+ }
77
+ await this.clearToken();
78
+ }
79
+ const code = await requestDeviceCode(this.baseUrl, this.clientId);
80
+ const verifyUrl = code.verification_uri_complete || code.verification_uri;
81
+ this.log(`Open ${verifyUrl} and approve this device. User code: ${code.user_code}`);
82
+ try {
83
+ await open(verifyUrl);
84
+ }
85
+ catch {
86
+ this.log("Could not open a browser automatically; use the URL above.");
87
+ }
88
+ const accessToken = await pollDeviceAccessToken(this.baseUrl, this.clientId, code.device_code, code.interval, { log: (m) => this.log(m) });
89
+ const toStore = {
90
+ version: 1,
91
+ baseUrl: this.baseUrl,
92
+ clientId: this.clientId,
93
+ accessToken,
94
+ };
95
+ await saveCredentials(toStore);
96
+ return accessToken;
97
+ })();
57
98
  try {
58
- await open(verifyUrl);
99
+ return await this.activeGetAccessTokenPromise;
59
100
  }
60
- catch {
61
- this.log("Could not open a browser automatically; use the URL above.");
101
+ finally {
102
+ this.activeGetAccessTokenPromise = undefined;
62
103
  }
63
- const accessToken = await pollDeviceAccessToken(this.baseUrl, this.clientId, code.device_code, code.interval, { log: (m) => this.log(m) });
64
- const toStore = {
65
- version: 1,
66
- baseUrl: this.baseUrl,
67
- clientId: this.clientId,
68
- accessToken,
69
- };
70
- await saveCredentials(toStore);
71
- return accessToken;
72
104
  }
73
105
  }
@@ -0,0 +1,21 @@
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
+ export type DeviceTokenErrorBody = {
10
+ error?: string;
11
+ error_description?: string;
12
+ };
13
+ export declare function requestDeviceCode(baseUrl: string, clientId: string): Promise<DeviceCodeResponse>;
14
+ /**
15
+ * Polls `/api/auth/device/token` until success or terminal error.
16
+ * First attempt is immediate; subsequent attempts wait `interval` seconds (increased on `slow_down`).
17
+ */
18
+ export declare function pollDeviceAccessToken(baseUrl: string, clientId: string, deviceCode: string, initialIntervalSec: number, options?: {
19
+ maxWaitMs?: number;
20
+ log?: (msg: string) => void;
21
+ }): Promise<string>;
@@ -1,17 +1,49 @@
1
+ const REQUEST_TIMEOUT_MS = 10_000;
1
2
  function sleep(ms) {
2
3
  return new Promise((r) => setTimeout(r, ms));
3
4
  }
5
+ function isAbortError(err) {
6
+ return err instanceof DOMException
7
+ ? err.name === "AbortError"
8
+ : err instanceof Error && err.name === "AbortError";
9
+ }
10
+ function isObjectRecord(value) {
11
+ return value !== null && typeof value === "object";
12
+ }
13
+ async function fetchWithTimeout(input, init, timeoutMs = REQUEST_TIMEOUT_MS) {
14
+ const controller = new AbortController();
15
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
16
+ try {
17
+ return await fetch(input, { ...init, signal: controller.signal });
18
+ }
19
+ finally {
20
+ clearTimeout(timeout);
21
+ }
22
+ }
4
23
  export async function requestDeviceCode(baseUrl, clientId) {
5
- const res = await fetch(`${baseUrl}/api/auth/device/code`, {
6
- method: "POST",
7
- headers: { "Content-Type": "application/json" },
8
- body: JSON.stringify({ client_id: clientId }),
9
- });
10
- const body = (await res.json().catch(() => ({})));
24
+ let res;
25
+ try {
26
+ res = await fetchWithTimeout(`${baseUrl}/api/auth/device/code`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({ client_id: clientId }),
30
+ });
31
+ }
32
+ catch (err) {
33
+ if (isAbortError(err)) {
34
+ throw new Error("device/code request timed out after 10s.");
35
+ }
36
+ throw err;
37
+ }
38
+ const parsedBody = await res.json().catch(() => ({}));
39
+ if (!isObjectRecord(parsedBody)) {
40
+ throw new Error(`device/code: unexpected response ${JSON.stringify(parsedBody)}`);
41
+ }
42
+ const body = parsedBody;
11
43
  if (!res.ok) {
12
44
  throw new Error(`device/code failed (${res.status}): ${JSON.stringify(body)}`);
13
45
  }
14
- if (!("device_code" in body) || typeof body.device_code !== "string") {
46
+ if (typeof body.device_code !== "string") {
15
47
  throw new Error(`device/code: unexpected response ${JSON.stringify(body)}`);
16
48
  }
17
49
  if (typeof body.user_code !== "string" ||
@@ -53,24 +85,41 @@ export async function pollDeviceAccessToken(baseUrl, clientId, deviceCode, initi
53
85
  for (let attempt = 0; Date.now() - started < maxWait; attempt++) {
54
86
  if (attempt > 0) {
55
87
  await sleep(intervalMs);
88
+ if (Date.now() - started >= maxWait) {
89
+ break;
90
+ }
56
91
  }
57
- const res = await fetch(`${baseUrl}/api/auth/device/token`, {
58
- method: "POST",
59
- headers: { "Content-Type": "application/json" },
60
- body: JSON.stringify({
61
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
62
- device_code: deviceCode,
63
- client_id: clientId,
64
- }),
65
- });
66
- if (Date.now() - started > maxWait) {
92
+ let res;
93
+ try {
94
+ res = await fetchWithTimeout(`${baseUrl}/api/auth/device/token`, {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({
98
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
99
+ device_code: deviceCode,
100
+ client_id: clientId,
101
+ }),
102
+ });
103
+ }
104
+ catch (err) {
105
+ if (isAbortError(err)) {
106
+ log("Device token poll request timed out; retrying.");
107
+ continue;
108
+ }
109
+ throw err;
110
+ }
111
+ if (Date.now() - started >= maxWait) {
67
112
  throw new Error("Device authorization timed out waiting for approval.");
68
113
  }
69
- const body = (await res.json().catch(() => ({})));
114
+ const parsedBody = await res.json().catch(() => ({}));
115
+ if (!isObjectRecord(parsedBody)) {
116
+ throw new Error(`device/token failed (${res.status}): ${JSON.stringify(parsedBody)}`);
117
+ }
118
+ const body = parsedBody;
70
119
  if (res.ok && typeof body.access_token === "string") {
71
120
  return body.access_token;
72
121
  }
73
- const err = body.error;
122
+ const err = typeof body.error === "string" ? body.error : undefined;
74
123
  if (err === "authorization_pending") {
75
124
  log("Waiting for device approval…");
76
125
  continue;
@@ -0,0 +1,10 @@
1
+ export type StoredCredentials = {
2
+ version: 1;
3
+ baseUrl: string;
4
+ clientId: string;
5
+ accessToken: string;
6
+ };
7
+ export declare function credentialsPath(): string;
8
+ export declare function loadCredentials(): Promise<StoredCredentials | null>;
9
+ export declare function saveCredentials(data: StoredCredentials): Promise<void>;
10
+ export declare function clearCredentials(): Promise<void>;
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runCli(): Promise<void>;
package/dist/cli.js CHANGED
@@ -35,7 +35,7 @@ async function startMcpServer() {
35
35
  await server.connect(transport);
36
36
  }
37
37
  function printMainHelp() {
38
- console.log(`kaneo-mcp — Kaneo MCP server (stdio transport)
38
+ console.log(`kaneo-mcp: Kaneo MCP server (stdio transport)
39
39
 
40
40
  Usage:
41
41
  npx @kaneo/mcp Interactive installer (terminal only; no global install)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js CHANGED
File without changes
@@ -0,0 +1,11 @@
1
+ export type ParsedInstallArgs = {
2
+ target?: string;
3
+ output?: string;
4
+ name: string;
5
+ yes: boolean;
6
+ apiUrl?: string;
7
+ projectDir: string;
8
+ help: boolean;
9
+ };
10
+ export declare function parseInstallArgs(argv: string[]): ParsedInstallArgs;
11
+ export declare function runInstall(argv: string[]): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
4
  import { mergeMcpServerEntry } from "./merge-config.js";
@@ -254,6 +254,12 @@ export async function runInstall(argv) {
254
254
  for (const w of mergedWrites) {
255
255
  await mkdir(dirname(w.configPath), { recursive: true });
256
256
  await writeFile(w.configPath, w.merged, { encoding: "utf8", mode: 0o600 });
257
+ try {
258
+ await chmod(w.configPath, 0o600);
259
+ }
260
+ catch {
261
+ /* ignore chmod failures on some FS */
262
+ }
257
263
  writtenPaths.push(w.configPath);
258
264
  }
259
265
  if (writtenPaths.length === 0) {
@@ -267,7 +273,7 @@ export async function runInstall(argv) {
267
273
  console.log("\nRestart your MCP client (or reload the window) if needed.");
268
274
  }
269
275
  function printInstallHelp() {
270
- console.log(`kaneo-mcp install — register Kaneo in an MCP client config
276
+ console.log(`kaneo-mcp install: register Kaneo in an MCP client config
271
277
 
272
278
  Usage:
273
279
  kaneo-mcp install [options]
@@ -0,0 +1,10 @@
1
+ export type McpServerEntry = {
2
+ command: string;
3
+ args: string[];
4
+ env?: Record<string, string>;
5
+ };
6
+ /**
7
+ * Merges or replaces `mcpServers[serverName]` and returns formatted JSON.
8
+ * Preserves other top-level keys and other MCP server entries.
9
+ */
10
+ export declare function mergeMcpServerEntry(existingJson: string | null, serverName: string, serverConfig: McpServerEntry): string;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Absolute path to this package's `dist/index.js` (the MCP stdio entry).
3
+ * Resolved from `dist/install/resolve-entry.js` at runtime.
4
+ */
5
+ export declare function resolvePackageEntryPath(): string;
@@ -0,0 +1,39 @@
1
+ export type InstallTargetId = "cursor-user" | "cursor-project" | "claude-desktop" | "custom";
2
+ export type InstallTarget = {
3
+ id: InstallTargetId;
4
+ label: string;
5
+ description: string;
6
+ };
7
+ /**
8
+ * Validates a non-interactive custom MCP config path (same rules as the install wizard).
9
+ * Returns the trimmed absolute path on success.
10
+ */
11
+ export declare function validateCustomConfigPathInput(raw: string): {
12
+ ok: true;
13
+ path: string;
14
+ } | {
15
+ ok: false;
16
+ message: string;
17
+ };
18
+ export declare const INSTALL_TARGETS: readonly [{
19
+ readonly id: "cursor-user";
20
+ readonly label: "Cursor (user-wide)";
21
+ readonly description: "~/.cursor/mcp.json, available in all projects";
22
+ }, {
23
+ readonly id: "cursor-project";
24
+ readonly label: "Cursor (this project only)";
25
+ readonly description: ".cursor/mcp.json in the current directory";
26
+ }, {
27
+ readonly id: "claude-desktop";
28
+ readonly label: "Claude Desktop";
29
+ readonly description: "claude_desktop_config.json for the Claude app";
30
+ }, {
31
+ readonly id: "custom";
32
+ readonly label: "Custom file path";
33
+ readonly description: "Any JSON file you choose (advanced)";
34
+ }];
35
+ export declare function getClaudeDesktopConfigPath(): string;
36
+ export declare function resolveTargetConfigPath(id: InstallTargetId, options: {
37
+ cwd: string;
38
+ customPath?: string;
39
+ }): string;
@@ -21,7 +21,7 @@ export const INSTALL_TARGETS = [
21
21
  {
22
22
  id: "cursor-user",
23
23
  label: "Cursor (user-wide)",
24
- description: "~/.cursor/mcp.json — available in all projects",
24
+ description: "~/.cursor/mcp.json, available in all projects",
25
25
  },
26
26
  {
27
27
  id: "cursor-project",
@@ -0,0 +1,4 @@
1
+ import { type InstallTargetId } from "./targets.js";
2
+ export declare function promptTargetSelect(): Promise<InstallTargetId[]>;
3
+ export declare function promptCustomConfigPath(): Promise<string>;
4
+ export declare function promptConfirmOverwrite(serverName: string, configPath: string): Promise<boolean>;
@@ -0,0 +1,14 @@
1
+ import type { AuthService } from "../auth/auth-service.js";
2
+ export type Json = null | boolean | number | string | Json[] | {
3
+ [key: string]: Json;
4
+ };
5
+ export declare class KaneoClient {
6
+ readonly baseUrl: string;
7
+ private readonly auth;
8
+ constructor(options: {
9
+ baseUrl: string;
10
+ auth: AuthService;
11
+ });
12
+ private authorizedFetch;
13
+ json<T = Json>(path: string, init?: RequestInit): Promise<T>;
14
+ }
@@ -13,8 +13,14 @@ export class KaneoClient {
13
13
  headers.set("Content-Type", "application/json");
14
14
  }
15
15
  const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
16
- const res = await fetch(url, { ...init, headers });
17
- if (res.status === 401 && !didRetry) {
16
+ const timeoutSignal = AbortSignal.timeout(10_000);
17
+ const signal = init?.signal
18
+ ? AbortSignal.any([init.signal, timeoutSignal])
19
+ : timeoutSignal;
20
+ const res = await fetch(url, { ...init, headers, signal });
21
+ // With a static API key there is nothing to refresh, so surface the 401
22
+ // instead of looping back into the interactive device flow.
23
+ if (res.status === 401 && !didRetry && !this.auth.usingApiKey) {
18
24
  await this.auth.clearToken();
19
25
  return this.authorizedFetch(path, init, true);
20
26
  }
@@ -0,0 +1,19 @@
1
+ declare const PRIORITIES: readonly ["no-priority", "low", "medium", "high", "urgent"];
2
+ export type TaskPriority = (typeof PRIORITIES)[number];
3
+ export declare function isTaskPriority(v: string): v is TaskPriority;
4
+ export type TaskUpdatePatch = {
5
+ title?: string;
6
+ description?: string | null;
7
+ status?: string;
8
+ priority?: TaskPriority;
9
+ projectId?: string;
10
+ position?: number;
11
+ startDate?: string | null;
12
+ dueDate?: string | null;
13
+ userId?: string | null;
14
+ };
15
+ /**
16
+ * Builds the JSON body for `PUT /api/task/:id` from an existing task plus a patch.
17
+ */
18
+ export declare function buildFullTaskUpdateBody(existing: Record<string, unknown>, patch: TaskUpdatePatch): Record<string, string | number | undefined>;
19
+ export {};
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function createMcpServer(): McpServer;
package/dist/server.js CHANGED
@@ -9,7 +9,8 @@ const { version: packageVersion } = require("../package.json");
9
9
  export function createMcpServer() {
10
10
  const baseUrl = normalizeBaseUrl(process.env.KANEO_API_URL || "http://localhost:1337");
11
11
  const clientId = process.env.KANEO_MCP_CLIENT_ID || "kaneo-mcp";
12
- const auth = new AuthService({ baseUrl, clientId });
12
+ const apiKey = process.env.KANEO_API_KEY || undefined;
13
+ const auth = new AuthService({ baseUrl, clientId, apiKey });
13
14
  const client = new KaneoClient({ baseUrl, auth });
14
15
  const server = new McpServer({
15
16
  name: "kaneo-mcp",
@@ -0,0 +1,5 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { KaneoClient } from "../kaneo/client.js";
3
+ export declare function registerTools(server: McpServer, ctx: {
4
+ client: KaneoClient;
5
+ }): void;
@@ -93,9 +93,9 @@ export function registerTools(server, ctx) {
93
93
  }
94
94
  const icon = patch.icon !== undefined
95
95
  ? patch.icon
96
- : existing.icon != null
97
- ? String(existing.icon)
98
- : "";
96
+ : typeof existing.icon === "string"
97
+ ? existing.icon
98
+ : "Layout";
99
99
  const slug = patch.slug ??
100
100
  (typeof existing.slug === "string" ? existing.slug : "");
101
101
  if (!slug) {
@@ -108,16 +108,13 @@ export function registerTools(server, ctx) {
108
108
  : "";
109
109
  const isPublic = patch.isPublic !== undefined
110
110
  ? patch.isPublic
111
- : Boolean(existing.isPublic);
111
+ : typeof existing.isPublic === "boolean"
112
+ ? existing.isPublic
113
+ : false;
114
+ const body = { name, icon, slug, description, isPublic };
112
115
  return client.json(`/api/project/${encodeURIComponent(id)}`, {
113
116
  method: "PUT",
114
- body: JSON.stringify({
115
- name,
116
- icon,
117
- slug,
118
- description,
119
- isPublic,
120
- }),
117
+ body: JSON.stringify(body),
121
118
  });
122
119
  });
123
120
  });
@@ -258,6 +255,22 @@ export function registerTools(server, ctx) {
258
255
  method: "POST",
259
256
  body: JSON.stringify({ content: args.content }),
260
257
  })));
258
+ server.registerTool("update_task_comment", {
259
+ description: "Update one of your comments on a task.",
260
+ inputSchema: z.object({
261
+ commentId: nonEmptyString,
262
+ content: nonEmptyString,
263
+ }),
264
+ }, async (args) => run(() => client.json(`/api/comment/${encodeURIComponent(args.commentId)}`, {
265
+ method: "PUT",
266
+ body: JSON.stringify({ content: args.content }),
267
+ })));
268
+ server.registerTool("delete_task_comment", {
269
+ description: "Delete one of your comments from a task.",
270
+ inputSchema: z.object({ commentId: nonEmptyString }),
271
+ }, async (args) => run(() => client.json(`/api/comment/${encodeURIComponent(args.commentId)}`, {
272
+ method: "DELETE",
273
+ })));
261
274
  server.registerTool("list_workspace_labels", {
262
275
  description: "List labels defined in a workspace.",
263
276
  inputSchema: z.object({ workspaceId: nonEmptyString }),
@@ -295,4 +308,43 @@ export function registerTools(server, ctx) {
295
308
  }, async (args) => run(() => client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, {
296
309
  method: "DELETE",
297
310
  })));
311
+ server.registerTool("create_task_relation", {
312
+ description: "Create a relation between two tasks. relationType: 'subtask' (sourceTaskId is the parent, targetTaskId the child), 'blocks' (sourceTaskId blocks targetTaskId), or 'related' (bidirectional).",
313
+ inputSchema: z.object({
314
+ sourceTaskId: nonEmptyString,
315
+ targetTaskId: nonEmptyString,
316
+ relationType: z.enum(["subtask", "blocks", "related"]),
317
+ }),
318
+ }, async (args) => run(() => client.json("/api/task-relation", {
319
+ method: "POST",
320
+ body: JSON.stringify({
321
+ sourceTaskId: args.sourceTaskId,
322
+ targetTaskId: args.targetTaskId,
323
+ relationType: args.relationType,
324
+ }),
325
+ })));
326
+ server.registerTool("get_task_relations", {
327
+ description: "List all relations (subtask/blocks/related) involving a task.",
328
+ inputSchema: z.object({ taskId: nonEmptyString }),
329
+ }, async (args) => run(() => client.json(`/api/task-relation/${encodeURIComponent(args.taskId)}`, {
330
+ method: "GET",
331
+ })));
332
+ server.registerTool("delete_task_relation", {
333
+ description: "Delete a task relation by its relation ID.",
334
+ inputSchema: z.object({ id: nonEmptyString }),
335
+ }, async (args) => run(() => client.json(`/api/task-relation/${encodeURIComponent(args.id)}`, {
336
+ method: "DELETE",
337
+ })));
338
+ server.registerTool("delete_label", {
339
+ description: "Delete a label by ID. Only task-associated labels can be deleted; workspace-level labels (taskId null) are rejected by the API.",
340
+ inputSchema: z.object({ id: nonEmptyString }),
341
+ }, async (args) => run(async () => {
342
+ const label = (await client.json(`/api/label/${encodeURIComponent(args.id)}`, { method: "GET" }));
343
+ if (!label?.taskId) {
344
+ throw new Error("Label is not associated with a task and cannot be deleted (workspace-level labels are not deletable via this endpoint).");
345
+ }
346
+ return client.json(`/api/label/${encodeURIComponent(args.id)}`, {
347
+ method: "DELETE",
348
+ });
349
+ }));
298
350
  }
@@ -0,0 +1,3 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare function textResult(data: unknown, isError?: boolean): CallToolResult;
3
+ export declare function errorResult(message: string): CallToolResult;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Kaneo API base URL without trailing slash and without `/api` suffix.
3
+ */
4
+ export declare function normalizeBaseUrl(raw: string): string;
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@kaneo/mcp",
3
- "version": "0.1.4",
4
- "description": "Model Context Protocol (stdio) server for Kaneo — tasks, projects, labels, and device authorization",
3
+ "version": "0.1.6",
4
+ "description": "Model Context Protocol (stdio) server for Kaneo: tasks, projects, labels, and device authorization",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/usekaneo/kaneo/",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/usekaneo/kaneo/"
9
+ "url": "https://github.com/usekaneo/kaneo/",
10
+ "directory": "packages/mcp"
10
11
  },
11
12
  "type": "module",
12
13
  "bin": {
13
14
  "kaneo-mcp": "dist/index.js"
14
15
  },
16
+ "exports": {
17
+ ".": {
18
+ "import": "./dist/server.js",
19
+ "types": "./dist/server.d.ts"
20
+ }
21
+ },
15
22
  "publishConfig": {
16
23
  "access": "public"
17
24
  },
@@ -36,17 +43,17 @@
36
43
  "test:watch": "vitest --config vitest.config.ts"
37
44
  },
38
45
  "dependencies": {
39
- "@modelcontextprotocol/sdk": "^1.26.0",
46
+ "@modelcontextprotocol/sdk": "^1.29.0",
40
47
  "open": "^11.0.0",
41
48
  "prompts": "^2.4.2",
42
- "zod": "^4.3.6"
49
+ "zod": "^4.4.3"
43
50
  },
44
51
  "devDependencies": {
45
52
  "@kaneo/typescript-config": "workspace:*",
46
53
  "@types/node": "^25.3.5",
47
54
  "@types/prompts": "^2.4.9",
48
- "tsx": "^4.21.0",
55
+ "tsx": "^4.23.1",
49
56
  "typescript": "^5.9.3",
50
- "vitest": "^4.1.2"
57
+ "vitest": "^4.1.10"
51
58
  }
52
59
  }