@kaneo/mcp 0.1.3 → 0.1.4

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.
@@ -42,6 +42,10 @@ export class AuthService {
42
42
  cached.clientId === this.clientId &&
43
43
  cached.accessToken) {
44
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.
45
49
  if (validation === "valid" || validation === "unknown") {
46
50
  return cached.accessToken;
47
51
  }
@@ -14,7 +14,32 @@ export async function requestDeviceCode(baseUrl, clientId) {
14
14
  if (!("device_code" in body) || typeof body.device_code !== "string") {
15
15
  throw new Error(`device/code: unexpected response ${JSON.stringify(body)}`);
16
16
  }
17
- return body;
17
+ if (typeof body.user_code !== "string" ||
18
+ typeof body.verification_uri !== "string") {
19
+ throw new Error(`device/code: missing user_code or verification_uri ${JSON.stringify(body)}`);
20
+ }
21
+ const interval = toFiniteNumber(body.interval);
22
+ const expiresIn = toFiniteNumber(body.expires_in);
23
+ if (interval === undefined || expiresIn === undefined) {
24
+ throw new Error(`device/code: invalid interval or expires_in ${JSON.stringify(body)}`);
25
+ }
26
+ return {
27
+ ...body,
28
+ interval,
29
+ expires_in: expiresIn,
30
+ };
31
+ }
32
+ function toFiniteNumber(v) {
33
+ if (typeof v === "number" && Number.isFinite(v)) {
34
+ return v;
35
+ }
36
+ if (typeof v === "string" && v.trim() !== "") {
37
+ const n = Number(v);
38
+ if (Number.isFinite(n)) {
39
+ return n;
40
+ }
41
+ }
42
+ return undefined;
18
43
  }
19
44
  /**
20
45
  * Polls `/api/auth/device/token` until success or terminal error.
@@ -38,6 +63,9 @@ export async function pollDeviceAccessToken(baseUrl, clientId, deviceCode, initi
38
63
  client_id: clientId,
39
64
  }),
40
65
  });
66
+ if (Date.now() - started > maxWait) {
67
+ throw new Error("Device authorization timed out waiting for approval.");
68
+ }
41
69
  const body = (await res.json().catch(() => ({})));
42
70
  if (res.ok && typeof body.access_token === "string") {
43
71
  return body.access_token;
@@ -3,8 +3,15 @@ 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.startsWith("-")) {
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.startsWith("-")) {
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.startsWith("-")) {
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.startsWith("-")) {
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.startsWith("-")) {
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
- existingText = null;
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,27 @@ 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
- console.error(`Cannot update config (invalid JSON or merge error): ${configPath}`);
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
- await mkdir(dirname(configPath), { recursive: true });
224
- await writeFile(configPath, merged, { encoding: "utf8", mode: 0o600 });
225
- writtenPaths.push(configPath);
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
+ writtenPaths.push(w.configPath);
226
258
  }
227
259
  if (writtenPaths.length === 0) {
228
260
  console.log("No config files were updated.");
@@ -243,7 +275,7 @@ Usage:
243
275
  Without options, runs interactively (pick Cursor / Claude / custom path).
244
276
 
245
277
  Options:
246
- --target <id> cursor-user | cursor-project | claude-desktop | custom
278
+ --target <id> ${VALID_TARGETS.join(" | ")}
247
279
  --output <path> Required for --target custom (absolute path to JSON file)
248
280
  --project-dir <dir> Base directory for cursor-project (default: current dir)
249
281
  --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
- const parsed = JSON.parse(existingJson);
9
- if (typeof parsed === "object" &&
10
- parsed !== null &&
11
- !Array.isArray(parsed)) {
12
- root = { ...parsed };
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
- return { ...raw };
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;
@@ -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 p = options.customPath?.trim();
49
- if (!p) {
50
- throw new Error("Custom target requires a file path");
65
+ const result = validateCustomConfigPathInput(options.customPath ?? "");
66
+ if (!result.ok) {
67
+ throw new Error(result.message);
51
68
  }
52
- return p;
69
+ return result.path;
53
70
  }
54
71
  }
55
72
  }
@@ -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) => typeof v === "string" && v.trim().length > 0
34
- ? true
35
- : "Path is required",
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) {
@@ -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
  ? ""
@@ -73,25 +73,54 @@ 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: 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,
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
+ : existing.icon != null
97
+ ? String(existing.icon)
98
+ : "";
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
+ : Boolean(existing.isPublic);
112
+ return client.json(`/api/project/${encodeURIComponent(id)}`, {
113
+ method: "PUT",
114
+ body: JSON.stringify({
115
+ name,
116
+ icon,
117
+ slug,
118
+ description,
119
+ isPublic,
120
+ }),
121
+ });
122
+ });
123
+ });
95
124
  const listTasksSchema = z.object({
96
125
  projectId: nonEmptyString,
97
126
  status: optionalNonEmptyString,
@@ -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: typeof data === "string" ? data : JSON.stringify(data, null, 2),
7
+ text,
7
8
  },
8
9
  ],
9
10
  isError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaneo/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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/",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "build": "tsc -p tsconfig.json",
31
- "prepublishOnly": "npm run build",
31
+ "prepublishOnly": "pnpm run build",
32
32
  "dev": "tsx watch src/index.ts",
33
33
  "lint": "biome check --write .",
34
34
  "start": "node dist/index.js",