@mauvable/flowboards-mcp 1.0.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/README.md +78 -0
- package/dist/client.d.ts +6 -0
- package/dist/client.js +107 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +21 -0
- package/dist/resolver.d.ts +5 -0
- package/dist/resolver.js +75 -0
- package/dist/tools/bins.d.ts +2 -0
- package/dist/tools/bins.js +33 -0
- package/dist/tools/boards.d.ts +2 -0
- package/dist/tools/boards.js +14 -0
- package/dist/tools/comments.d.ts +2 -0
- package/dist/tools/comments.js +38 -0
- package/dist/tools/flows.d.ts +2 -0
- package/dist/tools/flows.js +24 -0
- package/dist/tools/misc.d.ts +2 -0
- package/dist/tools/misc.js +68 -0
- package/dist/tools/tickets.d.ts +2 -0
- package/dist/tools/tickets.js +101 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @mauvable/flowboards-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [Flow Boards](https://fb.mauvable.com) — exposes the full Flow Boards REST API as Claude tools.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Claude / MCP-compatible AI tool config:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"flowboards": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "@mauvable/flowboards-mcp"],
|
|
15
|
+
"env": {
|
|
16
|
+
"FLOW_BOARDS_URL": "https://<host>/rest/2/<org-id>",
|
|
17
|
+
"FLOW_BOARDS_TOKEN": "<your-bearer-token>"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
| Variable | Description |
|
|
27
|
+
|----------|-------------|
|
|
28
|
+
| `FLOW_BOARDS_URL` | Full API base URL including org path, e.g. `https://n1.fb.mauvable.com/rest/2/<org-id>` |
|
|
29
|
+
| `FLOW_BOARDS_TOKEN` | Bearer token for authentication |
|
|
30
|
+
|
|
31
|
+
## Available Tools
|
|
32
|
+
|
|
33
|
+
### Tickets
|
|
34
|
+
- `create_ticket` — Create a ticket in a flow (accepts flow name, resolves IDs automatically)
|
|
35
|
+
- `get_ticket` — Get ticket details
|
|
36
|
+
- `list_tickets` — List tickets by bin, parent, or child
|
|
37
|
+
- `update_ticket` — Update ticket fields
|
|
38
|
+
- `move_ticket` — Move ticket to a different bin
|
|
39
|
+
- `delete_ticket` / `delete_tickets` — Delete one or many tickets
|
|
40
|
+
- `archive_tickets` / `restore_tickets` — Archive or restore tickets
|
|
41
|
+
- `link_to_parent` — Link stories to an epic
|
|
42
|
+
- `unlink_from_parent` — Remove parent relationship
|
|
43
|
+
- `list_ticket_moves` — Movement history for a ticket
|
|
44
|
+
|
|
45
|
+
### Flows
|
|
46
|
+
- `list_flows` — List all flows
|
|
47
|
+
- `get_flow` — Get flow details
|
|
48
|
+
- `list_flow_tickets` — List tickets in a flow
|
|
49
|
+
- `list_flow_moves` — Movement history for a flow
|
|
50
|
+
|
|
51
|
+
### Bins
|
|
52
|
+
- `list_bins` / `get_bin` — List or get bins
|
|
53
|
+
- `create_bin` / `update_bin` — Create or update bins
|
|
54
|
+
|
|
55
|
+
### Boards
|
|
56
|
+
- `list_boards` / `get_board`
|
|
57
|
+
|
|
58
|
+
### Comments
|
|
59
|
+
- `add_comment` — Add a comment to a ticket
|
|
60
|
+
- `list_comments` / `get_comment` — Read comments
|
|
61
|
+
- `update_comment` / `delete_comment`
|
|
62
|
+
|
|
63
|
+
### Other
|
|
64
|
+
- `list_ticket_types` — Available ticket types (User Story, Epic, etc.)
|
|
65
|
+
- `list_custom_fields`
|
|
66
|
+
- `list_users` / `get_user` / `invite_user`
|
|
67
|
+
- `list_user_groups`
|
|
68
|
+
- `search_tickets`
|
|
69
|
+
- `list_webhooks` / `create_webhook` / `delete_webhook` / `test_webhook`
|
|
70
|
+
|
|
71
|
+
## Example Usage
|
|
72
|
+
|
|
73
|
+
Once configured, just talk to Claude naturally:
|
|
74
|
+
|
|
75
|
+
> "Create 3 user stories in the Backlog flow"
|
|
76
|
+
> "Move all stories in 'In Progress' to 'Done' in the Dev flow"
|
|
77
|
+
> "Add a comment 'Ready for review' to ticket abc123"
|
|
78
|
+
> "Link stories X, Y, Z to epic E"
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export declare function apiGet<T>(path: string): Promise<T>;
|
|
3
|
+
export declare function apiPost(path: string, body?: unknown): Promise<void>;
|
|
4
|
+
export declare function apiPut(path: string, body?: unknown): Promise<void>;
|
|
5
|
+
export declare function apiDelete(path: string): Promise<void>;
|
|
6
|
+
export declare function generateId(): string;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const BASE_URL = process.env.FLOW_BOARDS_URL?.replace(/\/$/, '');
|
|
3
|
+
const TOKEN = process.env.FLOW_BOARDS_TOKEN;
|
|
4
|
+
if (!BASE_URL) {
|
|
5
|
+
process.stderr.write([
|
|
6
|
+
'',
|
|
7
|
+
' flowboards-mcp: missing required configuration',
|
|
8
|
+
'',
|
|
9
|
+
' FLOW_BOARDS_URL is not set.',
|
|
10
|
+
'',
|
|
11
|
+
' Add the following to your MCP server config (e.g. ~/.claude/settings.json):',
|
|
12
|
+
'',
|
|
13
|
+
' {',
|
|
14
|
+
' "mcpServers": {',
|
|
15
|
+
' "flowboards": {',
|
|
16
|
+
' "command": "npx",',
|
|
17
|
+
' "args": ["-y", "flowboards-mcp"],',
|
|
18
|
+
' "env": {',
|
|
19
|
+
' "FLOW_BOARDS_URL": "https://app.getflowboard.com",',
|
|
20
|
+
' "FLOW_BOARDS_TOKEN": "<your-api-token>"',
|
|
21
|
+
' }',
|
|
22
|
+
' }',
|
|
23
|
+
' }',
|
|
24
|
+
' }',
|
|
25
|
+
'',
|
|
26
|
+
' Get your API token at: https://app.getflowboard.com/settings/api',
|
|
27
|
+
'',
|
|
28
|
+
].join('\n'));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
if (!TOKEN) {
|
|
32
|
+
process.stderr.write([
|
|
33
|
+
'',
|
|
34
|
+
' flowboards-mcp: missing required configuration',
|
|
35
|
+
'',
|
|
36
|
+
' FLOW_BOARDS_TOKEN is not set.',
|
|
37
|
+
'',
|
|
38
|
+
' Add the following to your MCP server config (e.g. ~/.claude/settings.json):',
|
|
39
|
+
'',
|
|
40
|
+
' {',
|
|
41
|
+
' "mcpServers": {',
|
|
42
|
+
' "flowboards": {',
|
|
43
|
+
' "command": "npx",',
|
|
44
|
+
' "args": ["-y", "flowboards-mcp"],',
|
|
45
|
+
' "env": {',
|
|
46
|
+
' "FLOW_BOARDS_URL": "https://app.getflowboard.com",',
|
|
47
|
+
' "FLOW_BOARDS_TOKEN": "<your-api-token>"',
|
|
48
|
+
' }',
|
|
49
|
+
' }',
|
|
50
|
+
' }',
|
|
51
|
+
' }',
|
|
52
|
+
'',
|
|
53
|
+
' Get your API token at: https://app.getflowboard.com/settings/api',
|
|
54
|
+
'',
|
|
55
|
+
].join('\n'));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const HEADERS = {
|
|
59
|
+
'Authorization': `Bearer ${TOKEN}`,
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
};
|
|
62
|
+
export async function apiGet(path) {
|
|
63
|
+
const res = await fetch(`${BASE_URL}${path}`, { headers: HEADERS });
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
throw new Error(`GET ${path} failed (${res.status}): ${text}`);
|
|
67
|
+
}
|
|
68
|
+
return res.json();
|
|
69
|
+
}
|
|
70
|
+
export async function apiPost(path, body) {
|
|
71
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: HEADERS,
|
|
74
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
75
|
+
});
|
|
76
|
+
if (res.status !== 200 && res.status !== 201 && res.status !== 204) {
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
throw new Error(`POST ${path} failed (${res.status}): ${text}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function apiPut(path, body) {
|
|
82
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
83
|
+
method: 'PUT',
|
|
84
|
+
headers: HEADERS,
|
|
85
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
86
|
+
});
|
|
87
|
+
if (res.status !== 200 && res.status !== 201 && res.status !== 204) {
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
throw new Error(`PUT ${path} failed (${res.status}): ${text}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function apiDelete(path) {
|
|
93
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
94
|
+
method: 'DELETE',
|
|
95
|
+
headers: HEADERS,
|
|
96
|
+
});
|
|
97
|
+
if (res.status !== 200 && res.status !== 201 && res.status !== 204) {
|
|
98
|
+
const text = await res.text();
|
|
99
|
+
throw new Error(`DELETE ${path} failed (${res.status}): ${text}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function generateId() {
|
|
103
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
104
|
+
const bytes = new Uint8Array(17);
|
|
105
|
+
crypto.getRandomValues(bytes);
|
|
106
|
+
return Array.from(bytes, b => chars[b % chars.length]).join('');
|
|
107
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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 { registerTicketTools } from './tools/tickets.js';
|
|
5
|
+
import { registerFlowTools } from './tools/flows.js';
|
|
6
|
+
import { registerBinTools } from './tools/bins.js';
|
|
7
|
+
import { registerBoardTools } from './tools/boards.js';
|
|
8
|
+
import { registerCommentTools } from './tools/comments.js';
|
|
9
|
+
import { registerMiscTools } from './tools/misc.js';
|
|
10
|
+
const server = new McpServer({
|
|
11
|
+
name: 'flowboards',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
});
|
|
14
|
+
registerTicketTools(server);
|
|
15
|
+
registerFlowTools(server);
|
|
16
|
+
registerBinTools(server);
|
|
17
|
+
registerBoardTools(server);
|
|
18
|
+
registerCommentTools(server);
|
|
19
|
+
registerMiscTools(server);
|
|
20
|
+
const transport = new StdioServerTransport();
|
|
21
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function resolveFlowId(nameOrId: string): Promise<string>;
|
|
2
|
+
export declare function resolveFlowFirstBin(nameOrId: string): Promise<string>;
|
|
3
|
+
export declare function resolveBinId(nameOrId: string): Promise<string>;
|
|
4
|
+
export declare function resolveTicketTypeId(nameOrId: string): Promise<string>;
|
|
5
|
+
export declare function resolveBoardId(nameOrId: string): Promise<string>;
|
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves human-readable names to Flow Boards IDs.
|
|
3
|
+
* Caches results for the lifetime of the process.
|
|
4
|
+
*/
|
|
5
|
+
import { apiGet } from './client.js';
|
|
6
|
+
let flowsCache = null;
|
|
7
|
+
let binsCache = null;
|
|
8
|
+
let typesCache = null;
|
|
9
|
+
let boardsCache = null;
|
|
10
|
+
async function getFlows() {
|
|
11
|
+
if (!flowsCache)
|
|
12
|
+
flowsCache = await apiGet('/flows');
|
|
13
|
+
return flowsCache;
|
|
14
|
+
}
|
|
15
|
+
async function getBins() {
|
|
16
|
+
if (!binsCache)
|
|
17
|
+
binsCache = await apiGet('/bins');
|
|
18
|
+
return binsCache;
|
|
19
|
+
}
|
|
20
|
+
async function getTypes() {
|
|
21
|
+
if (!typesCache)
|
|
22
|
+
typesCache = await apiGet('/ticket-types');
|
|
23
|
+
return typesCache;
|
|
24
|
+
}
|
|
25
|
+
async function getBoards() {
|
|
26
|
+
if (!boardsCache)
|
|
27
|
+
boardsCache = await apiGet('/boards');
|
|
28
|
+
return boardsCache;
|
|
29
|
+
}
|
|
30
|
+
export async function resolveFlowId(nameOrId) {
|
|
31
|
+
const flows = await getFlows();
|
|
32
|
+
const flow = flows.find(f => f._id === nameOrId || f.name === nameOrId);
|
|
33
|
+
if (!flow)
|
|
34
|
+
throw new Error(`Flow not found: "${nameOrId}". Available: ${flows.map(f => f.name).join(', ')}`);
|
|
35
|
+
return flow._id;
|
|
36
|
+
}
|
|
37
|
+
export async function resolveFlowFirstBin(nameOrId) {
|
|
38
|
+
const flows = await getFlows();
|
|
39
|
+
const flow = flows.find(f => f._id === nameOrId || f.name === nameOrId);
|
|
40
|
+
if (!flow)
|
|
41
|
+
throw new Error(`Flow not found: "${nameOrId}"`);
|
|
42
|
+
if (!flow.bin_ids?.length)
|
|
43
|
+
throw new Error(`Flow "${nameOrId}" has no bins`);
|
|
44
|
+
return flow.bin_ids[0];
|
|
45
|
+
}
|
|
46
|
+
export async function resolveBinId(nameOrId) {
|
|
47
|
+
const bins = await getBins();
|
|
48
|
+
const bin = bins.find(b => b._id === nameOrId || b.name === nameOrId);
|
|
49
|
+
if (bin)
|
|
50
|
+
return bin._id;
|
|
51
|
+
// Fall back to direct fetch — board-specific bins may not appear in the global list
|
|
52
|
+
try {
|
|
53
|
+
const direct = await apiGet(`/bins/${nameOrId}`);
|
|
54
|
+
if (direct?._id)
|
|
55
|
+
return direct._id;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// ignore, throw the original error below
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Bin not found: "${nameOrId}". Available: ${bins.map(b => b.name).join(', ')}`);
|
|
61
|
+
}
|
|
62
|
+
export async function resolveTicketTypeId(nameOrId) {
|
|
63
|
+
const types = await getTypes();
|
|
64
|
+
const type = types.find(t => t._id === nameOrId || t.name === nameOrId);
|
|
65
|
+
if (!type)
|
|
66
|
+
throw new Error(`Ticket type not found: "${nameOrId}". Available: ${types.map(t => t.name).join(', ')}`);
|
|
67
|
+
return type._id;
|
|
68
|
+
}
|
|
69
|
+
export async function resolveBoardId(nameOrId) {
|
|
70
|
+
const boards = await getBoards();
|
|
71
|
+
const board = boards.find(b => b._id === nameOrId || b.name === nameOrId);
|
|
72
|
+
if (!board)
|
|
73
|
+
throw new Error(`Board not found: "${nameOrId}". Available: ${boards.map(b => b.name).join(', ')}`);
|
|
74
|
+
return board._id;
|
|
75
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiGet, apiPost, apiPut, generateId } from '../client.js';
|
|
3
|
+
import { resolveBinId } from '../resolver.js';
|
|
4
|
+
export function registerBinTools(server) {
|
|
5
|
+
server.tool('list_bins', 'List all bins.', {}, async () => {
|
|
6
|
+
const bins = await apiGet('/bins');
|
|
7
|
+
return { content: [{ type: 'text', text: JSON.stringify(bins, null, 2) }] };
|
|
8
|
+
});
|
|
9
|
+
server.tool('get_bin', 'Get details for a specific bin by name or ID.', { bin: z.string().describe('Bin name or ID') }, async ({ bin }) => {
|
|
10
|
+
const id = await resolveBinId(bin);
|
|
11
|
+
const data = await apiGet(`/bins/${id}`);
|
|
12
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
13
|
+
});
|
|
14
|
+
server.tool('create_bin', 'Create a new bin.', {
|
|
15
|
+
name: z.string().describe('Bin name'),
|
|
16
|
+
flowId: z.string().describe('Flow ID to add the bin to'),
|
|
17
|
+
}, async ({ name, flowId }) => {
|
|
18
|
+
const id = generateId();
|
|
19
|
+
await apiPost(`/bins/${id}`, { _id: id, name, flow_id: flowId });
|
|
20
|
+
return { content: [{ type: 'text', text: `Created bin: ${id} — ${name}` }] };
|
|
21
|
+
});
|
|
22
|
+
server.tool('update_bin', 'Update a bin\'s name or configuration.', {
|
|
23
|
+
bin: z.string().describe('Bin name or ID'),
|
|
24
|
+
name: z.string().optional().describe('New name'),
|
|
25
|
+
}, async ({ bin, name }) => {
|
|
26
|
+
const id = await resolveBinId(bin);
|
|
27
|
+
const partial = {};
|
|
28
|
+
if (name)
|
|
29
|
+
partial.name = { $replace: name };
|
|
30
|
+
await apiPut(`/bins/${id}`, { $partial: partial });
|
|
31
|
+
return { content: [{ type: 'text', text: `Updated bin: ${id}` }] };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiGet } from '../client.js';
|
|
3
|
+
import { resolveBoardId } from '../resolver.js';
|
|
4
|
+
export function registerBoardTools(server) {
|
|
5
|
+
server.tool('list_boards', 'List all boards.', {}, async () => {
|
|
6
|
+
const boards = await apiGet('/boards');
|
|
7
|
+
return { content: [{ type: 'text', text: JSON.stringify(boards, null, 2) }] };
|
|
8
|
+
});
|
|
9
|
+
server.tool('get_board', 'Get details for a specific board by name or ID.', { board: z.string().describe('Board name or ID') }, async ({ board }) => {
|
|
10
|
+
const id = await resolveBoardId(board);
|
|
11
|
+
const data = await apiGet(`/boards/${id}`);
|
|
12
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiGet, apiPost, apiPut, apiDelete, generateId } from '../client.js';
|
|
3
|
+
export function registerCommentTools(server) {
|
|
4
|
+
server.tool('add_comment', 'Add a comment to a ticket.', {
|
|
5
|
+
ticketId: z.string().describe('Ticket ID'),
|
|
6
|
+
comment: z.string().describe('Comment text'),
|
|
7
|
+
format: z.enum(['text', 'html']).optional().default('text').describe('Comment format'),
|
|
8
|
+
}, async ({ ticketId, comment, format }) => {
|
|
9
|
+
const id = generateId();
|
|
10
|
+
await apiPost(`/ticket-comments/${id}`, {
|
|
11
|
+
_id: id,
|
|
12
|
+
ticket_id: ticketId,
|
|
13
|
+
comment,
|
|
14
|
+
rtformat: format,
|
|
15
|
+
});
|
|
16
|
+
return { content: [{ type: 'text', text: `Added comment ${id} to ticket ${ticketId}` }] };
|
|
17
|
+
});
|
|
18
|
+
server.tool('list_comments', 'List all comments for a ticket.', { ticketId: z.string().describe('Ticket ID') }, async ({ ticketId }) => {
|
|
19
|
+
const comments = await apiGet(`/ticket-comments?ticket_id=${ticketId}`);
|
|
20
|
+
return { content: [{ type: 'text', text: JSON.stringify(comments, null, 2) }] };
|
|
21
|
+
});
|
|
22
|
+
server.tool('get_comment', 'Get a specific comment by ID.', { id: z.string().describe('Comment ID') }, async ({ id }) => {
|
|
23
|
+
const comment = await apiGet(`/ticket-comments/${id}`);
|
|
24
|
+
return { content: [{ type: 'text', text: JSON.stringify(comment, null, 2) }] };
|
|
25
|
+
});
|
|
26
|
+
server.tool('update_comment', 'Update a comment\'s text.', {
|
|
27
|
+
id: z.string().describe('Comment ID'),
|
|
28
|
+
comment: z.string().describe('New comment text'),
|
|
29
|
+
format: z.enum(['text', 'html']).optional().default('text'),
|
|
30
|
+
}, async ({ id, comment, format }) => {
|
|
31
|
+
await apiPut(`/ticket-comments/${id}`, { comment, rtformat: format });
|
|
32
|
+
return { content: [{ type: 'text', text: `Updated comment: ${id}` }] };
|
|
33
|
+
});
|
|
34
|
+
server.tool('delete_comment', 'Delete a comment by ID.', { id: z.string().describe('Comment ID') }, async ({ id }) => {
|
|
35
|
+
await apiDelete(`/ticket-comments/${id}`);
|
|
36
|
+
return { content: [{ type: 'text', text: `Deleted comment: ${id}` }] };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiGet } from '../client.js';
|
|
3
|
+
import { resolveFlowId } from '../resolver.js';
|
|
4
|
+
export function registerFlowTools(server) {
|
|
5
|
+
server.tool('list_flows', 'List all flows with their bins and metadata.', {}, async () => {
|
|
6
|
+
const flows = await apiGet('/flows');
|
|
7
|
+
return { content: [{ type: 'text', text: JSON.stringify(flows, null, 2) }] };
|
|
8
|
+
});
|
|
9
|
+
server.tool('get_flow', 'Get details for a specific flow by name or ID.', { flow: z.string().describe('Flow name or ID') }, async ({ flow }) => {
|
|
10
|
+
const id = await resolveFlowId(flow);
|
|
11
|
+
const data = await apiGet(`/flows/${id}`);
|
|
12
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
13
|
+
});
|
|
14
|
+
server.tool('list_flow_tickets', 'List all tickets in a flow.', { flow: z.string().describe('Flow name or ID') }, async ({ flow }) => {
|
|
15
|
+
const id = await resolveFlowId(flow);
|
|
16
|
+
const tickets = await apiGet(`/flows/${id}/tickets`);
|
|
17
|
+
return { content: [{ type: 'text', text: JSON.stringify(tickets, null, 2) }] };
|
|
18
|
+
});
|
|
19
|
+
server.tool('list_flow_moves', 'List ticket movement history within a flow.', { flow: z.string().describe('Flow name or ID') }, async ({ flow }) => {
|
|
20
|
+
const id = await resolveFlowId(flow);
|
|
21
|
+
const moves = await apiGet(`/flows/${id}/moves`);
|
|
22
|
+
return { content: [{ type: 'text', text: JSON.stringify(moves, null, 2) }] };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiGet, apiPost, apiDelete, generateId } from '../client.js';
|
|
3
|
+
export function registerMiscTools(server) {
|
|
4
|
+
// Ticket Types
|
|
5
|
+
server.tool('list_ticket_types', 'List all ticket types (e.g. User Story, Epic, Bug).', {}, async () => {
|
|
6
|
+
const data = await apiGet('/ticket-types');
|
|
7
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
8
|
+
});
|
|
9
|
+
// Custom Fields
|
|
10
|
+
server.tool('list_custom_fields', 'List all custom fields.', {}, async () => {
|
|
11
|
+
const data = await apiGet('/custom-fields');
|
|
12
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
13
|
+
});
|
|
14
|
+
// Users
|
|
15
|
+
server.tool('list_users', 'List all users in the organisation.', {}, async () => {
|
|
16
|
+
const data = await apiGet('/users');
|
|
17
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
18
|
+
});
|
|
19
|
+
server.tool('get_user', 'Get a user by ID or email.', {
|
|
20
|
+
idOrEmail: z.string().describe('User ID or email address'),
|
|
21
|
+
}, async ({ idOrEmail }) => {
|
|
22
|
+
const data = await apiGet(`/users/${encodeURIComponent(idOrEmail)}`);
|
|
23
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
24
|
+
});
|
|
25
|
+
server.tool('invite_user', 'Send an invitation to a new user.', {
|
|
26
|
+
email: z.string().email().describe('Email address to invite'),
|
|
27
|
+
}, async ({ email }) => {
|
|
28
|
+
await apiPost('/invite-user', { email });
|
|
29
|
+
return { content: [{ type: 'text', text: `Invitation sent to ${email}` }] };
|
|
30
|
+
});
|
|
31
|
+
// User Groups
|
|
32
|
+
server.tool('list_user_groups', 'List all user groups.', {}, async () => {
|
|
33
|
+
const data = await apiGet('/user-groups');
|
|
34
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
35
|
+
});
|
|
36
|
+
// Search
|
|
37
|
+
server.tool('search_tickets', 'Search tickets by text or criteria.', {
|
|
38
|
+
query: z.string().describe('Search query'),
|
|
39
|
+
}, async ({ query }) => {
|
|
40
|
+
const data = await apiGet(`/ticket-search?q=${encodeURIComponent(query)}`);
|
|
41
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
42
|
+
});
|
|
43
|
+
// Webhooks
|
|
44
|
+
server.tool('list_webhooks', 'List all webhooks.', {}, async () => {
|
|
45
|
+
const data = await apiGet('/webhooks');
|
|
46
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
47
|
+
});
|
|
48
|
+
server.tool('create_webhook', 'Create a new webhook.', {
|
|
49
|
+
url: z.string().url().describe('Webhook destination URL'),
|
|
50
|
+
events: z.array(z.string()).optional().describe('Event types to subscribe to'),
|
|
51
|
+
}, async ({ url, events }) => {
|
|
52
|
+
const id = generateId();
|
|
53
|
+
await apiPost(`/webhooks/${id}`, { _id: id, url, events });
|
|
54
|
+
return { content: [{ type: 'text', text: `Created webhook: ${id}` }] };
|
|
55
|
+
});
|
|
56
|
+
server.tool('delete_webhook', 'Delete a webhook by ID.', {
|
|
57
|
+
id: z.string().describe('Webhook ID'),
|
|
58
|
+
}, async ({ id }) => {
|
|
59
|
+
await apiDelete(`/webhooks/${id}`);
|
|
60
|
+
return { content: [{ type: 'text', text: `Deleted webhook: ${id}` }] };
|
|
61
|
+
});
|
|
62
|
+
server.tool('test_webhook', 'Send a test payload to a webhook.', {
|
|
63
|
+
id: z.string().describe('Webhook ID'),
|
|
64
|
+
}, async ({ id }) => {
|
|
65
|
+
const data = await apiGet(`/webhooks/${id}/test`);
|
|
66
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiGet, apiPost, apiPut, apiDelete, generateId } from '../client.js';
|
|
3
|
+
import { resolveFlowFirstBin, resolveTicketTypeId, resolveBinId } from '../resolver.js';
|
|
4
|
+
export function registerTicketTools(server) {
|
|
5
|
+
server.tool('create_ticket', 'Create a new ticket in a flow. Accepts flow name and ticket type name — resolves IDs automatically.', {
|
|
6
|
+
name: z.string().describe('Ticket name'),
|
|
7
|
+
flow: z.string().describe('Flow name or ID to place the ticket in (uses first/backlog bin)'),
|
|
8
|
+
ticketType: z.string().describe('Ticket type name or ID (e.g. "User Story", "Epic", "Bug")'),
|
|
9
|
+
bin: z.string().optional().describe('Specific bin name or ID (overrides flow default)'),
|
|
10
|
+
parentId: z.string().optional().describe('Parent ticket ID to nest this ticket inside (mutually exclusive with bin/flow)'),
|
|
11
|
+
}, async ({ name, flow, ticketType, bin, parentId }) => {
|
|
12
|
+
const id = generateId();
|
|
13
|
+
const typeId = await resolveTicketTypeId(ticketType);
|
|
14
|
+
const body = { _id: id, name, ticketType_id: typeId };
|
|
15
|
+
if (parentId) {
|
|
16
|
+
body.enclosed_id = parentId;
|
|
17
|
+
}
|
|
18
|
+
else if (bin) {
|
|
19
|
+
body.bin_id = await resolveBinId(bin);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
body.bin_id = await resolveFlowFirstBin(flow);
|
|
23
|
+
}
|
|
24
|
+
await apiPost(`/tickets/${id}`, body);
|
|
25
|
+
return { content: [{ type: 'text', text: `Created ticket: ${id} — ${name}` }] };
|
|
26
|
+
});
|
|
27
|
+
server.tool('get_ticket', 'Get details for a specific ticket by ID.', { id: z.string().describe('Ticket ID') }, async ({ id }) => {
|
|
28
|
+
const ticket = await apiGet(`/tickets/${id}`);
|
|
29
|
+
return { content: [{ type: 'text', text: JSON.stringify(ticket, null, 2) }] };
|
|
30
|
+
});
|
|
31
|
+
server.tool('list_tickets', 'List tickets filtered by bin, parent, or child.', {
|
|
32
|
+
binId: z.string().optional().describe('Filter by bin ID'),
|
|
33
|
+
parentId: z.string().optional().describe('Filter by parent ticket ID (list children)'),
|
|
34
|
+
childId: z.string().optional().describe('Filter by child ticket ID (find parent)'),
|
|
35
|
+
}, async ({ binId, parentId, childId }) => {
|
|
36
|
+
const params = new URLSearchParams();
|
|
37
|
+
if (binId)
|
|
38
|
+
params.set('bin_id', binId);
|
|
39
|
+
if (parentId)
|
|
40
|
+
params.set('parent_id', parentId);
|
|
41
|
+
if (childId)
|
|
42
|
+
params.set('child_id', childId);
|
|
43
|
+
const tickets = await apiGet(`/tickets?${params}`);
|
|
44
|
+
return { content: [{ type: 'text', text: JSON.stringify(tickets, null, 2) }] };
|
|
45
|
+
});
|
|
46
|
+
server.tool('update_ticket', 'Update ticket fields (name, description, bin, etc.).', {
|
|
47
|
+
id: z.string().describe('Ticket ID'),
|
|
48
|
+
name: z.string().optional(),
|
|
49
|
+
description: z.string().optional(),
|
|
50
|
+
binName: z.string().optional().describe('Move to bin by name or ID'),
|
|
51
|
+
}, async ({ id, name, description, binName }) => {
|
|
52
|
+
const partial = {};
|
|
53
|
+
if (name)
|
|
54
|
+
partial.name = { $replace: name };
|
|
55
|
+
if (description)
|
|
56
|
+
partial.description = { $replace: description };
|
|
57
|
+
if (binName)
|
|
58
|
+
partial.bin_id = { $replace: await resolveBinId(binName) };
|
|
59
|
+
await apiPut(`/tickets/${id}`, { $partial: partial });
|
|
60
|
+
return { content: [{ type: 'text', text: `Updated ticket: ${id}` }] };
|
|
61
|
+
});
|
|
62
|
+
server.tool('move_ticket', 'Move a ticket to a different bin (workflow stage).', {
|
|
63
|
+
id: z.string().describe('Ticket ID'),
|
|
64
|
+
bin: z.string().describe('Target bin name or ID'),
|
|
65
|
+
}, async ({ id, bin }) => {
|
|
66
|
+
const binId = await resolveBinId(bin);
|
|
67
|
+
await apiPut(`/tickets/${id}`, { $partial: { bin_id: { $replace: binId } } });
|
|
68
|
+
return { content: [{ type: 'text', text: `Moved ticket ${id} to bin "${bin}"` }] };
|
|
69
|
+
});
|
|
70
|
+
server.tool('delete_ticket', 'Delete a ticket by ID.', { id: z.string().describe('Ticket ID') }, async ({ id }) => {
|
|
71
|
+
await apiDelete(`/tickets/${id}`);
|
|
72
|
+
return { content: [{ type: 'text', text: `Deleted ticket: ${id}` }] };
|
|
73
|
+
});
|
|
74
|
+
server.tool('delete_tickets', 'Delete multiple tickets by ID.', { ids: z.array(z.string()).describe('List of ticket IDs to delete') }, async ({ ids }) => {
|
|
75
|
+
await apiDelete(`/tickets?ids=${ids.join(',')}`);
|
|
76
|
+
return { content: [{ type: 'text', text: `Deleted tickets: ${ids.join(', ')}` }] };
|
|
77
|
+
});
|
|
78
|
+
server.tool('archive_tickets', 'Archive one or more tickets.', { ids: z.array(z.string()).describe('Ticket IDs to archive') }, async ({ ids }) => {
|
|
79
|
+
await apiPut(`/tickets/archive?ids=${ids.join(',')}`);
|
|
80
|
+
return { content: [{ type: 'text', text: `Archived: ${ids.join(', ')}` }] };
|
|
81
|
+
});
|
|
82
|
+
server.tool('restore_tickets', 'Restore one or more archived tickets.', { ids: z.array(z.string()).describe('Ticket IDs to restore') }, async ({ ids }) => {
|
|
83
|
+
await apiPut(`/tickets/restore?ids=${ids.join(',')}`);
|
|
84
|
+
return { content: [{ type: 'text', text: `Restored: ${ids.join(', ')}` }] };
|
|
85
|
+
});
|
|
86
|
+
server.tool('link_to_parent', 'Link one or more tickets to a parent (epic). Stories become children of the epic.', {
|
|
87
|
+
ids: z.array(z.string()).describe('Ticket IDs to link'),
|
|
88
|
+
parentId: z.string().describe('Parent ticket ID'),
|
|
89
|
+
}, async ({ ids, parentId }) => {
|
|
90
|
+
await apiPut(`/tickets/addParent?ids=${ids.join(',')}&parent_id=${parentId}`);
|
|
91
|
+
return { content: [{ type: 'text', text: `Linked ${ids.join(', ')} → parent ${parentId}` }] };
|
|
92
|
+
});
|
|
93
|
+
server.tool('unlink_from_parent', 'Remove parent relationship from one or more tickets.', { ids: z.array(z.string()).describe('Ticket IDs to unlink') }, async ({ ids }) => {
|
|
94
|
+
await apiPut(`/tickets/removeParent?ids=${ids.join(',')}`);
|
|
95
|
+
return { content: [{ type: 'text', text: `Unlinked: ${ids.join(', ')}` }] };
|
|
96
|
+
});
|
|
97
|
+
server.tool('list_ticket_moves', 'Get the movement history for a ticket.', { id: z.string().describe('Ticket ID') }, async ({ id }) => {
|
|
98
|
+
const moves = await apiGet(`/tickets/${id}/moves`);
|
|
99
|
+
return { content: [{ type: 'text', text: JSON.stringify(moves, null, 2) }] };
|
|
100
|
+
});
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mauvable/flowboards-mcp",
|
|
3
|
+
"mcpName": "io.github.Germanicus1/flowboards-mcp",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "MCP server for the FlowBoards workflow management API",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"flowboards-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"dev": "tsx src/index.ts",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
|
+
"zod": "^3.23.8"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"tsx": "^4.19.0",
|
|
24
|
+
"typescript": "^5.6.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|