@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 +21 -0
- package/README.md +115 -0
- package/dist/auth/auth-service.js +69 -0
- package/dist/auth/device-flow.js +64 -0
- package/dist/auth/token-store.js +50 -0
- package/dist/cli.js +55 -0
- package/dist/index.js +6 -0
- package/dist/install/index.js +248 -0
- package/dist/install/merge-config.js +25 -0
- package/dist/install/resolve-entry.js +9 -0
- package/dist/install/targets.js +55 -0
- package/dist/install/wizard.js +59 -0
- package/dist/kaneo/client.js +53 -0
- package/dist/kaneo/task-helpers.js +84 -0
- package/dist/server.js +20 -0
- package/dist/tools/register.js +269 -0
- package/dist/utils/mcp-result.js +14 -0
- package/dist/utils/normalize-base-url.js +18 -0
- package/package.json +47 -0
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,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
|
+
}
|