@kaneo/mcp 0.1.4 → 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 -29
- package/dist/auth/device-flow.js +68 -19
- package/dist/install/index.js +7 -1
- package/dist/kaneo/client.js +5 -1
- package/dist/tools/register.js +8 -11
- package/package.json +3 -2
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,38 +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
|
-
// Fail-open for "unknown": validateAccessToken returns "unknown" on transient HTTP/network
|
|
46
|
-
// errors or ambiguous responses. Treating "unknown" like "valid" avoids clearToken() and a full
|
|
47
|
-
// device re-auth so flaky connectivity does not wipe cached.accessToken; only "invalid" forces
|
|
48
|
-
// a fresh login. Tests should cover both unknown and invalid paths.
|
|
49
|
-
if (validation === "valid" || validation === "unknown") {
|
|
50
|
-
return cached.accessToken;
|
|
51
|
-
}
|
|
52
|
-
await this.clearToken();
|
|
42
|
+
if (this.activeGetAccessTokenPromise) {
|
|
43
|
+
return await this.activeGetAccessTokenPromise;
|
|
53
44
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
})();
|
|
57
80
|
try {
|
|
58
|
-
await
|
|
81
|
+
return await this.activeGetAccessTokenPromise;
|
|
59
82
|
}
|
|
60
|
-
|
|
61
|
-
this.
|
|
83
|
+
finally {
|
|
84
|
+
this.activeGetAccessTokenPromise = undefined;
|
|
62
85
|
}
|
|
63
|
-
const accessToken = await pollDeviceAccessToken(this.baseUrl, this.clientId, code.device_code, code.interval, { log: (m) => this.log(m) });
|
|
64
|
-
const toStore = {
|
|
65
|
-
version: 1,
|
|
66
|
-
baseUrl: this.baseUrl,
|
|
67
|
-
clientId: this.clientId,
|
|
68
|
-
accessToken,
|
|
69
|
-
};
|
|
70
|
-
await saveCredentials(toStore);
|
|
71
|
-
return accessToken;
|
|
72
86
|
}
|
|
73
87
|
}
|
package/dist/auth/device-flow.js
CHANGED
|
@@ -1,17 +1,49 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
1
2
|
function sleep(ms) {
|
|
2
3
|
return new Promise((r) => setTimeout(r, ms));
|
|
3
4
|
}
|
|
5
|
+
function isAbortError(err) {
|
|
6
|
+
return err instanceof DOMException
|
|
7
|
+
? err.name === "AbortError"
|
|
8
|
+
: err instanceof Error && err.name === "AbortError";
|
|
9
|
+
}
|
|
10
|
+
function isObjectRecord(value) {
|
|
11
|
+
return value !== null && typeof value === "object";
|
|
12
|
+
}
|
|
13
|
+
async function fetchWithTimeout(input, init, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
16
|
+
try {
|
|
17
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(timeout);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
4
23
|
export async function requestDeviceCode(baseUrl, clientId) {
|
|
5
|
-
|
|
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" ||
|
|
@@ -53,24 +85,41 @@ export async function pollDeviceAccessToken(baseUrl, clientId, deviceCode, initi
|
|
|
53
85
|
for (let attempt = 0; Date.now() - started < maxWait; attempt++) {
|
|
54
86
|
if (attempt > 0) {
|
|
55
87
|
await sleep(intervalMs);
|
|
88
|
+
if (Date.now() - started >= maxWait) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
56
91
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
92
|
+
let res;
|
|
93
|
+
try {
|
|
94
|
+
res = await fetchWithTimeout(`${baseUrl}/api/auth/device/token`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
99
|
+
device_code: deviceCode,
|
|
100
|
+
client_id: clientId,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (isAbortError(err)) {
|
|
106
|
+
log("Device token poll request timed out; retrying.");
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() - started >= maxWait) {
|
|
67
112
|
throw new Error("Device authorization timed out waiting for approval.");
|
|
68
113
|
}
|
|
69
|
-
const
|
|
114
|
+
const parsedBody = await res.json().catch(() => ({}));
|
|
115
|
+
if (!isObjectRecord(parsedBody)) {
|
|
116
|
+
throw new Error(`device/token failed (${res.status}): ${JSON.stringify(parsedBody)}`);
|
|
117
|
+
}
|
|
118
|
+
const body = parsedBody;
|
|
70
119
|
if (res.ok && typeof body.access_token === "string") {
|
|
71
120
|
return body.access_token;
|
|
72
121
|
}
|
|
73
|
-
const err = body.error;
|
|
122
|
+
const err = typeof body.error === "string" ? body.error : undefined;
|
|
74
123
|
if (err === "authorization_pending") {
|
|
75
124
|
log("Waiting for device approval…");
|
|
76
125
|
continue;
|
package/dist/install/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
4
|
import { mergeMcpServerEntry } from "./merge-config.js";
|
|
@@ -254,6 +254,12 @@ export async function runInstall(argv) {
|
|
|
254
254
|
for (const w of mergedWrites) {
|
|
255
255
|
await mkdir(dirname(w.configPath), { recursive: true });
|
|
256
256
|
await writeFile(w.configPath, w.merged, { encoding: "utf8", mode: 0o600 });
|
|
257
|
+
try {
|
|
258
|
+
await chmod(w.configPath, 0o600);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* ignore chmod failures on some FS */
|
|
262
|
+
}
|
|
257
263
|
writtenPaths.push(w.configPath);
|
|
258
264
|
}
|
|
259
265
|
if (writtenPaths.length === 0) {
|
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);
|
package/dist/tools/register.js
CHANGED
|
@@ -93,9 +93,9 @@ export function registerTools(server, ctx) {
|
|
|
93
93
|
}
|
|
94
94
|
const icon = patch.icon !== undefined
|
|
95
95
|
? patch.icon
|
|
96
|
-
: existing.icon
|
|
97
|
-
?
|
|
98
|
-
: "";
|
|
96
|
+
: typeof existing.icon === "string"
|
|
97
|
+
? existing.icon
|
|
98
|
+
: "Layout";
|
|
99
99
|
const slug = patch.slug ??
|
|
100
100
|
(typeof existing.slug === "string" ? existing.slug : "");
|
|
101
101
|
if (!slug) {
|
|
@@ -108,16 +108,13 @@ export function registerTools(server, ctx) {
|
|
|
108
108
|
: "";
|
|
109
109
|
const isPublic = patch.isPublic !== undefined
|
|
110
110
|
? patch.isPublic
|
|
111
|
-
:
|
|
111
|
+
: typeof existing.isPublic === "boolean"
|
|
112
|
+
? existing.isPublic
|
|
113
|
+
: false;
|
|
114
|
+
const body = { name, icon, slug, description, isPublic };
|
|
112
115
|
return client.json(`/api/project/${encodeURIComponent(id)}`, {
|
|
113
116
|
method: "PUT",
|
|
114
|
-
body: JSON.stringify(
|
|
115
|
-
name,
|
|
116
|
-
icon,
|
|
117
|
-
slug,
|
|
118
|
-
description,
|
|
119
|
-
isPublic,
|
|
120
|
-
}),
|
|
117
|
+
body: JSON.stringify(body),
|
|
121
118
|
});
|
|
122
119
|
});
|
|
123
120
|
});
|
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": {
|