@kaneo/mcp 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/auth/auth-service.js +43 -25
- package/dist/auth/device-flow.js +96 -19
- package/dist/install/index.js +62 -24
- package/dist/install/merge-config.js +30 -8
- package/dist/install/targets.js +22 -5
- package/dist/install/wizard.js +10 -6
- package/dist/kaneo/client.js +5 -1
- package/dist/kaneo/task-helpers.js +3 -0
- package/dist/tools/register.js +42 -16
- package/dist/utils/mcp-result.js +2 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`@kaneo/mcp` is a local MCP server for Kaneo.
|
|
4
4
|
|
|
5
|
-
It runs over stdio, signs in with Kaneo's device flow, and then calls the Kaneo API with a bearer token. The package lives in `
|
|
5
|
+
It runs over stdio, signs in with Kaneo's device flow, and then calls the Kaneo API with a bearer token. The package lives in `packages/mcp` in this monorepo and exposes the `kaneo-mcp` CLI.
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -69,7 +69,7 @@ See all options:
|
|
|
69
69
|
kaneo-mcp install --help
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
If you are currently inside the local `
|
|
72
|
+
If you are currently inside the local `packages/mcp` package directory, npm may resolve the local workspace package instead of the published one and fail to expose the bin. In that case, either run `npx` from outside `packages/mcp`, or use a local build:
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
75
|
node dist/index.js
|
|
@@ -91,7 +91,7 @@ pnpm --filter @kaneo/mcp run test
|
|
|
91
91
|
Or run it from the package directory:
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
pnpm -C
|
|
94
|
+
pnpm -C packages/mcp run build
|
|
95
95
|
```
|
|
96
96
|
|
|
97
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.
|
|
@@ -4,6 +4,7 @@ import { clearCredentials, loadCredentials, saveCredentials, } from "./token-sto
|
|
|
4
4
|
export class AuthService {
|
|
5
5
|
baseUrl;
|
|
6
6
|
clientId;
|
|
7
|
+
activeGetAccessTokenPromise;
|
|
7
8
|
constructor(options) {
|
|
8
9
|
this.baseUrl = options.baseUrl;
|
|
9
10
|
this.clientId = options.clientId;
|
|
@@ -13,8 +14,10 @@ export class AuthService {
|
|
|
13
14
|
}
|
|
14
15
|
async validateAccessToken(token) {
|
|
15
16
|
try {
|
|
17
|
+
const signal = AbortSignal.timeout(10_000);
|
|
16
18
|
const res = await fetch(`${this.baseUrl}/api/auth/get-session`, {
|
|
17
19
|
headers: { Authorization: `Bearer ${token}` },
|
|
20
|
+
signal,
|
|
18
21
|
});
|
|
19
22
|
if (res.status === 401) {
|
|
20
23
|
return "invalid";
|
|
@@ -36,34 +39,49 @@ export class AuthService {
|
|
|
36
39
|
* Returns a valid access token, running the device authorization flow if needed.
|
|
37
40
|
*/
|
|
38
41
|
async getAccessToken() {
|
|
39
|
-
|
|
40
|
-
|
|
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();
|
|
42
|
+
if (this.activeGetAccessTokenPromise) {
|
|
43
|
+
return await this.activeGetAccessTokenPromise;
|
|
49
44
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
this.activeGetAccessTokenPromise = (async () => {
|
|
46
|
+
const cached = await loadCredentials();
|
|
47
|
+
if (cached &&
|
|
48
|
+
cached.baseUrl === this.baseUrl &&
|
|
49
|
+
cached.clientId === this.clientId &&
|
|
50
|
+
cached.accessToken) {
|
|
51
|
+
const validation = await this.validateAccessToken(cached.accessToken);
|
|
52
|
+
// Fail-open for "unknown": validateAccessToken returns "unknown" on transient HTTP/network
|
|
53
|
+
// errors or ambiguous responses. Treating "unknown" like "valid" avoids clearToken() and a full
|
|
54
|
+
// device re-auth so flaky connectivity does not wipe cached.accessToken; only "invalid" forces
|
|
55
|
+
// a fresh login. Tests should cover both unknown and invalid paths.
|
|
56
|
+
if (validation === "valid" || validation === "unknown") {
|
|
57
|
+
return cached.accessToken;
|
|
58
|
+
}
|
|
59
|
+
await this.clearToken();
|
|
60
|
+
}
|
|
61
|
+
const code = await requestDeviceCode(this.baseUrl, this.clientId);
|
|
62
|
+
const verifyUrl = code.verification_uri_complete || code.verification_uri;
|
|
63
|
+
this.log(`Open ${verifyUrl} and approve this device. User code: ${code.user_code}`);
|
|
64
|
+
try {
|
|
65
|
+
await open(verifyUrl);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
this.log("Could not open a browser automatically; use the URL above.");
|
|
69
|
+
}
|
|
70
|
+
const accessToken = await pollDeviceAccessToken(this.baseUrl, this.clientId, code.device_code, code.interval, { log: (m) => this.log(m) });
|
|
71
|
+
const toStore = {
|
|
72
|
+
version: 1,
|
|
73
|
+
baseUrl: this.baseUrl,
|
|
74
|
+
clientId: this.clientId,
|
|
75
|
+
accessToken,
|
|
76
|
+
};
|
|
77
|
+
await saveCredentials(toStore);
|
|
78
|
+
return accessToken;
|
|
79
|
+
})();
|
|
53
80
|
try {
|
|
54
|
-
await
|
|
81
|
+
return await this.activeGetAccessTokenPromise;
|
|
55
82
|
}
|
|
56
|
-
|
|
57
|
-
this.
|
|
83
|
+
finally {
|
|
84
|
+
this.activeGetAccessTokenPromise = undefined;
|
|
58
85
|
}
|
|
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
86
|
}
|
|
69
87
|
}
|
package/dist/auth/device-flow.js
CHANGED
|
@@ -1,20 +1,77 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
1
2
|
function sleep(ms) {
|
|
2
3
|
return new Promise((r) => setTimeout(r, ms));
|
|
3
4
|
}
|
|
5
|
+
function isAbortError(err) {
|
|
6
|
+
return err instanceof DOMException
|
|
7
|
+
? err.name === "AbortError"
|
|
8
|
+
: err instanceof Error && err.name === "AbortError";
|
|
9
|
+
}
|
|
10
|
+
function isObjectRecord(value) {
|
|
11
|
+
return value !== null && typeof value === "object";
|
|
12
|
+
}
|
|
13
|
+
async function fetchWithTimeout(input, init, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
16
|
+
try {
|
|
17
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(timeout);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
4
23
|
export async function requestDeviceCode(baseUrl, clientId) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
24
|
+
let res;
|
|
25
|
+
try {
|
|
26
|
+
res = await fetchWithTimeout(`${baseUrl}/api/auth/device/code`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ client_id: clientId }),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (isAbortError(err)) {
|
|
34
|
+
throw new Error("device/code request timed out after 10s.");
|
|
35
|
+
}
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
const parsedBody = await res.json().catch(() => ({}));
|
|
39
|
+
if (!isObjectRecord(parsedBody)) {
|
|
40
|
+
throw new Error(`device/code: unexpected response ${JSON.stringify(parsedBody)}`);
|
|
41
|
+
}
|
|
42
|
+
const body = parsedBody;
|
|
11
43
|
if (!res.ok) {
|
|
12
44
|
throw new Error(`device/code failed (${res.status}): ${JSON.stringify(body)}`);
|
|
13
45
|
}
|
|
14
|
-
if (
|
|
46
|
+
if (typeof body.device_code !== "string") {
|
|
15
47
|
throw new Error(`device/code: unexpected response ${JSON.stringify(body)}`);
|
|
16
48
|
}
|
|
17
|
-
|
|
49
|
+
if (typeof body.user_code !== "string" ||
|
|
50
|
+
typeof body.verification_uri !== "string") {
|
|
51
|
+
throw new Error(`device/code: missing user_code or verification_uri ${JSON.stringify(body)}`);
|
|
52
|
+
}
|
|
53
|
+
const interval = toFiniteNumber(body.interval);
|
|
54
|
+
const expiresIn = toFiniteNumber(body.expires_in);
|
|
55
|
+
if (interval === undefined || expiresIn === undefined) {
|
|
56
|
+
throw new Error(`device/code: invalid interval or expires_in ${JSON.stringify(body)}`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
...body,
|
|
60
|
+
interval,
|
|
61
|
+
expires_in: expiresIn,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function toFiniteNumber(v) {
|
|
65
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
69
|
+
const n = Number(v);
|
|
70
|
+
if (Number.isFinite(n)) {
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
18
75
|
}
|
|
19
76
|
/**
|
|
20
77
|
* Polls `/api/auth/device/token` until success or terminal error.
|
|
@@ -28,21 +85,41 @@ export async function pollDeviceAccessToken(baseUrl, clientId, deviceCode, initi
|
|
|
28
85
|
for (let attempt = 0; Date.now() - started < maxWait; attempt++) {
|
|
29
86
|
if (attempt > 0) {
|
|
30
87
|
await sleep(intervalMs);
|
|
88
|
+
if (Date.now() - started >= maxWait) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
31
91
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
92
|
+
let res;
|
|
93
|
+
try {
|
|
94
|
+
res = await fetchWithTimeout(`${baseUrl}/api/auth/device/token`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
99
|
+
device_code: deviceCode,
|
|
100
|
+
client_id: clientId,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (isAbortError(err)) {
|
|
106
|
+
log("Device token poll request timed out; retrying.");
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() - started >= maxWait) {
|
|
112
|
+
throw new Error("Device authorization timed out waiting for approval.");
|
|
113
|
+
}
|
|
114
|
+
const parsedBody = await res.json().catch(() => ({}));
|
|
115
|
+
if (!isObjectRecord(parsedBody)) {
|
|
116
|
+
throw new Error(`device/token failed (${res.status}): ${JSON.stringify(parsedBody)}`);
|
|
117
|
+
}
|
|
118
|
+
const body = parsedBody;
|
|
42
119
|
if (res.ok && typeof body.access_token === "string") {
|
|
43
120
|
return body.access_token;
|
|
44
121
|
}
|
|
45
|
-
const err = body.error;
|
|
122
|
+
const err = typeof body.error === "string" ? body.error : undefined;
|
|
46
123
|
if (err === "authorization_pending") {
|
|
47
124
|
log("Waiting for device approval…");
|
|
48
125
|
continue;
|
package/dist/install/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
4
|
import { mergeMcpServerEntry } from "./merge-config.js";
|
|
5
5
|
import { resolvePackageEntryPath } from "./resolve-entry.js";
|
|
6
|
-
import { resolveTargetConfigPath } from "./targets.js";
|
|
6
|
+
import { INSTALL_TARGETS, resolveTargetConfigPath, validateCustomConfigPathInput, } from "./targets.js";
|
|
7
7
|
import { promptConfirmOverwrite, promptCustomConfigPath, promptTargetSelect, } from "./wizard.js";
|
|
8
|
+
/** True if `v` looks like a CLI flag (`--long` or single-letter `-y`), not a value like `-my-server`. */
|
|
9
|
+
function isFlagLikeToken(v) {
|
|
10
|
+
if (v.startsWith("--")) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return /^-[A-Za-z]$/.test(v);
|
|
14
|
+
}
|
|
8
15
|
export function parseInstallArgs(argv) {
|
|
9
16
|
let target;
|
|
10
17
|
let output;
|
|
@@ -28,7 +35,7 @@ export function parseInstallArgs(argv) {
|
|
|
28
35
|
}
|
|
29
36
|
if (a === "--target") {
|
|
30
37
|
const v = argv[i + 1];
|
|
31
|
-
if (!v || v
|
|
38
|
+
if (!v || isFlagLikeToken(v)) {
|
|
32
39
|
throw new Error("--target requires a value");
|
|
33
40
|
}
|
|
34
41
|
target = v;
|
|
@@ -41,7 +48,7 @@ export function parseInstallArgs(argv) {
|
|
|
41
48
|
}
|
|
42
49
|
if (a === "--output") {
|
|
43
50
|
const v = argv[i + 1];
|
|
44
|
-
if (!v || v
|
|
51
|
+
if (!v || isFlagLikeToken(v)) {
|
|
45
52
|
throw new Error("--output requires a value");
|
|
46
53
|
}
|
|
47
54
|
output = v;
|
|
@@ -54,7 +61,7 @@ export function parseInstallArgs(argv) {
|
|
|
54
61
|
}
|
|
55
62
|
if (a === "--name") {
|
|
56
63
|
const v = argv[i + 1];
|
|
57
|
-
if (!v || v
|
|
64
|
+
if (!v || isFlagLikeToken(v)) {
|
|
58
65
|
throw new Error("--name requires a value");
|
|
59
66
|
}
|
|
60
67
|
name = v;
|
|
@@ -67,7 +74,7 @@ export function parseInstallArgs(argv) {
|
|
|
67
74
|
}
|
|
68
75
|
if (a === "--api-url") {
|
|
69
76
|
const v = argv[i + 1];
|
|
70
|
-
if (!v || v
|
|
77
|
+
if (!v || isFlagLikeToken(v)) {
|
|
71
78
|
throw new Error("--api-url requires a value");
|
|
72
79
|
}
|
|
73
80
|
apiUrl = v;
|
|
@@ -80,7 +87,7 @@ export function parseInstallArgs(argv) {
|
|
|
80
87
|
}
|
|
81
88
|
if (a === "--project-dir") {
|
|
82
89
|
const v = argv[i + 1];
|
|
83
|
-
if (!v || v
|
|
90
|
+
if (!v || isFlagLikeToken(v)) {
|
|
84
91
|
throw new Error("--project-dir requires a value");
|
|
85
92
|
}
|
|
86
93
|
projectDir = v;
|
|
@@ -114,12 +121,7 @@ function hasExistingServerEntry(jsonText, serverName) {
|
|
|
114
121
|
return false;
|
|
115
122
|
}
|
|
116
123
|
}
|
|
117
|
-
const VALID_TARGETS =
|
|
118
|
-
"cursor-user",
|
|
119
|
-
"cursor-project",
|
|
120
|
-
"claude-desktop",
|
|
121
|
-
"custom",
|
|
122
|
-
];
|
|
124
|
+
const VALID_TARGETS = INSTALL_TARGETS.map((t) => t.id);
|
|
123
125
|
function isInstallTargetId(s) {
|
|
124
126
|
return VALID_TARGETS.includes(s);
|
|
125
127
|
}
|
|
@@ -161,7 +163,7 @@ export async function runInstall(argv) {
|
|
|
161
163
|
process.exitCode = 1;
|
|
162
164
|
return;
|
|
163
165
|
}
|
|
164
|
-
let customPath = parsed.output;
|
|
166
|
+
let customPath = parsed.output?.trim();
|
|
165
167
|
if (targetIds.includes("custom")) {
|
|
166
168
|
if (!customPath) {
|
|
167
169
|
if (input.isTTY && output.isTTY) {
|
|
@@ -173,6 +175,15 @@ export async function runInstall(argv) {
|
|
|
173
175
|
return;
|
|
174
176
|
}
|
|
175
177
|
}
|
|
178
|
+
else {
|
|
179
|
+
const validation = validateCustomConfigPathInput(customPath);
|
|
180
|
+
if (!validation.ok) {
|
|
181
|
+
console.error(`Invalid --output: ${validation.message}`);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
customPath = validation.path;
|
|
186
|
+
}
|
|
176
187
|
}
|
|
177
188
|
const entryPath = resolvePackageEntryPath();
|
|
178
189
|
const env = parsed.apiUrl !== undefined && parsed.apiUrl.length > 0
|
|
@@ -184,6 +195,7 @@ export async function runInstall(argv) {
|
|
|
184
195
|
...(env ? { env } : {}),
|
|
185
196
|
};
|
|
186
197
|
const writtenPaths = [];
|
|
198
|
+
const pending = [];
|
|
187
199
|
for (const targetId of targetIds) {
|
|
188
200
|
const configPath = resolveTargetConfigPath(targetId, {
|
|
189
201
|
cwd: parsed.projectDir,
|
|
@@ -193,13 +205,24 @@ export async function runInstall(argv) {
|
|
|
193
205
|
try {
|
|
194
206
|
existingText = await readFile(configPath, "utf8");
|
|
195
207
|
}
|
|
196
|
-
catch {
|
|
197
|
-
|
|
208
|
+
catch (err) {
|
|
209
|
+
const code = err &&
|
|
210
|
+
typeof err === "object" &&
|
|
211
|
+
"code" in err &&
|
|
212
|
+
typeof err.code === "string"
|
|
213
|
+
? err.code
|
|
214
|
+
: undefined;
|
|
215
|
+
if (code === "ENOENT") {
|
|
216
|
+
existingText = null;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
198
221
|
}
|
|
199
222
|
const already = hasExistingServerEntry(existingText, parsed.name);
|
|
200
223
|
if (already && !parsed.yes) {
|
|
201
224
|
if (input.isTTY && output.isTTY) {
|
|
202
|
-
const ok = await promptConfirmOverwrite(parsed.name);
|
|
225
|
+
const ok = await promptConfirmOverwrite(parsed.name, configPath);
|
|
203
226
|
if (!ok) {
|
|
204
227
|
console.log(`Skipped:\n ${configPath}`);
|
|
205
228
|
continue;
|
|
@@ -211,18 +234,33 @@ export async function runInstall(argv) {
|
|
|
211
234
|
return;
|
|
212
235
|
}
|
|
213
236
|
}
|
|
237
|
+
pending.push({ configPath, existingText });
|
|
238
|
+
}
|
|
239
|
+
const mergedWrites = [];
|
|
240
|
+
for (const p of pending) {
|
|
214
241
|
let merged;
|
|
215
242
|
try {
|
|
216
|
-
merged = mergeMcpServerEntry(existingText, parsed.name, serverConfig);
|
|
243
|
+
merged = mergeMcpServerEntry(p.existingText, parsed.name, serverConfig);
|
|
217
244
|
}
|
|
218
|
-
catch {
|
|
219
|
-
|
|
245
|
+
catch (err) {
|
|
246
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
247
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
248
|
+
console.error(`Cannot update config at ${p.configPath}: ${message}${stack ? `\n${stack}` : ""}`);
|
|
220
249
|
process.exitCode = 1;
|
|
221
250
|
return;
|
|
222
251
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
252
|
+
mergedWrites.push({ configPath: p.configPath, merged });
|
|
253
|
+
}
|
|
254
|
+
for (const w of mergedWrites) {
|
|
255
|
+
await mkdir(dirname(w.configPath), { recursive: true });
|
|
256
|
+
await writeFile(w.configPath, w.merged, { encoding: "utf8", mode: 0o600 });
|
|
257
|
+
try {
|
|
258
|
+
await chmod(w.configPath, 0o600);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* ignore chmod failures on some FS */
|
|
262
|
+
}
|
|
263
|
+
writtenPaths.push(w.configPath);
|
|
226
264
|
}
|
|
227
265
|
if (writtenPaths.length === 0) {
|
|
228
266
|
console.log("No config files were updated.");
|
|
@@ -243,7 +281,7 @@ Usage:
|
|
|
243
281
|
Without options, runs interactively (pick Cursor / Claude / custom path).
|
|
244
282
|
|
|
245
283
|
Options:
|
|
246
|
-
--target <id>
|
|
284
|
+
--target <id> ${VALID_TARGETS.join(" | ")}
|
|
247
285
|
--output <path> Required for --target custom (absolute path to JSON file)
|
|
248
286
|
--project-dir <dir> Base directory for cursor-project (default: current dir)
|
|
249
287
|
--name <string> MCP server key under mcpServers (default: kaneo)
|
|
@@ -1,23 +1,45 @@
|
|
|
1
|
+
const RESERVED_MCP_SERVER_KEYS = new Set([
|
|
2
|
+
"__proto__",
|
|
3
|
+
"constructor",
|
|
4
|
+
"prototype",
|
|
5
|
+
]);
|
|
6
|
+
function isPlainObject(v) {
|
|
7
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
8
|
+
}
|
|
1
9
|
/**
|
|
2
10
|
* Merges or replaces `mcpServers[serverName]` and returns formatted JSON.
|
|
3
11
|
* Preserves other top-level keys and other MCP server entries.
|
|
4
12
|
*/
|
|
5
13
|
export function mergeMcpServerEntry(existingJson, serverName, serverConfig) {
|
|
14
|
+
if (RESERVED_MCP_SERVER_KEYS.has(serverName)) {
|
|
15
|
+
throw new Error(`Refusing MCP server name "${serverName}" (reserved key; use a different --name).`);
|
|
16
|
+
}
|
|
6
17
|
let root = {};
|
|
7
|
-
if (existingJson) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
parsed
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
if (existingJson !== null) {
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(existingJson);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error("Existing MCP config is not valid JSON.");
|
|
25
|
+
}
|
|
26
|
+
if (!isPlainObject(parsed)) {
|
|
27
|
+
throw new Error("Existing MCP config must be a JSON object (not an array or primitive).");
|
|
13
28
|
}
|
|
29
|
+
root = { ...parsed };
|
|
14
30
|
}
|
|
15
31
|
const mcpServers = (() => {
|
|
32
|
+
const map = Object.create(null);
|
|
16
33
|
const raw = root.mcpServers;
|
|
17
34
|
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
18
|
-
|
|
35
|
+
for (const key of Object.keys(raw)) {
|
|
36
|
+
if (RESERVED_MCP_SERVER_KEYS.has(key)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
map[key] = raw[key];
|
|
40
|
+
}
|
|
19
41
|
}
|
|
20
|
-
return
|
|
42
|
+
return map;
|
|
21
43
|
})();
|
|
22
44
|
mcpServers[serverName] = serverConfig;
|
|
23
45
|
root.mcpServers = mcpServers;
|
package/dist/install/targets.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import path, { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Validates a non-interactive custom MCP config path (same rules as the install wizard).
|
|
5
|
+
* Returns the trimmed absolute path on success.
|
|
6
|
+
*/
|
|
7
|
+
export function validateCustomConfigPathInput(raw) {
|
|
8
|
+
const trimmed = raw.trim();
|
|
9
|
+
if (trimmed.length === 0) {
|
|
10
|
+
return { ok: false, message: "Path is required" };
|
|
11
|
+
}
|
|
12
|
+
if (!path.isAbsolute(trimmed)) {
|
|
13
|
+
return { ok: false, message: "Path must be absolute" };
|
|
14
|
+
}
|
|
15
|
+
if (!trimmed.toLowerCase().endsWith(".json")) {
|
|
16
|
+
return { ok: false, message: "Path must end with .json" };
|
|
17
|
+
}
|
|
18
|
+
return { ok: true, path: trimmed };
|
|
19
|
+
}
|
|
3
20
|
export const INSTALL_TARGETS = [
|
|
4
21
|
{
|
|
5
22
|
id: "cursor-user",
|
|
@@ -45,11 +62,11 @@ export function resolveTargetConfigPath(id, options) {
|
|
|
45
62
|
case "claude-desktop":
|
|
46
63
|
return getClaudeDesktopConfigPath();
|
|
47
64
|
case "custom": {
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
50
|
-
throw new Error(
|
|
65
|
+
const result = validateCustomConfigPathInput(options.customPath ?? "");
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
throw new Error(result.message);
|
|
51
68
|
}
|
|
52
|
-
return
|
|
69
|
+
return result.path;
|
|
53
70
|
}
|
|
54
71
|
}
|
|
55
72
|
}
|
package/dist/install/wizard.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import prompts from "prompts";
|
|
2
|
-
import { INSTALL_TARGETS } from "./targets.js";
|
|
2
|
+
import { INSTALL_TARGETS, validateCustomConfigPathInput, } from "./targets.js";
|
|
3
3
|
function onCancel() {
|
|
4
4
|
console.log("\nCancelled.");
|
|
5
5
|
process.exit(0);
|
|
@@ -30,20 +30,24 @@ export async function promptCustomConfigPath() {
|
|
|
30
30
|
type: "text",
|
|
31
31
|
name: "path",
|
|
32
32
|
message: "Absolute path to the JSON config file (create or update):",
|
|
33
|
-
validate: (v) =>
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
validate: (v) => {
|
|
34
|
+
if (typeof v !== "string") {
|
|
35
|
+
return "Path is required";
|
|
36
|
+
}
|
|
37
|
+
const result = validateCustomConfigPathInput(v);
|
|
38
|
+
return result.ok ? true : result.message;
|
|
39
|
+
},
|
|
36
40
|
}, { onCancel });
|
|
37
41
|
if (answer.path === undefined) {
|
|
38
42
|
onCancel();
|
|
39
43
|
}
|
|
40
44
|
return String(answer.path).trim();
|
|
41
45
|
}
|
|
42
|
-
export async function promptConfirmOverwrite(serverName) {
|
|
46
|
+
export async function promptConfirmOverwrite(serverName, configPath) {
|
|
43
47
|
const answer = await prompts({
|
|
44
48
|
type: "confirm",
|
|
45
49
|
name: "overwrite",
|
|
46
|
-
message: `MCP server "${serverName}" is already in this file. Overwrite it?`,
|
|
50
|
+
message: `MCP server "${serverName}" is already in this file (${configPath}). Overwrite it?`,
|
|
47
51
|
initial: false,
|
|
48
52
|
}, { onCancel });
|
|
49
53
|
if (answer.overwrite === undefined) {
|
package/dist/kaneo/client.js
CHANGED
|
@@ -13,7 +13,11 @@ export class KaneoClient {
|
|
|
13
13
|
headers.set("Content-Type", "application/json");
|
|
14
14
|
}
|
|
15
15
|
const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
16
|
-
const
|
|
16
|
+
const timeoutSignal = AbortSignal.timeout(10_000);
|
|
17
|
+
const signal = init?.signal
|
|
18
|
+
? AbortSignal.any([init.signal, timeoutSignal])
|
|
19
|
+
: timeoutSignal;
|
|
20
|
+
const res = await fetch(url, { ...init, headers, signal });
|
|
17
21
|
if (res.status === 401 && !didRetry) {
|
|
18
22
|
await this.auth.clearToken();
|
|
19
23
|
return this.authorizedFetch(path, init, true);
|
|
@@ -42,6 +42,9 @@ export function buildFullTaskUpdateBody(existing, patch) {
|
|
|
42
42
|
if (!projectId) {
|
|
43
43
|
throw new Error("Cannot update task: missing projectId.");
|
|
44
44
|
}
|
|
45
|
+
// When patch.userId is explicitly null, we set userId to "" so the API clears assignee; the API
|
|
46
|
+
// treats "" as unassigned (userId || null). When patch.userId is undefined, keep existing.userId
|
|
47
|
+
// unchanged (leave assignee as-is).
|
|
45
48
|
const userId = patch.userId !== undefined
|
|
46
49
|
? patch.userId === null
|
|
47
50
|
? ""
|
package/dist/tools/register.js
CHANGED
|
@@ -73,25 +73,51 @@ export function registerTools(server, ctx) {
|
|
|
73
73
|
}),
|
|
74
74
|
})));
|
|
75
75
|
server.registerTool("update_project", {
|
|
76
|
-
description: "Update project metadata.",
|
|
76
|
+
description: "Update project metadata (PATCH-style: only provided fields are changed).",
|
|
77
77
|
inputSchema: z.object({
|
|
78
78
|
id: nonEmptyString,
|
|
79
|
-
name:
|
|
80
|
-
icon:
|
|
81
|
-
slug:
|
|
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,
|
|
79
|
+
name: optionalNonEmptyString,
|
|
80
|
+
icon: z.string().optional(),
|
|
81
|
+
slug: optionalNonEmptyString,
|
|
82
|
+
description: z.string().optional(),
|
|
83
|
+
isPublic: z.boolean().optional(),
|
|
93
84
|
}),
|
|
94
|
-
})
|
|
85
|
+
}, async (args) => {
|
|
86
|
+
const { id, ...patch } = args;
|
|
87
|
+
return run(async () => {
|
|
88
|
+
const existing = (await client.json(`/api/project/${encodeURIComponent(id)}`, { method: "GET" }));
|
|
89
|
+
const name = patch.name ??
|
|
90
|
+
(typeof existing.name === "string" ? existing.name : "");
|
|
91
|
+
if (!name) {
|
|
92
|
+
throw new Error("Cannot update project: missing name.");
|
|
93
|
+
}
|
|
94
|
+
const icon = patch.icon !== undefined
|
|
95
|
+
? patch.icon
|
|
96
|
+
: typeof existing.icon === "string"
|
|
97
|
+
? existing.icon
|
|
98
|
+
: "Layout";
|
|
99
|
+
const slug = patch.slug ??
|
|
100
|
+
(typeof existing.slug === "string" ? existing.slug : "");
|
|
101
|
+
if (!slug) {
|
|
102
|
+
throw new Error("Cannot update project: missing slug.");
|
|
103
|
+
}
|
|
104
|
+
const description = patch.description !== undefined
|
|
105
|
+
? patch.description
|
|
106
|
+
: typeof existing.description === "string"
|
|
107
|
+
? existing.description
|
|
108
|
+
: "";
|
|
109
|
+
const isPublic = patch.isPublic !== undefined
|
|
110
|
+
? patch.isPublic
|
|
111
|
+
: typeof existing.isPublic === "boolean"
|
|
112
|
+
? existing.isPublic
|
|
113
|
+
: false;
|
|
114
|
+
const body = { name, icon, slug, description, isPublic };
|
|
115
|
+
return client.json(`/api/project/${encodeURIComponent(id)}`, {
|
|
116
|
+
method: "PUT",
|
|
117
|
+
body: JSON.stringify(body),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
95
121
|
const listTasksSchema = z.object({
|
|
96
122
|
projectId: nonEmptyString,
|
|
97
123
|
status: optionalNonEmptyString,
|
package/dist/utils/mcp-result.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export function textResult(data, isError = false) {
|
|
2
|
+
const text = typeof data === "string" ? data : (JSON.stringify(data, null, 2) ?? "");
|
|
2
3
|
return {
|
|
3
4
|
content: [
|
|
4
5
|
{
|
|
5
6
|
type: "text",
|
|
6
|
-
text
|
|
7
|
+
text,
|
|
7
8
|
},
|
|
8
9
|
],
|
|
9
10
|
isError,
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaneo/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Model Context Protocol (stdio) server for Kaneo — tasks, projects, labels, and device authorization",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/usekaneo/kaneo/",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/usekaneo/kaneo/"
|
|
9
|
+
"url": "https://github.com/usekaneo/kaneo/",
|
|
10
|
+
"directory": "packages/mcp"
|
|
10
11
|
},
|
|
11
12
|
"type": "module",
|
|
12
13
|
"bin": {
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
],
|
|
29
30
|
"scripts": {
|
|
30
31
|
"build": "tsc -p tsconfig.json",
|
|
31
|
-
"prepublishOnly": "
|
|
32
|
+
"prepublishOnly": "pnpm run build",
|
|
32
33
|
"dev": "tsx watch src/index.ts",
|
|
33
34
|
"lint": "biome check --write .",
|
|
34
35
|
"start": "node dist/index.js",
|