@kaneo/mcp 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Kaneo MCP server
2
+
3
+ `@kaneo/mcp` is a local MCP server for Kaneo.
4
+
5
+ It runs over stdio, signs in with Kaneo's device flow, and then calls the Kaneo API with a bearer token. The package lives in `apps/mcp` in this monorepo and exposes the `kaneo-mcp` CLI.
6
+
7
+ ## Prerequisites
8
+
9
+ - Node.js 20+
10
+ - A running Kaneo API (for example `http://localhost:1337`) and web app (for device approval UI).
11
+
12
+ Kaneo allows `kaneo-cli` and `kaneo-mcp` by default, so you usually do not need extra server configuration.
13
+
14
+ If you want to run this server with a different client ID, allow it on the Kaneo server:
15
+
16
+ ```bash
17
+ DEVICE_AUTH_CLIENT_IDS=kaneo-cli,kaneo-mcp,your-client-id
18
+ ```
19
+
20
+ ## Environment
21
+
22
+ | Variable | Description |
23
+ |----------|-------------|
24
+ | `KANEO_API_URL` | Kaneo API origin (default `http://localhost:1337`). Do not include `/api`. |
25
+ | `KANEO_MCP_CLIENT_ID` | Device-flow client id (default `kaneo-mcp`). Must match `DEVICE_AUTH_CLIENT_IDS` on the server. |
26
+
27
+ ## Install
28
+
29
+ **Recommended (no global install):** run the interactive installer with npx:
30
+
31
+ ```bash
32
+ npx @kaneo/mcp
33
+ ```
34
+
35
+ npm downloads the package, then an **interactive menu** (arrow keys + Enter) asks **where** to register the server (Cursor user-wide, Cursor project, Claude Desktop, or a custom JSON path). It then merges a `mcpServers` entry that points at this package’s `dist/index.js` with your current Node binary.
36
+
37
+ In a normal terminal, `npx @kaneo/mcp` and `kaneo-mcp` with no subcommand both start the installer. When the process is **not** attached to a TTY (for example when Cursor launches the MCP server with a pipe), the same entry runs the stdio MCP server instead.
38
+
39
+ To run the server manually from a shell (for example to debug stdio), use:
40
+
41
+ ```bash
42
+ npx @kaneo/mcp serve
43
+ ```
44
+
45
+ If you prefer a global install:
46
+
47
+ ```bash
48
+ npm install -g @kaneo/mcp
49
+ kaneo-mcp
50
+ ```
51
+
52
+ (`kaneo-mcp install` is the same installer with an explicit subcommand.)
53
+
54
+ Non-interactive example (Cursor user config, skip overwrite prompts):
55
+
56
+ ```bash
57
+ kaneo-mcp install --target cursor-user -y
58
+ ```
59
+
60
+ Point at a self-hosted API when generating the config:
61
+
62
+ ```bash
63
+ kaneo-mcp install --target cursor-user -y --api-url https://kaneo.example.com
64
+ ```
65
+
66
+ See all options:
67
+
68
+ ```bash
69
+ kaneo-mcp install --help
70
+ ```
71
+
72
+ If you are currently inside the local `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:
73
+
74
+ ```bash
75
+ node dist/index.js
76
+ ```
77
+
78
+ The published package includes `dist/`. `prepublishOnly` runs the build before publish.
79
+
80
+ ## Develop from source
81
+
82
+ From the repo root:
83
+
84
+ ```bash
85
+ pnpm install
86
+ pnpm --filter @kaneo/mcp run build
87
+ pnpm --filter @kaneo/mcp run start
88
+ pnpm --filter @kaneo/mcp run test
89
+ ```
90
+
91
+ Or run it from the package directory:
92
+
93
+ ```bash
94
+ pnpm -C apps/mcp run build
95
+ ```
96
+
97
+ The CLI entry points to `./dist/index.js`. Use `npx @kaneo/mcp` or `kaneo-mcp` after a global install so your IDE config points at the resolved path.
98
+
99
+ ## Authentication
100
+
101
+ On the first tool call that needs Kaneo, the server:
102
+
103
+ 1. Requests a device code from `POST /api/auth/device/code`
104
+ 2. Prints the verification URL and user code to `stderr`
105
+ 3. Tries to open the browser
106
+ 4. Polls `POST /api/auth/device/token` until approved
107
+ 5. Stores the access token at `~/.config/kaneo-mcp/credentials.json` with mode `0600`
108
+
109
+ ## Tools
110
+
111
+ - Session: `whoami`, `list_workspaces`
112
+ - Projects: `list_projects`, `get_project`, `create_project`, `update_project`
113
+ - Tasks: `list_tasks`, `get_task`, `create_task`, `update_task`, `move_task`, `update_task_status`
114
+ - Comments: `list_task_comments`, `create_task_comment`
115
+ - Labels: `list_workspace_labels`, `create_label`, `attach_label_to_task`, `detach_label_from_task`
@@ -0,0 +1,69 @@
1
+ import open from "open";
2
+ import { pollDeviceAccessToken, requestDeviceCode } from "./device-flow.js";
3
+ import { clearCredentials, loadCredentials, saveCredentials, } from "./token-store.js";
4
+ export class AuthService {
5
+ baseUrl;
6
+ clientId;
7
+ constructor(options) {
8
+ this.baseUrl = options.baseUrl;
9
+ this.clientId = options.clientId;
10
+ }
11
+ async clearToken() {
12
+ await clearCredentials();
13
+ }
14
+ async validateAccessToken(token) {
15
+ try {
16
+ const res = await fetch(`${this.baseUrl}/api/auth/get-session`, {
17
+ headers: { Authorization: `Bearer ${token}` },
18
+ });
19
+ if (res.status === 401) {
20
+ return "invalid";
21
+ }
22
+ if (!res.ok) {
23
+ return "unknown";
24
+ }
25
+ const data = (await res.json().catch(() => null));
26
+ return data?.user?.id ? "valid" : "unknown";
27
+ }
28
+ catch {
29
+ return "unknown";
30
+ }
31
+ }
32
+ log(msg) {
33
+ console.error(`[kaneo-mcp] ${msg}`);
34
+ }
35
+ /**
36
+ * Returns a valid access token, running the device authorization flow if needed.
37
+ */
38
+ 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
+ if (validation === "valid" || validation === "unknown") {
46
+ return cached.accessToken;
47
+ }
48
+ await this.clearToken();
49
+ }
50
+ const code = await requestDeviceCode(this.baseUrl, this.clientId);
51
+ const verifyUrl = code.verification_uri_complete || code.verification_uri;
52
+ this.log(`Open ${verifyUrl} and approve this device. User code: ${code.user_code}`);
53
+ try {
54
+ await open(verifyUrl);
55
+ }
56
+ catch {
57
+ this.log("Could not open a browser automatically; use the URL above.");
58
+ }
59
+ const accessToken = await pollDeviceAccessToken(this.baseUrl, this.clientId, code.device_code, code.interval, { log: (m) => this.log(m) });
60
+ const toStore = {
61
+ version: 1,
62
+ baseUrl: this.baseUrl,
63
+ clientId: this.clientId,
64
+ accessToken,
65
+ };
66
+ await saveCredentials(toStore);
67
+ return accessToken;
68
+ }
69
+ }
@@ -0,0 +1,64 @@
1
+ function sleep(ms) {
2
+ return new Promise((r) => setTimeout(r, ms));
3
+ }
4
+ 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(() => ({})));
11
+ if (!res.ok) {
12
+ throw new Error(`device/code failed (${res.status}): ${JSON.stringify(body)}`);
13
+ }
14
+ if (!("device_code" in body) || typeof body.device_code !== "string") {
15
+ throw new Error(`device/code: unexpected response ${JSON.stringify(body)}`);
16
+ }
17
+ return body;
18
+ }
19
+ /**
20
+ * Polls `/api/auth/device/token` until success or terminal error.
21
+ * First attempt is immediate; subsequent attempts wait `interval` seconds (increased on `slow_down`).
22
+ */
23
+ export async function pollDeviceAccessToken(baseUrl, clientId, deviceCode, initialIntervalSec, options) {
24
+ const maxWait = options?.maxWaitMs ?? 30 * 60 * 1000;
25
+ const log = options?.log ?? (() => { });
26
+ const started = Date.now();
27
+ let intervalMs = Math.max(1000, initialIntervalSec * 1000);
28
+ for (let attempt = 0; Date.now() - started < maxWait; attempt++) {
29
+ if (attempt > 0) {
30
+ await sleep(intervalMs);
31
+ }
32
+ const res = await fetch(`${baseUrl}/api/auth/device/token`, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({
36
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
37
+ device_code: deviceCode,
38
+ client_id: clientId,
39
+ }),
40
+ });
41
+ const body = (await res.json().catch(() => ({})));
42
+ if (res.ok && typeof body.access_token === "string") {
43
+ return body.access_token;
44
+ }
45
+ const err = body.error;
46
+ if (err === "authorization_pending") {
47
+ log("Waiting for device approval…");
48
+ continue;
49
+ }
50
+ if (err === "slow_down") {
51
+ intervalMs += 5000;
52
+ log(`Rate limited (slow_down); polling every ${intervalMs / 1000}s`);
53
+ continue;
54
+ }
55
+ if (err === "access_denied") {
56
+ throw new Error("Device authorization was denied.");
57
+ }
58
+ if (err === "expired_token") {
59
+ throw new Error("Device code expired; start login again.");
60
+ }
61
+ throw new Error(`device/token failed (${res.status}): ${JSON.stringify(body)}`);
62
+ }
63
+ throw new Error("Device authorization timed out waiting for approval.");
64
+ }
@@ -0,0 +1,50 @@
1
+ import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ const FILE_MODE = 0o600;
5
+ const DIR_MODE = 0o700;
6
+ function configDir() {
7
+ const base = process.env.XDG_CONFIG_HOME?.trim() || path.join(homedir(), ".config");
8
+ return path.join(base, "kaneo-mcp");
9
+ }
10
+ export function credentialsPath() {
11
+ return path.join(configDir(), "credentials.json");
12
+ }
13
+ export async function loadCredentials() {
14
+ try {
15
+ const raw = await readFile(credentialsPath(), "utf8");
16
+ const parsed = JSON.parse(raw);
17
+ if (parsed?.version === 1 &&
18
+ typeof parsed.baseUrl === "string" &&
19
+ typeof parsed.clientId === "string" &&
20
+ typeof parsed.accessToken === "string") {
21
+ return parsed;
22
+ }
23
+ return null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export async function saveCredentials(data) {
30
+ const dir = configDir();
31
+ await mkdir(dir, { recursive: true, mode: DIR_MODE });
32
+ const file = credentialsPath();
33
+ await writeFile(file, `${JSON.stringify(data, null, 2)}\n`, {
34
+ mode: FILE_MODE,
35
+ });
36
+ try {
37
+ await chmod(file, FILE_MODE);
38
+ }
39
+ catch {
40
+ /* ignore chmod failures on some FS */
41
+ }
42
+ }
43
+ export async function clearCredentials() {
44
+ try {
45
+ await unlink(credentialsPath());
46
+ }
47
+ catch {
48
+ /* noop */
49
+ }
50
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ import { stdin as input } from "node:process";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { runInstall } from "./install/index.js";
4
+ import { createMcpServer } from "./server.js";
5
+ const SERVE_ALIASES = new Set(["serve", "server", "stdio", "run"]);
6
+ export async function runCli() {
7
+ const argv = process.argv.slice(2);
8
+ if (argv[0] === "-h" || argv[0] === "--help" || argv[0] === "help") {
9
+ printMainHelp();
10
+ return;
11
+ }
12
+ if (argv[0] === "install" || argv[0] === "setup") {
13
+ await runInstall(argv.slice(1));
14
+ return;
15
+ }
16
+ if (argv[0] !== undefined) {
17
+ if (SERVE_ALIASES.has(argv[0])) {
18
+ await startMcpServer();
19
+ return;
20
+ }
21
+ console.error(`Unknown command: ${argv[0]}`);
22
+ printMainHelp();
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ if (input.isTTY) {
27
+ await runInstall([]);
28
+ return;
29
+ }
30
+ await startMcpServer();
31
+ }
32
+ async function startMcpServer() {
33
+ const server = createMcpServer();
34
+ const transport = new StdioServerTransport();
35
+ await server.connect(transport);
36
+ }
37
+ function printMainHelp() {
38
+ console.log(`kaneo-mcp — Kaneo MCP server (stdio transport)
39
+
40
+ Usage:
41
+ npx @kaneo/mcp Interactive installer (terminal only; no global install)
42
+ kaneo-mcp Same: installer in a TTY; MCP server when stdin is piped
43
+ kaneo-mcp install Register in Cursor / Claude / a custom path (explicit)
44
+ kaneo-mcp serve Run the MCP server (use from a terminal to test stdio)
45
+ kaneo-mcp help
46
+
47
+ Options:
48
+ -h, --help Show this help
49
+
50
+ MCP clients (Cursor, etc.) run this process with a pipe, so they get the server.
51
+ In a normal terminal, the default is the interactive installer.
52
+
53
+ See also: kaneo-mcp install --help
54
+ `);
55
+ }
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli.js";
3
+ runCli().catch((err) => {
4
+ console.error(err);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,248 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import { mergeMcpServerEntry } from "./merge-config.js";
5
+ import { resolvePackageEntryPath } from "./resolve-entry.js";
6
+ import { resolveTargetConfigPath } from "./targets.js";
7
+ import { promptConfirmOverwrite, promptCustomConfigPath, promptTargetSelect, } from "./wizard.js";
8
+ export function parseInstallArgs(argv) {
9
+ let target;
10
+ let output;
11
+ let name = "kaneo";
12
+ let yes = false;
13
+ let apiUrl;
14
+ let projectDir = process.cwd();
15
+ let help = false;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const a = argv[i];
18
+ if (a === undefined) {
19
+ continue;
20
+ }
21
+ if (a === "-h" || a === "--help") {
22
+ help = true;
23
+ continue;
24
+ }
25
+ if (a === "-y" || a === "--yes") {
26
+ yes = true;
27
+ continue;
28
+ }
29
+ if (a === "--target") {
30
+ const v = argv[i + 1];
31
+ if (!v || v.startsWith("-")) {
32
+ throw new Error("--target requires a value");
33
+ }
34
+ target = v;
35
+ i++;
36
+ continue;
37
+ }
38
+ if (a.startsWith("--target=")) {
39
+ target = a.slice("--target=".length);
40
+ continue;
41
+ }
42
+ if (a === "--output") {
43
+ const v = argv[i + 1];
44
+ if (!v || v.startsWith("-")) {
45
+ throw new Error("--output requires a value");
46
+ }
47
+ output = v;
48
+ i++;
49
+ continue;
50
+ }
51
+ if (a.startsWith("--output=")) {
52
+ output = a.slice("--output=".length);
53
+ continue;
54
+ }
55
+ if (a === "--name") {
56
+ const v = argv[i + 1];
57
+ if (!v || v.startsWith("-")) {
58
+ throw new Error("--name requires a value");
59
+ }
60
+ name = v;
61
+ i++;
62
+ continue;
63
+ }
64
+ if (a.startsWith("--name=")) {
65
+ name = a.slice("--name=".length);
66
+ continue;
67
+ }
68
+ if (a === "--api-url") {
69
+ const v = argv[i + 1];
70
+ if (!v || v.startsWith("-")) {
71
+ throw new Error("--api-url requires a value");
72
+ }
73
+ apiUrl = v;
74
+ i++;
75
+ continue;
76
+ }
77
+ if (a.startsWith("--api-url=")) {
78
+ apiUrl = a.slice("--api-url=".length);
79
+ continue;
80
+ }
81
+ if (a === "--project-dir") {
82
+ const v = argv[i + 1];
83
+ if (!v || v.startsWith("-")) {
84
+ throw new Error("--project-dir requires a value");
85
+ }
86
+ projectDir = v;
87
+ i++;
88
+ continue;
89
+ }
90
+ if (a.startsWith("--project-dir=")) {
91
+ projectDir = a.slice("--project-dir=".length);
92
+ continue;
93
+ }
94
+ throw new Error(`Unknown option: ${a}`);
95
+ }
96
+ return { target, output, name, yes, apiUrl, projectDir, help };
97
+ }
98
+ function hasExistingServerEntry(jsonText, serverName) {
99
+ if (!jsonText) {
100
+ return false;
101
+ }
102
+ try {
103
+ const root = JSON.parse(jsonText);
104
+ if (!root || typeof root !== "object" || Array.isArray(root)) {
105
+ return false;
106
+ }
107
+ const m = root.mcpServers;
108
+ if (!m || typeof m !== "object" || Array.isArray(m)) {
109
+ return false;
110
+ }
111
+ return Object.hasOwn(m, serverName);
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ const VALID_TARGETS = [
118
+ "cursor-user",
119
+ "cursor-project",
120
+ "claude-desktop",
121
+ "custom",
122
+ ];
123
+ function isInstallTargetId(s) {
124
+ return VALID_TARGETS.includes(s);
125
+ }
126
+ export async function runInstall(argv) {
127
+ let parsed;
128
+ try {
129
+ parsed = parseInstallArgs(argv);
130
+ }
131
+ catch (e) {
132
+ console.error(e instanceof Error ? e.message : String(e));
133
+ printInstallHelp();
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ if (parsed.help) {
138
+ printInstallHelp();
139
+ return;
140
+ }
141
+ if (parsed.target !== undefined && !isInstallTargetId(parsed.target)) {
142
+ console.error(`Invalid --target. Use one of: ${VALID_TARGETS.join(", ")}`);
143
+ process.exitCode = 1;
144
+ return;
145
+ }
146
+ if (!parsed.name.trim()) {
147
+ console.error("--name must be a non-empty string.");
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ let targetId = parsed.target;
152
+ const needsInteractive = input.isTTY && output.isTTY && !parsed.yes && targetId === undefined;
153
+ if (needsInteractive) {
154
+ targetId = await promptTargetSelect();
155
+ }
156
+ else if (targetId === undefined) {
157
+ console.error("Non-interactive mode: specify --target (e.g. --target cursor-user) and use -y to confirm.");
158
+ printInstallHelp();
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ let customPath = parsed.output;
163
+ if (targetId === "custom") {
164
+ if (!customPath) {
165
+ if (input.isTTY && output.isTTY) {
166
+ customPath = await promptCustomConfigPath();
167
+ }
168
+ else {
169
+ console.error("Custom target requires --output <path>");
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ }
174
+ }
175
+ const configPath = resolveTargetConfigPath(targetId, {
176
+ cwd: parsed.projectDir,
177
+ customPath,
178
+ });
179
+ const entryPath = resolvePackageEntryPath();
180
+ const env = parsed.apiUrl !== undefined && parsed.apiUrl.length > 0
181
+ ? { KANEO_API_URL: parsed.apiUrl }
182
+ : undefined;
183
+ const serverConfig = {
184
+ command: process.execPath,
185
+ args: [entryPath],
186
+ ...(env ? { env } : {}),
187
+ };
188
+ let existingText = null;
189
+ try {
190
+ existingText = await readFile(configPath, "utf8");
191
+ }
192
+ catch {
193
+ existingText = null;
194
+ }
195
+ const already = hasExistingServerEntry(existingText, parsed.name);
196
+ if (already && !parsed.yes) {
197
+ if (input.isTTY && output.isTTY) {
198
+ const ok = await promptConfirmOverwrite(parsed.name);
199
+ if (!ok) {
200
+ console.log("Aborted.");
201
+ return;
202
+ }
203
+ }
204
+ else {
205
+ console.error(`Entry "${parsed.name}" already exists. Pass -y to overwrite.`);
206
+ process.exitCode = 1;
207
+ return;
208
+ }
209
+ }
210
+ let merged;
211
+ try {
212
+ merged = mergeMcpServerEntry(existingText, parsed.name, serverConfig);
213
+ }
214
+ catch {
215
+ console.error(`Cannot update config (invalid JSON or merge error): ${configPath}`);
216
+ process.exitCode = 1;
217
+ return;
218
+ }
219
+ await mkdir(dirname(configPath), { recursive: true });
220
+ await writeFile(configPath, merged, { encoding: "utf8", mode: 0o600 });
221
+ console.log(`Wrote MCP server "${parsed.name}" to:\n ${configPath}`);
222
+ console.log("\nRestart your MCP client (or reload the window) if needed.");
223
+ }
224
+ function printInstallHelp() {
225
+ console.log(`kaneo-mcp install — register Kaneo in an MCP client config
226
+
227
+ Usage:
228
+ kaneo-mcp install [options]
229
+
230
+ Without options, runs interactively (pick Cursor / Claude / custom path).
231
+
232
+ Options:
233
+ --target <id> cursor-user | cursor-project | claude-desktop | custom
234
+ --output <path> Required for --target custom (absolute path to JSON file)
235
+ --project-dir <dir> Base directory for cursor-project (default: current dir)
236
+ --name <string> MCP server key under mcpServers (default: kaneo)
237
+ --api-url <url> Set KANEO_API_URL in the generated entry (optional)
238
+ -y, --yes Overwrite existing entry without prompting
239
+ -h, --help Show this help
240
+
241
+ Examples:
242
+ npm install -g @kaneo/mcp
243
+ kaneo-mcp install
244
+
245
+ kaneo-mcp install --target cursor-user -y
246
+ kaneo-mcp install --target custom --output /path/to/mcp.json -y
247
+ `);
248
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Merges or replaces `mcpServers[serverName]` and returns formatted JSON.
3
+ * Preserves other top-level keys and other MCP server entries.
4
+ */
5
+ export function mergeMcpServerEntry(existingJson, serverName, serverConfig) {
6
+ let root = {};
7
+ if (existingJson) {
8
+ const parsed = JSON.parse(existingJson);
9
+ if (typeof parsed === "object" &&
10
+ parsed !== null &&
11
+ !Array.isArray(parsed)) {
12
+ root = { ...parsed };
13
+ }
14
+ }
15
+ const mcpServers = (() => {
16
+ const raw = root.mcpServers;
17
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
18
+ return { ...raw };
19
+ }
20
+ return {};
21
+ })();
22
+ mcpServers[serverName] = serverConfig;
23
+ root.mcpServers = mcpServers;
24
+ return `${JSON.stringify(root, null, 2)}\n`;
25
+ }
@@ -0,0 +1,9 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * Absolute path to this package's `dist/index.js` (the MCP stdio entry).
5
+ * Resolved from `dist/install/resolve-entry.js` at runtime.
6
+ */
7
+ export function resolvePackageEntryPath() {
8
+ return join(dirname(fileURLToPath(import.meta.url)), "../index.js");
9
+ }
@@ -0,0 +1,55 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ export const INSTALL_TARGETS = [
4
+ {
5
+ id: "cursor-user",
6
+ label: "Cursor (user-wide)",
7
+ description: "~/.cursor/mcp.json — available in all projects",
8
+ },
9
+ {
10
+ id: "cursor-project",
11
+ label: "Cursor (this project only)",
12
+ description: ".cursor/mcp.json in the current directory",
13
+ },
14
+ {
15
+ id: "claude-desktop",
16
+ label: "Claude Desktop",
17
+ description: "claude_desktop_config.json for the Claude app",
18
+ },
19
+ {
20
+ id: "custom",
21
+ label: "Custom file path",
22
+ description: "Any JSON file you choose (advanced)",
23
+ },
24
+ ];
25
+ export function getClaudeDesktopConfigPath() {
26
+ const platform = process.platform;
27
+ if (platform === "darwin") {
28
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
29
+ }
30
+ if (platform === "win32") {
31
+ const appData = process.env.APPDATA;
32
+ if (!appData) {
33
+ throw new Error("APPDATA is not set; cannot resolve Claude Desktop path");
34
+ }
35
+ return join(appData, "Claude", "claude_desktop_config.json");
36
+ }
37
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
38
+ }
39
+ export function resolveTargetConfigPath(id, options) {
40
+ switch (id) {
41
+ case "cursor-user":
42
+ return join(homedir(), ".cursor", "mcp.json");
43
+ case "cursor-project":
44
+ return join(options.cwd, ".cursor", "mcp.json");
45
+ case "claude-desktop":
46
+ return getClaudeDesktopConfigPath();
47
+ case "custom": {
48
+ const p = options.customPath?.trim();
49
+ if (!p) {
50
+ throw new Error("Custom target requires a file path");
51
+ }
52
+ return p;
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,59 @@
1
+ import prompts from "prompts";
2
+ import { INSTALL_TARGETS } from "./targets.js";
3
+ function onCancel() {
4
+ console.log("\nCancelled.");
5
+ process.exit(0);
6
+ }
7
+ function printIntro() {
8
+ console.log(`
9
+ ────────────────────────────────────────
10
+ Kaneo MCP · editor setup
11
+ ────────────────────────────────────────
12
+ Use ↑ / ↓ to move, Enter to select.
13
+ ────────────────────────────────────────
14
+ `);
15
+ }
16
+ export async function promptTargetSelect() {
17
+ printIntro();
18
+ const answer = await prompts({
19
+ type: "select",
20
+ name: "target",
21
+ message: "Where should Kaneo register this MCP server?",
22
+ choices: INSTALL_TARGETS.map((t) => ({
23
+ title: t.label,
24
+ description: t.description,
25
+ value: t.id,
26
+ })),
27
+ initial: 0,
28
+ }, { onCancel });
29
+ if (answer.target === undefined) {
30
+ onCancel();
31
+ }
32
+ return answer.target;
33
+ }
34
+ export async function promptCustomConfigPath() {
35
+ const answer = await prompts({
36
+ type: "text",
37
+ name: "path",
38
+ message: "Absolute path to the JSON config file (create or update):",
39
+ validate: (v) => typeof v === "string" && v.trim().length > 0
40
+ ? true
41
+ : "Path is required",
42
+ }, { onCancel });
43
+ if (answer.path === undefined) {
44
+ onCancel();
45
+ }
46
+ return String(answer.path).trim();
47
+ }
48
+ export async function promptConfirmOverwrite(serverName) {
49
+ const answer = await prompts({
50
+ type: "confirm",
51
+ name: "overwrite",
52
+ message: `MCP server "${serverName}" is already in this file. Overwrite it?`,
53
+ initial: false,
54
+ }, { onCancel });
55
+ if (answer.overwrite === undefined) {
56
+ onCancel();
57
+ }
58
+ return Boolean(answer.overwrite);
59
+ }
@@ -0,0 +1,53 @@
1
+ export class KaneoClient {
2
+ baseUrl;
3
+ auth;
4
+ constructor(options) {
5
+ this.baseUrl = options.baseUrl;
6
+ this.auth = options.auth;
7
+ }
8
+ async authorizedFetch(path, init, didRetry = false) {
9
+ const token = await this.auth.getAccessToken();
10
+ const headers = new Headers(init?.headers);
11
+ headers.set("Authorization", `Bearer ${token}`);
12
+ if (init?.body != null && !headers.has("Content-Type")) {
13
+ headers.set("Content-Type", "application/json");
14
+ }
15
+ const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
16
+ const res = await fetch(url, { ...init, headers });
17
+ if (res.status === 401 && !didRetry) {
18
+ await this.auth.clearToken();
19
+ return this.authorizedFetch(path, init, true);
20
+ }
21
+ return res;
22
+ }
23
+ async json(path, init) {
24
+ const res = await this.authorizedFetch(path, init);
25
+ const text = await res.text();
26
+ let body = null;
27
+ if (text) {
28
+ try {
29
+ body = JSON.parse(text);
30
+ }
31
+ catch {
32
+ body = text;
33
+ }
34
+ }
35
+ if (!res.ok) {
36
+ let detail;
37
+ if (typeof body === "object" &&
38
+ body !== null &&
39
+ "message" in body &&
40
+ typeof body.message === "string") {
41
+ detail = body.message;
42
+ }
43
+ else if (typeof body === "string" && body.length > 0) {
44
+ detail = body.length > 500 ? `${body.slice(0, 500)}…` : body;
45
+ }
46
+ else {
47
+ detail = `HTTP ${res.status}`;
48
+ }
49
+ throw new Error(`${path}: ${detail}`);
50
+ }
51
+ return body;
52
+ }
53
+ }
@@ -0,0 +1,84 @@
1
+ const PRIORITIES = ["no-priority", "low", "medium", "high", "urgent"];
2
+ export function isTaskPriority(v) {
3
+ return PRIORITIES.includes(v);
4
+ }
5
+ /**
6
+ * Builds the JSON body for `PUT /api/task/:id` from an existing task plus a patch.
7
+ */
8
+ export function buildFullTaskUpdateBody(existing, patch) {
9
+ const positionRaw = patch.position ?? existing.position;
10
+ const position = typeof positionRaw === "number"
11
+ ? positionRaw
12
+ : typeof positionRaw === "string"
13
+ ? Number(positionRaw)
14
+ : Number.NaN;
15
+ if (!Number.isFinite(position)) {
16
+ throw new Error("Cannot update task: missing numeric `position` on existing task.");
17
+ }
18
+ const title = patch.title ??
19
+ (typeof existing.title === "string" ? existing.title : undefined);
20
+ if (!title) {
21
+ throw new Error("Cannot update task: missing title.");
22
+ }
23
+ const description = patch.description !== undefined
24
+ ? patch.description === null
25
+ ? ""
26
+ : String(patch.description)
27
+ : existing.description == null
28
+ ? ""
29
+ : String(existing.description);
30
+ const status = patch.status ??
31
+ (typeof existing.status === "string" ? existing.status : undefined);
32
+ if (!status) {
33
+ throw new Error("Cannot update task: missing status.");
34
+ }
35
+ const priorityRaw = patch.priority ??
36
+ (typeof existing.priority === "string" ? existing.priority : undefined);
37
+ if (!priorityRaw || !isTaskPriority(priorityRaw)) {
38
+ throw new Error("Cannot update task: invalid or missing priority.");
39
+ }
40
+ const projectId = patch.projectId ??
41
+ (typeof existing.projectId === "string" ? existing.projectId : undefined);
42
+ if (!projectId) {
43
+ throw new Error("Cannot update task: missing projectId.");
44
+ }
45
+ const userId = patch.userId !== undefined
46
+ ? patch.userId === null
47
+ ? ""
48
+ : patch.userId
49
+ : typeof existing.userId === "string"
50
+ ? existing.userId
51
+ : undefined;
52
+ const startDate = formatOptionalIso(patch.startDate !== undefined ? patch.startDate : existing.startDate);
53
+ const dueDate = formatOptionalIso(patch.dueDate !== undefined ? patch.dueDate : existing.dueDate);
54
+ const body = {
55
+ title,
56
+ description,
57
+ status,
58
+ priority: priorityRaw,
59
+ projectId,
60
+ position,
61
+ };
62
+ if (startDate !== undefined) {
63
+ body.startDate = startDate;
64
+ }
65
+ if (dueDate !== undefined) {
66
+ body.dueDate = dueDate;
67
+ }
68
+ if (userId !== undefined) {
69
+ body.userId = userId;
70
+ }
71
+ return body;
72
+ }
73
+ function formatOptionalIso(value) {
74
+ if (value === null || value === undefined) {
75
+ return undefined;
76
+ }
77
+ if (value instanceof Date) {
78
+ return value.toISOString();
79
+ }
80
+ if (typeof value === "string") {
81
+ return value;
82
+ }
83
+ return undefined;
84
+ }
package/dist/server.js ADDED
@@ -0,0 +1,20 @@
1
+ import { createRequire } from "node:module";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { AuthService } from "./auth/auth-service.js";
4
+ import { KaneoClient } from "./kaneo/client.js";
5
+ import { registerTools } from "./tools/register.js";
6
+ import { normalizeBaseUrl } from "./utils/normalize-base-url.js";
7
+ const require = createRequire(import.meta.url);
8
+ const { version: packageVersion } = require("../package.json");
9
+ export function createMcpServer() {
10
+ const baseUrl = normalizeBaseUrl(process.env.KANEO_API_URL || "http://localhost:1337");
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: packageVersion,
17
+ });
18
+ registerTools(server, { client });
19
+ return server;
20
+ }
@@ -0,0 +1,269 @@
1
+ import { z } from "zod";
2
+ import { buildFullTaskUpdateBody } from "../kaneo/task-helpers.js";
3
+ import { errorResult, textResult } from "../utils/mcp-result.js";
4
+ const prioritySchema = z.enum([
5
+ "no-priority",
6
+ "low",
7
+ "medium",
8
+ "high",
9
+ "urgent",
10
+ ]);
11
+ const nonEmptyString = z.string().trim().min(1);
12
+ const optionalNonEmptyString = nonEmptyString.optional();
13
+ const nullableOptionalNonEmptyString = nonEmptyString.nullable().optional();
14
+ const isoDateTimeSchema = z.string().datetime({ offset: true });
15
+ const optionalIsoDateTimeSchema = isoDateTimeSchema.optional();
16
+ const nullableOptionalIsoDateTimeSchema = isoDateTimeSchema
17
+ .nullable()
18
+ .optional();
19
+ const hexColorSchema = z
20
+ .string()
21
+ .regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, "Expected a hex color like #FF6600");
22
+ function run(fn) {
23
+ return fn()
24
+ .then((data) => textResult(data))
25
+ .catch((e) => errorResult(e instanceof Error ? e.message : String(e)));
26
+ }
27
+ export function registerTools(server, ctx) {
28
+ const { client } = ctx;
29
+ server.registerTool("whoami", {
30
+ description: "Return the current Kaneo session and user for the cached device token.",
31
+ inputSchema: z.object({}),
32
+ }, async () => run(() => client.json("/api/auth/get-session", { method: "GET" })));
33
+ server.registerTool("list_workspaces", {
34
+ description: "List workspaces (Better Auth organizations) the signed-in user can access.",
35
+ inputSchema: z.object({}),
36
+ }, async () => run(() => client.json("/api/auth/organization/list", { method: "GET" })));
37
+ server.registerTool("list_projects", {
38
+ description: "List projects in a workspace.",
39
+ inputSchema: z.object({
40
+ workspaceId: nonEmptyString.describe("Workspace ID"),
41
+ includeArchived: z
42
+ .boolean()
43
+ .optional()
44
+ .describe("Include archived projects"),
45
+ }),
46
+ }, async (args) => {
47
+ const { workspaceId, includeArchived } = args;
48
+ const qs = new URLSearchParams({ workspaceId });
49
+ if (includeArchived === true) {
50
+ qs.set("includeArchived", "true");
51
+ }
52
+ return run(() => client.json(`/api/project?${qs.toString()}`, { method: "GET" }));
53
+ });
54
+ server.registerTool("get_project", {
55
+ description: "Get a single project by ID.",
56
+ inputSchema: z.object({ id: nonEmptyString }),
57
+ }, async (args) => run(() => client.json(`/api/project/${encodeURIComponent(args.id)}`)));
58
+ server.registerTool("create_project", {
59
+ description: "Create a project in a workspace.",
60
+ inputSchema: z.object({
61
+ name: nonEmptyString,
62
+ workspaceId: nonEmptyString,
63
+ icon: nonEmptyString,
64
+ slug: nonEmptyString,
65
+ }),
66
+ }, async (args) => run(() => client.json("/api/project", {
67
+ method: "POST",
68
+ body: JSON.stringify({
69
+ name: args.name,
70
+ workspaceId: args.workspaceId,
71
+ icon: args.icon,
72
+ slug: args.slug,
73
+ }),
74
+ })));
75
+ server.registerTool("update_project", {
76
+ description: "Update project metadata.",
77
+ inputSchema: z.object({
78
+ id: nonEmptyString,
79
+ name: nonEmptyString,
80
+ icon: nonEmptyString,
81
+ slug: nonEmptyString,
82
+ description: z.string(),
83
+ isPublic: z.boolean(),
84
+ }),
85
+ }, async (args) => run(() => client.json(`/api/project/${encodeURIComponent(args.id)}`, {
86
+ method: "PUT",
87
+ body: JSON.stringify({
88
+ name: args.name,
89
+ icon: args.icon,
90
+ slug: args.slug,
91
+ description: args.description,
92
+ isPublic: args.isPublic,
93
+ }),
94
+ })));
95
+ const listTasksSchema = z.object({
96
+ projectId: nonEmptyString,
97
+ status: optionalNonEmptyString,
98
+ priority: prioritySchema.optional(),
99
+ assigneeId: optionalNonEmptyString,
100
+ page: z.number().int().positive().optional(),
101
+ limit: z.number().int().positive().optional(),
102
+ sortBy: z
103
+ .enum(["createdAt", "priority", "dueDate", "position", "title", "number"])
104
+ .optional(),
105
+ sortOrder: z.enum(["asc", "desc"]).optional(),
106
+ dueBefore: optionalIsoDateTimeSchema,
107
+ dueAfter: optionalIsoDateTimeSchema,
108
+ });
109
+ server.registerTool("list_tasks", {
110
+ description: "List tasks for a project (optionally filtered/sorted).",
111
+ inputSchema: listTasksSchema,
112
+ }, async (args) => {
113
+ const { projectId, ...rest } = args;
114
+ const qs = new URLSearchParams();
115
+ for (const [k, v] of Object.entries(rest)) {
116
+ if (v === undefined || v === null) {
117
+ continue;
118
+ }
119
+ qs.set(k, String(v));
120
+ }
121
+ const q = qs.toString();
122
+ const path = `/api/task/tasks/${encodeURIComponent(projectId)}${q ? `?${q}` : ""}`;
123
+ return run(() => client.json(path, { method: "GET" }));
124
+ });
125
+ server.registerTool("get_task", {
126
+ description: "Get a task by ID.",
127
+ inputSchema: z.object({ taskId: nonEmptyString }),
128
+ }, async (args) => run(() => client.json(`/api/task/${encodeURIComponent(args.taskId)}`, {
129
+ method: "GET",
130
+ })));
131
+ server.registerTool("create_task", {
132
+ description: "Create a task in a project.",
133
+ inputSchema: z.object({
134
+ projectId: nonEmptyString,
135
+ title: nonEmptyString,
136
+ description: z.string(),
137
+ priority: prioritySchema,
138
+ status: nonEmptyString,
139
+ startDate: optionalIsoDateTimeSchema,
140
+ dueDate: optionalIsoDateTimeSchema,
141
+ userId: optionalNonEmptyString,
142
+ }),
143
+ }, async (args) => {
144
+ const body = {
145
+ title: args.title,
146
+ description: args.description,
147
+ priority: args.priority,
148
+ status: args.status,
149
+ };
150
+ if (args.startDate !== undefined) {
151
+ body.startDate = args.startDate;
152
+ }
153
+ if (args.dueDate !== undefined) {
154
+ body.dueDate = args.dueDate;
155
+ }
156
+ if (args.userId !== undefined) {
157
+ body.userId = args.userId;
158
+ }
159
+ return run(() => client.json(`/api/task/${encodeURIComponent(args.projectId)}`, {
160
+ method: "POST",
161
+ body: JSON.stringify(body),
162
+ }));
163
+ });
164
+ const updateTaskSchema = z.object({
165
+ taskId: nonEmptyString,
166
+ title: optionalNonEmptyString,
167
+ description: z.string().nullable().optional(),
168
+ status: optionalNonEmptyString,
169
+ priority: prioritySchema.optional(),
170
+ projectId: optionalNonEmptyString,
171
+ position: z.number().optional(),
172
+ startDate: nullableOptionalIsoDateTimeSchema,
173
+ dueDate: nullableOptionalIsoDateTimeSchema,
174
+ userId: nullableOptionalNonEmptyString,
175
+ });
176
+ server.registerTool("update_task", {
177
+ description: "Update a task (fetches current task, merges fields, then full update).",
178
+ inputSchema: updateTaskSchema,
179
+ }, async (args) => {
180
+ const { taskId, ...patch } = args;
181
+ return run(async () => {
182
+ const existing = (await client.json(`/api/task/${encodeURIComponent(taskId)}`, { method: "GET" }));
183
+ const body = buildFullTaskUpdateBody(existing, patch);
184
+ return client.json(`/api/task/${encodeURIComponent(taskId)}`, {
185
+ method: "PUT",
186
+ body: JSON.stringify(body),
187
+ });
188
+ });
189
+ });
190
+ server.registerTool("move_task", {
191
+ description: "Move a task to another project (and optional column status).",
192
+ inputSchema: z.object({
193
+ taskId: nonEmptyString,
194
+ destinationProjectId: nonEmptyString,
195
+ destinationStatus: optionalNonEmptyString,
196
+ }),
197
+ }, async (args) => run(() => client.json(`/api/task/move/${encodeURIComponent(args.taskId)}`, {
198
+ method: "PUT",
199
+ body: JSON.stringify({
200
+ destinationProjectId: args.destinationProjectId,
201
+ ...(args.destinationStatus !== undefined
202
+ ? { destinationStatus: args.destinationStatus }
203
+ : {}),
204
+ }),
205
+ })));
206
+ server.registerTool("update_task_status", {
207
+ description: "Update only the status (column) of a task.",
208
+ inputSchema: z.object({
209
+ taskId: nonEmptyString,
210
+ status: nonEmptyString,
211
+ }),
212
+ }, async (args) => run(() => client.json(`/api/task/status/${encodeURIComponent(args.taskId)}`, {
213
+ method: "PUT",
214
+ body: JSON.stringify({ status: args.status }),
215
+ })));
216
+ server.registerTool("list_task_comments", {
217
+ description: "List comments on a task.",
218
+ inputSchema: z.object({ taskId: nonEmptyString }),
219
+ }, async (args) => run(() => client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, {
220
+ method: "GET",
221
+ })));
222
+ server.registerTool("create_task_comment", {
223
+ description: "Add a comment to a task.",
224
+ inputSchema: z.object({
225
+ taskId: nonEmptyString,
226
+ content: nonEmptyString,
227
+ }),
228
+ }, async (args) => run(() => client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, {
229
+ method: "POST",
230
+ body: JSON.stringify({ content: args.content }),
231
+ })));
232
+ server.registerTool("list_workspace_labels", {
233
+ description: "List labels defined in a workspace.",
234
+ inputSchema: z.object({ workspaceId: nonEmptyString }),
235
+ }, async (args) => run(() => client.json(`/api/label/workspace/${encodeURIComponent(args.workspaceId)}`, { method: "GET" })));
236
+ server.registerTool("create_label", {
237
+ description: "Create a label in a workspace (optionally attach to a task).",
238
+ inputSchema: z.object({
239
+ name: nonEmptyString,
240
+ color: hexColorSchema,
241
+ workspaceId: nonEmptyString,
242
+ taskId: optionalNonEmptyString,
243
+ }),
244
+ }, async (args) => run(() => client.json("/api/label", {
245
+ method: "POST",
246
+ body: JSON.stringify({
247
+ name: args.name,
248
+ color: args.color,
249
+ workspaceId: args.workspaceId,
250
+ ...(args.taskId !== undefined ? { taskId: args.taskId } : {}),
251
+ }),
252
+ })));
253
+ server.registerTool("attach_label_to_task", {
254
+ description: "Attach an existing label to a task.",
255
+ inputSchema: z.object({
256
+ labelId: nonEmptyString,
257
+ taskId: nonEmptyString,
258
+ }),
259
+ }, async (args) => run(() => client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, {
260
+ method: "PUT",
261
+ body: JSON.stringify({ taskId: args.taskId }),
262
+ })));
263
+ server.registerTool("detach_label_from_task", {
264
+ description: "Detach a label from its current task.",
265
+ inputSchema: z.object({ labelId: nonEmptyString }),
266
+ }, async (args) => run(() => client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, {
267
+ method: "DELETE",
268
+ })));
269
+ }
@@ -0,0 +1,14 @@
1
+ export function textResult(data, isError = false) {
2
+ return {
3
+ content: [
4
+ {
5
+ type: "text",
6
+ text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
7
+ },
8
+ ],
9
+ isError,
10
+ };
11
+ }
12
+ export function errorResult(message) {
13
+ return textResult({ error: message }, true);
14
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Kaneo API base URL without trailing slash and without `/api` suffix.
3
+ */
4
+ export function normalizeBaseUrl(raw) {
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
+ }
15
+ catch {
16
+ return trimmed.replace(/\/api\/?$/, "").replace(/\/+$/, "");
17
+ }
18
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@kaneo/mcp",
3
+ "version": "0.1.1",
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
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "mcp",
20
+ "model-context-protocol",
21
+ "kaneo",
22
+ "stdio"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json",
26
+ "prepublishOnly": "npm run build",
27
+ "dev": "tsx watch src/index.ts",
28
+ "lint": "biome check --write .",
29
+ "start": "node dist/index.js",
30
+ "test": "vitest run --config vitest.config.ts",
31
+ "test:watch": "vitest --config vitest.config.ts"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.26.0",
35
+ "open": "^10.1.0",
36
+ "prompts": "^2.4.2",
37
+ "zod": "^3.25.0"
38
+ },
39
+ "devDependencies": {
40
+ "@kaneo/typescript-config": "workspace:*",
41
+ "@types/node": "^25.3.5",
42
+ "@types/prompts": "^2.4.9",
43
+ "tsx": "^4.21.0",
44
+ "typescript": "^5.9.3",
45
+ "vitest": "^4.1.2"
46
+ }
47
+ }