surecore-studio-mcp 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Surecore
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 CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # surecore-studio-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MCP server that lets Claude read your SurecoreStudio tasks and update task status.
4
+
5
+ ## Install (developers)
6
+
7
+ 1. In the app, generate a token: **Settings → Developer → Generate** (copy it — it's shown once).
8
+
9
+ 2. Install the server, then register it with Claude Code:
10
+
11
+ npm install -g surecore-studio-mcp
12
+ claude mcp add surecore-tasks \
13
+ --env SURECORE_PAT=sc_pat_your_token \
14
+ -- surecore-tasks-mcp
15
+
16
+ Or add it to your `.mcp.json` by hand:
17
+
18
+ {
19
+ "mcpServers": {
20
+ "surecore-tasks": {
21
+ "command": "surecore-tasks-mcp",
22
+ "env": {
23
+ "SURECORE_PAT": "sc_pat_your_token",
24
+ "SURECORE_API_URL": "https://surecorestudio.surecore.cc/api",
25
+ "SURECORE_TENANT": "surecorestudio"
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ `SURECORE_API_URL` and `SURECORE_TENANT` are optional — they default to the values shown.
32
+
33
+ > **Zero-install (npx):** `npx -y surecore-studio-mcp` also works on most setups. If Claude Code shows the server as failed with `command not found`, that's a known `npx` bin-linking quirk on some npm versions — use the global install above (reliable), or clear the npx cache with `rm -rf ~/.npm/_npx`.
34
+
35
+ ## Tools
36
+
37
+ Reads:
38
+
39
+ - `list_my_tasks` — your assigned tasks (filter by status, project or tag)
40
+ - `get_task` — one task in full: fields, description, assignee names, comment thread and the list of attached files. No second call needed to see comments.
41
+ - `search_tasks` — by status, project, tag and/or free text
42
+ - `list_comments` — just the comment thread, oldest first
43
+ - `get_attachment` — pull one attached file into the conversation; images render inline
44
+ - `list_members` — the workspace members with their ids, for turning a member id into a person
45
+
46
+ Writes (each asks you to confirm first):
47
+
48
+ - `create_task` — create a task or issue under a project (by project name or id; not tickets)
49
+ - `update_task` — change title, description, priority, status, due date, tags or assignees; only the fields you pass are touched. Assignees accept member names or ids.
50
+ - `update_task_status` — move a task across the workflow
51
+ - `add_comment` — post a comment, always attributed to the token holder
52
+
53
+ ### Statuses and tags come from your workspace
54
+
55
+ SurecoreStudio lets you define your own board columns and tags, so this server
56
+ reads `/task-statuses` and `/task-tags` at startup and offers exactly what your
57
+ workspace has. Nothing is hardcoded — a status or tag you add today is usable
58
+ here as soon as the server restarts. (Before 0.1.4 the status list was fixed at
59
+ release, which made any status added later impossible to set or filter by.)
60
+
61
+ ## Local development
62
+
63
+ cd mcp/surecore-tasks
64
+ npm install
65
+ npm run build
66
+ npm test
67
+
68
+ To run a local build instead of the published package, point Claude at `dist/index.js`:
69
+
70
+ {
71
+ "command": "node",
72
+ "args": ["/absolute/path/to/mcp/surecore-tasks/dist/index.js"],
73
+ "env": { "SURECORE_PAT": "sc_pat_..." }
74
+ }
@@ -0,0 +1,74 @@
1
+ export class ApiError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'ApiError';
7
+ }
8
+ }
9
+ export function createApiClient(cfg, fetchImpl = fetch) {
10
+ async function req(path, init = {}) {
11
+ let res;
12
+ try {
13
+ res = await fetchImpl(`${cfg.apiUrl}${path}`, {
14
+ ...init,
15
+ headers: {
16
+ 'Content-Type': 'application/json',
17
+ ...init.headers,
18
+ 'Authorization': `Bearer ${cfg.pat}`,
19
+ 'X-Tenant-Id': cfg.tenant,
20
+ },
21
+ });
22
+ }
23
+ catch (err) {
24
+ throw new ApiError('network', `Network error reaching the API: ${err.message}`);
25
+ }
26
+ if (!res.ok) {
27
+ const code = res.status === 401 ? 'auth' : res.status === 403 ? 'forbidden' : res.status === 404 ? 'not_found' : 'server';
28
+ throw new ApiError(code, `API returned ${res.status}`);
29
+ }
30
+ // The API wraps success responses as { ok: true, data: <T> } — unwrap to the payload.
31
+ const payload = (await res.json());
32
+ return (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload);
33
+ }
34
+ return {
35
+ getMe: () => req('/v1/auth/me'),
36
+ listTasks: () => req('/v1/tasks'),
37
+ getTask: (id) => req(`/v1/tasks/${encodeURIComponent(id)}`),
38
+ moveTask: (id, status) => req(`/v1/tasks/${encodeURIComponent(id)}/move`, { method: 'POST', body: JSON.stringify({ status }) }),
39
+ listProjects: () => req('/v1/projects'),
40
+ createTask: (input) => req('/v1/tasks', { method: 'POST', body: JSON.stringify(input) }),
41
+ updateTask: (id, patch) => req(`/v1/tasks/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) }),
42
+ // The workspace's own workflow and tag catalogue — fetched, never assumed.
43
+ listStatuses: () => req('/v1/task-statuses'),
44
+ listTags: () => req('/v1/task-tags'),
45
+ listMembers: () => req('/v1/members'),
46
+ listComments: (taskId) => req(`/v1/comments/${encodeURIComponent(taskId)}`),
47
+ addComment: (input) => req('/v1/comments', { method: 'POST', body: JSON.stringify(input) }),
48
+ listAttachments: (taskId) => req(`/v1/attachments/${encodeURIComponent(taskId)}`),
49
+ getAttachmentDataUrl: (id) => req(`/v1/attachments/${encodeURIComponent(id)}/data-url`),
50
+ uploadAttachment: async (taskId, file) => {
51
+ // Multipart, so Content-Type must be left to fetch (it has to add the
52
+ // boundary). req() always sets application/json, hence the direct call.
53
+ const form = new FormData();
54
+ form.append('file', new Blob([file.bytes], { type: file.mimeType }), file.name);
55
+ let res;
56
+ try {
57
+ res = await fetchImpl(`${cfg.apiUrl}/v1/attachments/${encodeURIComponent(taskId)}/upload`, {
58
+ method: 'POST',
59
+ headers: { Authorization: `Bearer ${cfg.pat}`, 'X-Tenant-Id': cfg.tenant },
60
+ body: form,
61
+ });
62
+ }
63
+ catch (err) {
64
+ throw new ApiError('network', `Network error reaching the API: ${err.message}`);
65
+ }
66
+ if (!res.ok) {
67
+ const code = res.status === 401 ? 'auth' : res.status === 403 ? 'forbidden' : res.status === 404 ? 'not_found' : 'server';
68
+ throw new ApiError(code, `Upload returned ${res.status}`);
69
+ }
70
+ const payload = (await res.json());
71
+ return payload?.data ?? [];
72
+ },
73
+ };
74
+ }
package/dist/config.js ADDED
@@ -0,0 +1,12 @@
1
+ export function loadConfig() {
2
+ const pat = process.env.SURECORE_PAT ?? '';
3
+ if (!pat) {
4
+ process.stderr.write('surecore-tasks: SURECORE_PAT is required.\n');
5
+ process.exit(1);
6
+ }
7
+ return {
8
+ apiUrl: (process.env.SURECORE_API_URL ?? 'https://surecorestudio.surecore.cc/api').replace(/\/+$/, ''),
9
+ pat,
10
+ tenant: process.env.SURECORE_TENANT ?? 'surecorestudio',
11
+ };
12
+ }
package/dist/index.js ADDED
@@ -0,0 +1,90 @@
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 { loadConfig } from './config.js';
6
+ import { createApiClient } from './apiClient.js';
7
+ import { CREATE_TASK_TYPES, listMyTasks, getTaskTool, searchTasks, updateTaskStatus, createTaskTool, updateTask, listComments, addComment, getAttachment, addAttachment, listMembersTool, } from './tools.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Workspace cache — statuses and tags are user-definable and change at any
10
+ // time. Re-fetch at most once per TTL so every tool call uses a fresh list
11
+ // without hammering the API on every keystroke.
12
+ // ---------------------------------------------------------------------------
13
+ const WS_TTL_MS = 60_000;
14
+ class WorkspaceCache {
15
+ api;
16
+ ws = null;
17
+ fetchedAt = 0;
18
+ constructor(api) {
19
+ this.api = api;
20
+ }
21
+ async get() {
22
+ if (this.ws && Date.now() - this.fetchedAt < WS_TTL_MS)
23
+ return this.ws;
24
+ const [statuses, tags, members] = await Promise.all([
25
+ this.api.listStatuses(),
26
+ this.api.listTags().catch(() => []),
27
+ this.api.listMembers().catch(() => []),
28
+ ]);
29
+ this.ws = { statuses, tags, members };
30
+ this.fetchedAt = Date.now();
31
+ return this.ws;
32
+ }
33
+ /** Call after a local mutation so the next read is always fresh. */
34
+ invalidate() { this.fetchedAt = 0; }
35
+ }
36
+ async function main() {
37
+ const cfg = loadConfig();
38
+ const api = createApiClient(cfg);
39
+ // Fail fast with a clear message if the PAT is invalid/revoked.
40
+ let me;
41
+ try {
42
+ me = await api.getMe();
43
+ }
44
+ catch {
45
+ process.stderr.write('surecore-tasks: could not authenticate — check SURECORE_PAT (generate one in Settings › Developer).\n');
46
+ process.exit(1);
47
+ }
48
+ const wsCache = new WorkspaceCache(api);
49
+ // -------------------------------------------------------------------------
50
+ // Status and tag params use z.string() rather than z.enum(). The enum was
51
+ // baked in at startup and silently rejected any value added after the
52
+ // process started. The server validates the value anyway (and returns a
53
+ // clear error listing current valid values), so the enum gate is redundant
54
+ // and harmful.
55
+ // -------------------------------------------------------------------------
56
+ const statusParam = z.string().describe('A status value (e.g. "todo", "in-progress"). The server validates against the workspace\'s current list and returns valid options on a mismatch.');
57
+ const tagParam = z.string().describe('A tag value defined in this workspace.');
58
+ const server = new McpServer({ name: 'surecore-tasks', version: '0.1.7' });
59
+ server.tool('list_my_tasks', 'List the tasks assigned to you, optionally filtered by status, project or tag.', { status: statusParam.optional(), projectId: z.string().optional(), tag: tagParam.optional() }, async (args) => listMyTasks(api, me, args));
60
+ server.tool('get_task', 'Get one task in full: its fields, description, assignee names, comment thread and the list of attached files. Use this for "read task N" — a second call is not needed for comments.', { task: z.string().describe('Task number or appId') }, async (args) => getTaskTool(api, args, await wsCache.get(), me));
61
+ server.tool('search_tasks', 'Search tasks by status, project, tag and/or free text. Statuses and tags are fetched fresh on each call so newly created values are always recognised.', { status: statusParam.optional(), projectId: z.string().optional(), text: z.string().optional(), tag: tagParam.optional() }, async (args) => searchTasks(api, args, await wsCache.get()));
62
+ server.tool('create_task', 'Create a new task or issue under a project (NOT a ticket — tickets come from support intake). Confirm the title, project, and type with the user before calling. `project` accepts a project name or id.', {
63
+ title: z.string().describe('The task title'),
64
+ project: z.string().describe('Project name (e.g. "AAIB") or project id'),
65
+ type: z.enum(CREATE_TASK_TYPES).default('task').describe('task or issue'),
66
+ description: z.string().optional(),
67
+ priority: z.enum(['low', 'medium', 'high']).optional(),
68
+ }, (args) => createTaskTool(api, args));
69
+ server.tool('update_task', 'Change a task\'s details: title, description, priority, status, due date, tags or assignees. Only the fields you pass are changed. Confirm the change with the user before calling.', {
70
+ task: z.string().describe('Task number or appId'),
71
+ title: z.string().optional(),
72
+ description: z.string().optional(),
73
+ priority: z.enum(['low', 'medium', 'high']).optional(),
74
+ status: statusParam.optional(),
75
+ dueDate: z.string().optional().describe('YYYY-MM-DD'),
76
+ tags: z.array(tagParam).optional().describe('Replaces the task\'s tags'),
77
+ assignees: z.array(z.string()).optional().describe('Member names or ids; replaces the current assignees'),
78
+ }, async (args) => { const ws = await wsCache.get(); return updateTask(api, args, ws); });
79
+ server.tool('update_task_status', 'Move a task to a new status. Confirm the task and the target status with the user before calling. For customer-linked tickets this notifies the customer, exactly like the web app.', { task: z.string().describe('Task number or appId'), status: statusParam }, async (args) => { const ws = await wsCache.get(); return updateTaskStatus(api, args, ws); });
80
+ server.tool('get_attachment', 'Pull one file attached to a task into the conversation. Images are shown inline; other types are reported by name and type. `file` accepts the file name or its id.', { task: z.string().describe('Task number or appId'), file: z.string().describe('Attachment file name or id') }, (args) => getAttachment(api, args));
81
+ server.tool('list_members', 'List the workspace members with their ids. Use this to turn a member id seen on a task into a person, or to find the id for an assignee.', {}, async () => listMembersTool(await wsCache.get()));
82
+ server.tool('add_attachment', 'Attach a file from this machine to a task. Give the full path. Used to attach a work report to a task when the work is finished.', { task: z.string().describe('Task number or appId'), path: z.string().describe('Full path to the file on this machine') }, (args) => addAttachment(api, args));
83
+ server.tool('list_comments', 'Read the comment thread on a task, oldest first.', { task: z.string().describe('Task number or appId') }, (args) => listComments(api, args));
84
+ server.tool('add_comment', 'Post a comment on a task. It is always attributed to you (the token holder). Confirm the wording with the user before calling.', { task: z.string().describe('Task number or appId'), text: z.string().describe('The comment body') }, (args) => addComment(api, me, args));
85
+ await server.connect(new StdioServerTransport());
86
+ }
87
+ main().catch((err) => {
88
+ process.stderr.write(`surecore-tasks: fatal ${err?.message ?? err}\n`);
89
+ process.exit(1);
90
+ });
package/dist/tools.js ADDED
@@ -0,0 +1,389 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ApiError } from './apiClient.js';
3
+ function statusValues(ws) { return ws.statuses.map((s) => s.value); }
4
+ function tagValues(ws) { return ws.tags.map((t) => t.value); }
5
+ /** A task read shows at most this many comments, newest last. */
6
+ const MAX_INLINE_COMMENTS = 20;
7
+ function humanSize(bytes) {
8
+ if (!bytes)
9
+ return '';
10
+ if (bytes < 1024)
11
+ return `${bytes} B`;
12
+ if (bytes < 1024 * 1024)
13
+ return `${Math.round(bytes / 1024)} KB`;
14
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
15
+ }
16
+ function renderComments(comments) {
17
+ if (comments.length === 0)
18
+ return 'Comments: none';
19
+ const shown = comments.slice(-MAX_INLINE_COMMENTS);
20
+ const header = comments.length > shown.length
21
+ ? `Comments (${comments.length} total, showing the most recent ${shown.length}):`
22
+ : `Comments (${comments.length}):`;
23
+ return [header, ...shown.map((c) => ` ${c.authorName} — ${c.createdAt}\n ${c.text.replace(/\n/g, '\n ')}`)].join('\n');
24
+ }
25
+ /**
26
+ * Member ids are opaque (`auth-1783682644299`), so a bare id answers nothing.
27
+ * An id the workspace cannot name is still printed rather than dropped — a
28
+ * stale member list must degrade to today's output, not hide the assignment.
29
+ */
30
+ function renderAssignees(ids, ws, me) {
31
+ return ids
32
+ .map((id) => {
33
+ const known = ws?.members.find((m) => m.id === id)?.name;
34
+ // We always know our own name, even if the member fetch failed at startup.
35
+ const label = known ?? (me && id === me.id ? me.name : id);
36
+ return me && id === me.id ? `${label} (you)` : label;
37
+ })
38
+ .join(', ');
39
+ }
40
+ function renderAttachments(files) {
41
+ if (files.length === 0)
42
+ return 'Attachments: none';
43
+ return [`Attachments (${files.length}):`, ...files.map((f) => {
44
+ const size = humanSize(f.size);
45
+ return ` ${f.name} (${f.mimeType}${size ? `, ${size}` : ''})`;
46
+ }), 'Use get_attachment to pull one into the conversation.'].join('\n');
47
+ }
48
+ function ok(text) { return { content: [{ type: 'text', text }] }; }
49
+ function err(text) { return { content: [{ type: 'text', text }], isError: true }; }
50
+ function friendly(e) {
51
+ if (e instanceof ApiError) {
52
+ switch (e.code) {
53
+ case 'auth': return 'Your API token is invalid or revoked — generate a new one in Settings › Developer.';
54
+ case 'forbidden': return 'Your role does not allow that action.';
55
+ case 'not_found': return 'Task not found.';
56
+ case 'network': return 'Could not reach the SurecoreStudio API.';
57
+ default: return 'The API returned an error. Please try again.';
58
+ }
59
+ }
60
+ return `Unexpected error: ${e?.message ?? String(e)}`;
61
+ }
62
+ function line(t) {
63
+ const tags = t.tags?.length ? ` {${t.tags.join(', ')}}` : '';
64
+ return `#${t.taskNumber ?? '?'} [${t.status}]${tags} ${t.title} (id: ${t.id})${t.projectId ? ` (project ${t.projectId})` : ''}${t.dueDate ? ` — due ${t.dueDate}` : ''}`;
65
+ }
66
+ /** Resolve a task reference that may be a taskNumber (digits) or an appId. */
67
+ async function resolveId(api, ref) {
68
+ if (/^\d+$/.test(ref)) {
69
+ const n = Number(ref);
70
+ const match = (await api.listTasks()).find((t) => t.taskNumber === n);
71
+ if (!match)
72
+ throw new ApiError('not_found', `No task numbered ${ref}.`);
73
+ return match.id;
74
+ }
75
+ return ref;
76
+ }
77
+ export async function listMyTasks(api, me, args) {
78
+ try {
79
+ let tasks = (await api.listTasks()).filter((t) => t.assignees.includes(me.id));
80
+ if (args.status)
81
+ tasks = tasks.filter((t) => t.status === args.status);
82
+ if (args.projectId)
83
+ tasks = tasks.filter((t) => t.projectId === args.projectId);
84
+ if (args.tag)
85
+ tasks = tasks.filter((t) => (t.tags ?? []).includes(args.tag));
86
+ if (tasks.length === 0)
87
+ return ok('You have no matching tasks.');
88
+ return ok(tasks.map(line).join('\n'));
89
+ }
90
+ catch (e) {
91
+ return err(friendly(e));
92
+ }
93
+ }
94
+ export async function getTaskTool(api, args, ws, me) {
95
+ try {
96
+ const id = await resolveId(api, args.task);
97
+ const t = await api.getTask(id);
98
+ // The thread and the file list are part of reading a task, not a separate
99
+ // errand — but neither may break the read, so both degrade to a note.
100
+ const [comments, files] = await Promise.all([
101
+ api.listComments(id).catch(() => null),
102
+ api.listAttachments(id).catch(() => null),
103
+ ]);
104
+ // Head fields drop out when absent; the sections below are always present
105
+ // and separated by blank lines, so filter(Boolean) must not touch them.
106
+ const head = [
107
+ `#${t.taskNumber ?? '?'} — ${t.title}`,
108
+ `Status: ${t.status} Priority: ${t.priority}`,
109
+ t.projectId ? `Project: ${t.projectId}` : '',
110
+ t.dueDate ? `Due: ${t.dueDate}` : '',
111
+ t.assignees.length ? `Assignees: ${renderAssignees(t.assignees, ws, me)}` : 'Unassigned',
112
+ t.tags?.length ? `Tags: ${t.tags.join(', ')}` : '',
113
+ ].filter(Boolean).join('\n');
114
+ const body = [
115
+ head,
116
+ t.description || '(no description)',
117
+ comments === null ? 'Comments: could not be loaded.' : renderComments(comments),
118
+ files === null ? 'Attachments: could not be loaded.' : renderAttachments(files),
119
+ ].join('\n\n');
120
+ return ok(body);
121
+ }
122
+ catch (e) {
123
+ return err(friendly(e));
124
+ }
125
+ }
126
+ export async function searchTasks(api, args, ws) {
127
+ try {
128
+ let tasks = await api.listTasks();
129
+ if (args.status)
130
+ tasks = tasks.filter((t) => t.status === args.status);
131
+ if (args.projectId)
132
+ tasks = tasks.filter((t) => t.projectId === args.projectId);
133
+ if (args.tag)
134
+ tasks = tasks.filter((t) => (t.tags ?? []).includes(args.tag));
135
+ if (args.text) {
136
+ const q = args.text.toLowerCase();
137
+ tasks = tasks.filter((t) => t.title.toLowerCase().includes(q) || t.description.toLowerCase().includes(q));
138
+ }
139
+ if (tasks.length === 0)
140
+ return ok('No tasks match.');
141
+ return ok(tasks.slice(0, 50).map(line).join('\n'));
142
+ }
143
+ catch (e) {
144
+ return err(friendly(e));
145
+ }
146
+ }
147
+ export const CREATE_TASK_TYPES = ['task', 'issue'];
148
+ /**
149
+ * Resolve a `project` reference the user gave — either an exact project id
150
+ * (appId) or a project name (case-insensitive). Returns the resolved id, or a
151
+ * helpful message listing the available projects when it can't match
152
+ * unambiguously. Never guesses between two same-named projects. The message is
153
+ * returned (not thrown) so it reaches the user verbatim rather than being
154
+ * flattened to a generic "not found" by friendly().
155
+ */
156
+ async function resolveProjectId(api, ref) {
157
+ const projects = await api.listProjects();
158
+ const wanted = ref.trim();
159
+ const byId = projects.find((p) => p.id === wanted);
160
+ if (byId)
161
+ return { id: byId.id };
162
+ const byName = projects.filter((p) => p.name.toLowerCase() === wanted.toLowerCase());
163
+ if (byName.length === 1)
164
+ return { id: byName[0].id };
165
+ const names = projects.map((p) => p.name).join(', ') || '(none)';
166
+ if (byName.length > 1) {
167
+ return { message: `More than one project is named "${ref}". Use the project id instead. Projects: ${names}.` };
168
+ }
169
+ return { message: `No project matches "${ref}". Available projects: ${names}.` };
170
+ }
171
+ export async function createTaskTool(api, args) {
172
+ const title = (args.title ?? '').trim();
173
+ if (!title)
174
+ return err('A task title is required.');
175
+ const type = (args.type ?? 'task').toLowerCase();
176
+ if (!CREATE_TASK_TYPES.includes(type)) {
177
+ return err(`type must be one of: ${CREATE_TASK_TYPES.join(', ')} (tickets are created from support intake, not here).`);
178
+ }
179
+ try {
180
+ const resolved = await resolveProjectId(api, args.project);
181
+ if ('message' in resolved)
182
+ return err(resolved.message);
183
+ const created = await api.createTask({
184
+ title,
185
+ taskType: type,
186
+ projectId: resolved.id,
187
+ description: args.description?.trim() || undefined,
188
+ priority: args.priority,
189
+ status: 'todo',
190
+ // Guards against an accidental duplicate if the tool is retried.
191
+ idempotencyKey: randomUUID(),
192
+ });
193
+ return ok(`Created ${type} #${created.taskNumber ?? '?'} "${created.title}" in project ${created.projectId} (id: ${created.id}), status ${created.status}.`);
194
+ }
195
+ catch (e) {
196
+ return err(friendly(e));
197
+ }
198
+ }
199
+ export async function updateTaskStatus(api, args, ws) {
200
+ if (!statusValues(ws).includes(args.status)) {
201
+ return err(`"${args.status}" is not a status in this workspace. Valid statuses: ${statusValues(ws).join(', ')}.`);
202
+ }
203
+ try {
204
+ const id = await resolveId(api, args.task);
205
+ const t = await api.moveTask(id, args.status);
206
+ return ok(`Moved #${t.taskNumber ?? id} to "${t.status}".`);
207
+ }
208
+ catch (e) {
209
+ return err(friendly(e));
210
+ }
211
+ }
212
+ /** Resolve assignee refs that may be member ids or human names. */
213
+ function resolveMembers(ws, refs) {
214
+ const ids = [];
215
+ for (const raw of refs) {
216
+ const ref = raw.trim();
217
+ if (!ref)
218
+ continue;
219
+ const byId = ws.members.find((m) => m.id === ref);
220
+ if (byId) {
221
+ ids.push(byId.id);
222
+ continue;
223
+ }
224
+ const byName = ws.members.filter((m) => m.name.toLowerCase() === ref.toLowerCase()
225
+ || m.name.toLowerCase().split(/\s+/)[0] === ref.toLowerCase());
226
+ if (byName.length === 1) {
227
+ ids.push(byName[0].id);
228
+ continue;
229
+ }
230
+ if (byName.length > 1) {
231
+ return { message: `More than one member matches "${ref}": ${byName.map((m) => m.name).join(', ')}. Use the member id.` };
232
+ }
233
+ return { message: `No member matches "${ref}". Members: ${ws.members.map((m) => m.name).join(', ') || '(none)'}.` };
234
+ }
235
+ return { ids };
236
+ }
237
+ export async function updateTask(api, args, ws) {
238
+ const patch = {};
239
+ if (args.title !== undefined) {
240
+ if (!args.title.trim())
241
+ return err('A task title cannot be empty.');
242
+ patch.title = args.title.trim();
243
+ }
244
+ if (args.description !== undefined)
245
+ patch.description = args.description;
246
+ if (args.priority !== undefined)
247
+ patch.priority = args.priority;
248
+ if (args.dueDate !== undefined)
249
+ patch.dueDate = args.dueDate;
250
+ if (args.status !== undefined) {
251
+ if (!statusValues(ws).includes(args.status)) {
252
+ return err(`"${args.status}" is not a status in this workspace. Valid statuses: ${statusValues(ws).join(', ')}.`);
253
+ }
254
+ patch.status = args.status;
255
+ }
256
+ if (args.tags !== undefined) {
257
+ const unknown = args.tags.filter((t) => !tagValues(ws).includes(t));
258
+ if (unknown.length) {
259
+ return err(`Unknown tag(s): ${unknown.join(', ')}. Tags in this workspace: ${tagValues(ws).join(', ') || '(none)'}.`);
260
+ }
261
+ patch.tags = args.tags;
262
+ }
263
+ if (args.assignees !== undefined) {
264
+ const resolved = resolveMembers(ws, args.assignees);
265
+ if ('message' in resolved)
266
+ return err(resolved.message);
267
+ patch.assignees = resolved.ids;
268
+ }
269
+ if (Object.keys(patch).length === 0) {
270
+ return err('Nothing to update — pass at least one of title, description, priority, status, dueDate, tags or assignees.');
271
+ }
272
+ try {
273
+ const id = await resolveId(api, args.task);
274
+ const t = await api.updateTask(id, patch);
275
+ return ok(`Updated #${t.taskNumber ?? id}: ${Object.keys(patch).join(', ')}.`);
276
+ }
277
+ catch (e) {
278
+ return err(friendly(e));
279
+ }
280
+ }
281
+ export async function listComments(api, args) {
282
+ try {
283
+ const id = await resolveId(api, args.task);
284
+ const comments = await api.listComments(id);
285
+ if (comments.length === 0)
286
+ return ok('This task has no comments yet.');
287
+ return ok(comments
288
+ .map((c) => `${c.authorName} — ${c.createdAt}\n${c.text}`)
289
+ .join('\n\n'));
290
+ }
291
+ catch (e) {
292
+ return err(friendly(e));
293
+ }
294
+ }
295
+ export async function addComment(api, me, args) {
296
+ const text = (args.text ?? '').trim();
297
+ if (!text)
298
+ return err('A comment cannot be empty.');
299
+ try {
300
+ const id = await resolveId(api, args.task);
301
+ // The API requires the author explicitly; it is always the PAT's own user,
302
+ // so a comment can never be attributed to someone else.
303
+ await api.addComment({ taskId: id, authorId: me.id, authorName: me.name, text });
304
+ return ok(`Comment added to #${args.task} as ${me.name}.`);
305
+ }
306
+ catch (e) {
307
+ return err(friendly(e));
308
+ }
309
+ }
310
+ /**
311
+ * Pull one attached file into the conversation. Images come back as an image
312
+ * block so they are actually visible; anything else is reported by name and
313
+ * type rather than dumped as base64 the model cannot read.
314
+ */
315
+ export async function getAttachment(api, args) {
316
+ try {
317
+ const id = await resolveId(api, args.task);
318
+ const files = await api.listAttachments(id);
319
+ const wanted = args.file.trim().toLowerCase();
320
+ const match = files.find((f) => f.id === args.file.trim())
321
+ ?? files.find((f) => f.name.toLowerCase() === wanted)
322
+ ?? (files.filter((f) => f.name.toLowerCase().includes(wanted)).length === 1
323
+ ? files.find((f) => f.name.toLowerCase().includes(wanted))
324
+ : undefined);
325
+ if (!match) {
326
+ const names = files.map((f) => f.name).join(', ') || '(none)';
327
+ return err(`No attachment matches "${args.file}" on this task. Attached files: ${names}.`);
328
+ }
329
+ const dataUrl = await api.getAttachmentDataUrl(match.id);
330
+ if (!dataUrl)
331
+ return err(`"${match.name}" has no stored file to read.`);
332
+ const comma = dataUrl.indexOf(',');
333
+ const base64 = comma === -1 ? '' : dataUrl.slice(comma + 1);
334
+ if (match.mimeType.startsWith('image/') && base64) {
335
+ return { content: [{ type: 'image', data: base64, mimeType: match.mimeType }] };
336
+ }
337
+ return ok(`${match.name} — ${match.mimeType}${match.size ? `, ${humanSize(match.size)}` : ''}. `
338
+ + 'This file type cannot be shown inline; open it in SurecoreStudio to view it.');
339
+ }
340
+ catch (e) {
341
+ return err(friendly(e));
342
+ }
343
+ }
344
+ /** The member directory, so an id seen on a task can be turned into a person. */
345
+ export async function listMembersTool(ws) {
346
+ if (ws.members.length === 0)
347
+ return ok('This workspace exposes no members.');
348
+ return ok(ws.members.map((m) => `${m.name} (id: ${m.id})`).join('\n'));
349
+ }
350
+ /**
351
+ * MIME type for an upload, by extension.
352
+ *
353
+ * `.html` is deliberately sent as text/plain: the server's allow-list has no
354
+ * text/html, and widening it would also let widget visitors upload HTML, which
355
+ * is a stored-XSS shape nobody needs. text/plain keeps the bytes and the .html
356
+ * filename intact while forcing a download rather than an inline render.
357
+ */
358
+ function uploadMimeFor(name) {
359
+ const ext = name.toLowerCase().split('.').pop() ?? '';
360
+ const map = {
361
+ html: 'text/plain', htm: 'text/plain', md: 'text/plain', txt: 'text/plain', log: 'text/plain',
362
+ json: 'application/json', pdf: 'application/pdf', csv: 'text/csv', zip: 'application/zip',
363
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
364
+ };
365
+ return map[ext] ?? 'text/plain';
366
+ }
367
+ export async function addAttachment(api, args) {
368
+ const { readFile } = await import('node:fs/promises');
369
+ const { basename } = await import('node:path');
370
+ let bytes;
371
+ try {
372
+ bytes = new Uint8Array(await readFile(args.path));
373
+ }
374
+ catch {
375
+ return err(`Could not read "${args.path}". Give the full path to a file that exists on this machine.`);
376
+ }
377
+ if (bytes.length === 0)
378
+ return err(`"${args.path}" is empty.`);
379
+ const name = basename(args.path);
380
+ try {
381
+ const id = await resolveId(api, args.task);
382
+ const saved = await api.uploadAttachment(id, { name, mimeType: uploadMimeFor(name), bytes });
383
+ const label = saved[0]?.name ?? name;
384
+ return ok(`Attached "${label}" (${humanSize(bytes.length)}) to #${args.task}.`);
385
+ }
386
+ catch (e) {
387
+ return err(friendly(e));
388
+ }
389
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "surecore-studio-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "MCP server for SurecoreStudio: read and update tasks, comments and attachments from Claude.",
5
+ "license": "MIT",
6
+ "author": "Surecore",
7
+ "keywords": [
8
+ "mcp",
9
+ "modelcontextprotocol",
10
+ "surecorestudio",
11
+ "tasks",
12
+ "claude"
13
+ ],
14
+ "homepage": "https://surecorestudio.surecore.cc",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://bitbucket.org/surecore-dev/surecore-studio.git",
18
+ "directory": "mcp/surecore-tasks"
19
+ },
20
+ "type": "module",
21
+ "bin": {
22
+ "surecore-tasks-mcp": "dist/index.js",
23
+ "surecore-studio-mcp": "dist/index.js"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.json",
33
+ "start": "node dist/index.js",
34
+ "dev": "node --import tsx src/index.ts",
35
+ "test": "node --import tsx --test src/apiClient.test.ts src/tools.test.ts",
36
+ "prepublishOnly": "npm run build"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.4.0",
40
+ "zod": "^3.23.8"
41
+ },
42
+ "devDependencies": {
43
+ "tsx": "^4.16.0",
44
+ "typescript": "^5.5.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }