skedo-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Skedo 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,111 @@
1
+ # skedo-mcp
2
+
3
+ MCP (Model Context Protocol) server for Skedo tasks. A thin, PAT-authenticated
4
+ client over the Skedo HTTP API — no business logic lives here; the Skedo API is
5
+ the source of truth.
6
+
7
+ Works with any MCP client: Claude Code, Cursor, Claude Desktop, etc.
8
+
9
+ ## Tools
10
+
11
+ - `list_tasks` — list tasks (optional `limit`, `status`, `search`)
12
+ - `get_task` — fetch one task by UUID or public ref (for example `SK-42`)
13
+ - `create_task` — create a task (requires `title`; other fields optional)
14
+ - `update_task` — validated patch of an existing task
15
+ - `add_task_link` — append a labeled URL (for example a PR link) to task notes
16
+
17
+ `update_task` accepts only these patch keys: `title`, `description`, `notes`,
18
+ `category`, `tracks`, `priority` (`low | normal | high | urgent`), `status`
19
+ (`todo | inprogress | review | done | rejected | canceled`), `deadline`,
20
+ `assignee` (id string, `null`, or object with `id`).
21
+
22
+ Compatibility alias: `due_date` is accepted and mapped to `deadline`.
23
+
24
+ ## Configuration
25
+
26
+ Configuration comes exclusively from environment variables set in your MCP
27
+ client config. There is no `.env` auto-loading.
28
+
29
+ - `SKEDO_API_BASE_URL` (required) — for example `https://skedo.example.com` or `http://localhost:3000`
30
+ - `SKEDO_API_TOKEN` (required) — Personal Access Token from Skedo settings (`/settings/tokens`)
31
+ - `SKEDO_TIMEOUT_MS` (optional) — outbound API timeout, defaults to `10000`
32
+
33
+ Treat `SKEDO_API_TOKEN` like a secret. Do not commit it or paste it into shared logs.
34
+
35
+ ## Client config
36
+
37
+ ### Claude Code (`.mcp.json` or `~/.claude.json`)
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "skedo": {
43
+ "command": "npx",
44
+ "args": ["-y", "skedo-mcp"],
45
+ "env": {
46
+ "SKEDO_API_BASE_URL": "https://your-skedo-url",
47
+ "SKEDO_API_TOKEN": "<your_pat_here>"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### Cursor
55
+
56
+ Same shape in Cursor's MCP settings:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "skedo": {
62
+ "command": "npx",
63
+ "args": ["-y", "skedo-mcp"],
64
+ "env": {
65
+ "SKEDO_API_BASE_URL": "https://your-skedo-url",
66
+ "SKEDO_API_TOKEN": "<your_pat_here>"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ No repo checkout is needed — `npx` fetches the published package. The MCP client
74
+ launches and stops the server process automatically.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ npm install
80
+ npm run dev # run from source (tsx), needs SKEDO_API_* env vars
81
+ npm run build # compile to dist/
82
+ npm test # build + unit tests + stdio smoke test
83
+ npm run typecheck
84
+ ```
85
+
86
+ To point a local MCP client at your working copy instead of the npm package:
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "skedo": {
92
+ "command": "node",
93
+ "args": ["/absolute/path/to/skedo_mcp/dist/server.js"],
94
+ "env": {
95
+ "SKEDO_API_BASE_URL": "http://localhost:3000",
96
+ "SKEDO_API_TOKEN": "<your_pat_here>"
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ ## Publishing
104
+
105
+ ```bash
106
+ npm publish # prepublishOnly runs build + tests
107
+ ```
108
+
109
+ ## See also
110
+
111
+ - [`skedo-cli`](https://www.npmjs.com/package/skedo-cli) — terminal client for the same API, same PAT auth
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ export const TASK_STATUS_VALUES = [
3
+ "todo",
4
+ "inprogress",
5
+ "review",
6
+ "done",
7
+ "rejected",
8
+ "canceled",
9
+ ];
10
+ export const TASK_PRIORITY_VALUES = ["low", "normal", "high", "urgent"];
11
+ export const assigneeObjectSchema = z
12
+ .object({
13
+ id: z.string().min(1),
14
+ })
15
+ .passthrough();
16
+ export const updateTaskPatchSchema = z
17
+ .object({
18
+ title: z.string().optional(),
19
+ description: z.string().nullable().optional(),
20
+ notes: z.string().nullable().optional(),
21
+ category: z.string().optional(),
22
+ tracks: z.array(z.string()).optional(),
23
+ priority: z.enum(TASK_PRIORITY_VALUES).optional(),
24
+ status: z.enum(TASK_STATUS_VALUES).optional(),
25
+ deadline: z.string().nullable().optional(),
26
+ due_date: z.string().nullable().optional(),
27
+ assignee: z.union([z.string(), z.null(), assigneeObjectSchema]).optional(),
28
+ })
29
+ .strict()
30
+ .refine((value) => Object.keys(value).length > 0, "patch must include at least one supported field");
31
+ // Shape (not a zod object) for registerTool inputSchema. `title` is required;
32
+ // everything else mirrors the update patch and is optional.
33
+ export const createTaskInputSchema = {
34
+ title: z.string().min(1),
35
+ description: z.string().nullable().optional(),
36
+ notes: z.string().nullable().optional(),
37
+ category: z.string().optional(),
38
+ tracks: z.array(z.string()).optional(),
39
+ priority: z.enum(TASK_PRIORITY_VALUES).optional(),
40
+ status: z.enum(TASK_STATUS_VALUES).optional(),
41
+ deadline: z.string().nullable().optional(),
42
+ due_date: z.string().nullable().optional(),
43
+ assignee: z.union([z.string(), z.null(), assigneeObjectSchema]).optional(),
44
+ };
45
+ export function normalizeTaskPatch(patch) {
46
+ const normalizedPatch = { ...patch };
47
+ const normalizationWarnings = [];
48
+ if ("due_date" in normalizedPatch && "deadline" in normalizedPatch) {
49
+ normalizationWarnings.push("Both deadline and due_date were provided. Using deadline and ignoring due_date.");
50
+ }
51
+ if ("due_date" in normalizedPatch && !("deadline" in normalizedPatch)) {
52
+ normalizedPatch.deadline = normalizedPatch.due_date;
53
+ }
54
+ delete normalizedPatch.due_date;
55
+ const assignee = normalizedPatch.assignee;
56
+ if (assignee && typeof assignee === "object" && "id" in assignee) {
57
+ normalizationWarnings.push("Assignee object was normalized to assignee id string.");
58
+ normalizedPatch.assignee = assignee.id;
59
+ }
60
+ return { normalizedPatch, normalizationWarnings };
61
+ }
package/dist/server.js ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { TASK_STATUS_VALUES, createTaskInputSchema, normalizeTaskPatch, updateTaskPatchSchema, } from "./normalize.js";
6
+ // Configuration comes exclusively from environment variables supplied by the
7
+ // MCP client config (Claude Code, Cursor, ...). No .env auto-loading: this
8
+ // server is installed via npm/npx and has no repo root to resolve files from.
9
+ function getRequiredEnv(name) {
10
+ const value = process.env[name]?.trim();
11
+ if (!value) {
12
+ throw new Error(`Missing required environment variable: ${name}`);
13
+ }
14
+ return value;
15
+ }
16
+ const apiBaseUrl = getRequiredEnv("SKEDO_API_BASE_URL").replace(/\/+$/, "");
17
+ const apiToken = getRequiredEnv("SKEDO_API_TOKEN");
18
+ const timeoutMs = Number(process.env.SKEDO_TIMEOUT_MS ?? "10000");
19
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
20
+ throw new Error("SKEDO_TIMEOUT_MS must be a positive number");
21
+ }
22
+ const server = new McpServer({
23
+ name: "skedo-mcp",
24
+ version: "0.1.0",
25
+ });
26
+ function toToolResult(payload) {
27
+ return {
28
+ content: [
29
+ {
30
+ type: "text",
31
+ text: JSON.stringify(payload, null, 2),
32
+ },
33
+ ],
34
+ structuredContent: payload,
35
+ };
36
+ }
37
+ function toErrorResult(error) {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ return {
40
+ isError: true,
41
+ content: [
42
+ {
43
+ type: "text",
44
+ text: message,
45
+ },
46
+ ],
47
+ };
48
+ }
49
+ async function skedoRequest(path, init = {}) {
50
+ const controller = new AbortController();
51
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
52
+ try {
53
+ const response = await fetch(`${apiBaseUrl}${path}`, {
54
+ ...init,
55
+ headers: {
56
+ Accept: "application/json",
57
+ "Content-Type": "application/json",
58
+ Authorization: `Bearer ${apiToken}`,
59
+ ...(init.headers ?? {}),
60
+ },
61
+ signal: controller.signal,
62
+ });
63
+ const raw = await response.text();
64
+ let json;
65
+ try {
66
+ json = raw ? JSON.parse(raw) : {};
67
+ }
68
+ catch {
69
+ json = { raw };
70
+ }
71
+ if (!response.ok) {
72
+ const message = typeof json?.error === "string"
73
+ ? json.error
74
+ : `Skedo API request failed (${response.status})`;
75
+ throw new Error(message);
76
+ }
77
+ return json;
78
+ }
79
+ catch (error) {
80
+ if (error instanceof Error && error.name === "AbortError") {
81
+ throw new Error(`Skedo API request timed out after ${timeoutMs}ms`);
82
+ }
83
+ throw error;
84
+ }
85
+ finally {
86
+ clearTimeout(timeout);
87
+ }
88
+ }
89
+ async function fetchTask(idOrRef) {
90
+ const payload = await skedoRequest(`/api/tasks/${encodeURIComponent(idOrRef)}`);
91
+ if (!payload?.task) {
92
+ throw new Error("Skedo API returned no task payload");
93
+ }
94
+ return payload.task;
95
+ }
96
+ server.registerTool("list_tasks", {
97
+ title: "List tasks",
98
+ description: "List tasks from Skedo.",
99
+ inputSchema: {
100
+ limit: z.number().int().min(1).max(100).optional(),
101
+ status: z.enum(TASK_STATUS_VALUES).optional(),
102
+ search: z.string().min(1).optional(),
103
+ },
104
+ }, async ({ limit, status, search }) => {
105
+ try {
106
+ const payload = await skedoRequest("/api/tasks");
107
+ const allTasks = Array.isArray(payload?.tasks) ? payload.tasks : [];
108
+ const normalizedSearch = search?.toLowerCase().trim();
109
+ const filtered = allTasks.filter((task) => {
110
+ if (status && task?.status !== status) {
111
+ return false;
112
+ }
113
+ if (!normalizedSearch) {
114
+ return true;
115
+ }
116
+ const title = String(task?.title ?? "").toLowerCase();
117
+ const description = String(task?.description ?? "").toLowerCase();
118
+ const notes = String(task?.notes ?? "").toLowerCase();
119
+ const ref = String(task?.public_ref ?? "").toLowerCase();
120
+ return (title.includes(normalizedSearch) ||
121
+ description.includes(normalizedSearch) ||
122
+ notes.includes(normalizedSearch) ||
123
+ ref.includes(normalizedSearch));
124
+ });
125
+ const finalTasks = typeof limit === "number" && Number.isInteger(limit)
126
+ ? filtered.slice(0, limit)
127
+ : filtered;
128
+ return toToolResult({
129
+ count: finalTasks.length,
130
+ items: finalTasks,
131
+ });
132
+ }
133
+ catch (error) {
134
+ return toErrorResult(error);
135
+ }
136
+ });
137
+ server.registerTool("get_task", {
138
+ title: "Get task",
139
+ description: "Get one task by UUID or public ref (for example SK-42).",
140
+ inputSchema: {
141
+ idOrRef: z.string().min(1),
142
+ },
143
+ }, async ({ idOrRef }) => {
144
+ try {
145
+ const task = await fetchTask(idOrRef);
146
+ return toToolResult({ task });
147
+ }
148
+ catch (error) {
149
+ return toErrorResult(error);
150
+ }
151
+ });
152
+ server.registerTool("create_task", {
153
+ title: "Create task",
154
+ description: "Create a new task in Skedo. Required: title. Optional: description, notes, category (defaults to 'none'), tracks, priority (defaults to normal), status (defaults to todo). New tasks land at the top of their status column. Compatibility alias: due_date (mapped to deadline). Assignee can be an id string ('owner-<profileId>' or an employee id), null, or an object with id.",
155
+ inputSchema: createTaskInputSchema,
156
+ }, async (args) => {
157
+ try {
158
+ // Reuse the update normalizer for the due_date->deadline alias and the
159
+ // assignee object->id shape so create and update behave identically.
160
+ const { normalizedPatch, normalizationWarnings } = normalizeTaskPatch(args);
161
+ if (!("category" in normalizedPatch)) {
162
+ normalizedPatch.category = "none";
163
+ }
164
+ const payload = await skedoRequest("/api/tasks", {
165
+ method: "POST",
166
+ body: JSON.stringify(normalizedPatch),
167
+ });
168
+ return toToolResult({
169
+ ...payload,
170
+ created: normalizedPatch,
171
+ normalizationWarnings,
172
+ });
173
+ }
174
+ catch (error) {
175
+ return toErrorResult(error);
176
+ }
177
+ });
178
+ server.registerTool("update_task", {
179
+ title: "Update task",
180
+ description: "Update a task by UUID or public ref. Supported patch fields: title, description, notes, category, tracks, priority, status, deadline, assignee. Compatibility alias: due_date (mapped to deadline). Assignee can be id string, null, or object with id.",
181
+ inputSchema: {
182
+ idOrRef: z.string().min(1),
183
+ patch: updateTaskPatchSchema,
184
+ },
185
+ }, async ({ idOrRef, patch }) => {
186
+ try {
187
+ const { normalizedPatch, normalizationWarnings } = normalizeTaskPatch(patch);
188
+ const payload = await skedoRequest(`/api/tasks/${encodeURIComponent(idOrRef)}`, {
189
+ method: "PUT",
190
+ body: JSON.stringify(normalizedPatch),
191
+ });
192
+ return toToolResult({
193
+ ...payload,
194
+ patchApplied: normalizedPatch,
195
+ normalizationWarnings,
196
+ });
197
+ }
198
+ catch (error) {
199
+ return toErrorResult(error);
200
+ }
201
+ });
202
+ server.registerTool("add_task_link", {
203
+ title: "Add task link",
204
+ description: "Append a labeled URL to task notes, useful for linking PRs (for example GitHub PR URL).",
205
+ inputSchema: {
206
+ idOrRef: z.string().min(1),
207
+ url: z.string().url(),
208
+ label: z.string().min(1).optional(),
209
+ },
210
+ }, async ({ idOrRef, url, label }) => {
211
+ try {
212
+ const task = await fetchTask(idOrRef);
213
+ const effectiveLabel = label?.trim() || "Link";
214
+ const existingNotes = typeof task.notes === "string" ? task.notes.trim() : "";
215
+ const line = `${effectiveLabel}: ${url}`;
216
+ if (existingNotes.includes(url)) {
217
+ return toToolResult({
218
+ ok: true,
219
+ skipped: true,
220
+ reason: "URL already present in task notes",
221
+ idOrRef,
222
+ task,
223
+ });
224
+ }
225
+ const notes = existingNotes ? `${existingNotes}\n${line}` : line;
226
+ const payload = await skedoRequest(`/api/tasks/${encodeURIComponent(idOrRef)}`, {
227
+ method: "PUT",
228
+ body: JSON.stringify({ notes }),
229
+ });
230
+ return toToolResult({
231
+ ok: true,
232
+ idOrRef,
233
+ appendedLine: line,
234
+ task: payload?.task ?? null,
235
+ });
236
+ }
237
+ catch (error) {
238
+ return toErrorResult(error);
239
+ }
240
+ });
241
+ const transport = new StdioServerTransport();
242
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "skedo-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "MCP server for Skedo tasks. Thin, PAT-authenticated client over the Skedo API.",
6
+ "type": "module",
7
+ "bin": {
8
+ "skedo-mcp": "./dist/server.js"
9
+ },
10
+ "files": [
11
+ "dist/**/*.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "keywords": [
19
+ "skedo",
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "tasks",
23
+ "kanban"
24
+ ],
25
+ "license": "MIT",
26
+ "scripts": {
27
+ "dev": "tsx src/server.ts",
28
+ "build": "tsc -p tsconfig.json",
29
+ "typecheck": "tsc --noEmit",
30
+ "start": "node dist/server.js",
31
+ "test": "npm run build && vitest run",
32
+ "prepack": "node -e \"const fs=require('fs'); if(!fs.existsSync('README.md')){console.error('README.md missing; npm would publish without a readme on the registry'); process.exit(1)}\"",
33
+ "prepublishOnly": "npm run build && npm test"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
+ "zod": "^4.4.3"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.5.2",
41
+ "tsx": "^4.20.5",
42
+ "typescript": "^5.9.2",
43
+ "vitest": "^3.2.4"
44
+ }
45
+ }