punchline-mcp 1.0.0 → 1.2.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 +15 -6
- package/dist/client.js +1 -1
- package/dist/server.js +3 -1
- package/dist/tools/links.js +67 -0
- package/dist/tools/tickets.js +30 -8
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ claude mcp add punchline --env PUNCHLINE_API_KEY=pnchl_live_... -- node /path/to
|
|
|
49
49
|
| Variable | Required | Default | Notes |
|
|
50
50
|
|---|---|---|---|
|
|
51
51
|
| `PUNCHLINE_API_KEY` | Yes | — | A `pnchl_live_…` project key or a `pnchl_user_…` user provisioning key. Sent as `X-Punchline-Key`. |
|
|
52
|
-
| `PUNCHLINE_BASE_URL` | No | `
|
|
52
|
+
| `PUNCHLINE_BASE_URL` | No | `https://proding.co:8050` | Punchline API origin. |
|
|
53
53
|
|
|
54
54
|
Any other key prefix, or a missing key, fails at startup with a `ConfigError` on stderr.
|
|
55
55
|
|
|
@@ -57,8 +57,9 @@ Any other key prefix, or a missing key, fails at startup with a `ConfigError` on
|
|
|
57
57
|
|
|
58
58
|
The server registers a different tool set depending on the key prefix:
|
|
59
59
|
|
|
60
|
-
- **`pnchl_live_…` (project key)** — registers the
|
|
61
|
-
`list_tickets`, `get_ticket`, `update_ticket`, `add_communication`, `
|
|
60
|
+
- **`pnchl_live_…` (project key)** — registers the 9 ticket/project tools: `create_ticket`,
|
|
61
|
+
`list_tickets`, `get_ticket`, `update_ticket`, `add_communication`, `link_tickets`,
|
|
62
|
+
`unlink_tickets`, `whoami`, `get_changelog`.
|
|
62
63
|
- **`pnchl_user_…` (user provisioning key)** — registers only `create_project`, which returns a
|
|
63
64
|
ONE-TIME project API key.
|
|
64
65
|
|
|
@@ -70,11 +71,19 @@ the server twice under two names with two different keys, e.g. `punchline` (proj
|
|
|
70
71
|
|
|
71
72
|
| Tool | Available with | Description |
|
|
72
73
|
|---|---|---|
|
|
73
|
-
| `create_ticket` | project key | Create a ticket. `reporter_email` is required. Supports idempotent retries via `idempotency_key`, sent to the API as the `idempotencyKey` body field. |
|
|
74
|
+
| `create_ticket` | project key | Create a ticket. `reporter_email` is required. Supports idempotent retries via `idempotency_key`, sent to the API as the `idempotencyKey` body field. Optional `custom_fields` (sent as `customFields`) is an array of `{ name, value }` pairs; `name` must match a custom-field definition configured on the project or the request is rejected with 400. |
|
|
74
75
|
| `list_tickets` | project key | List tickets visible to the key (own tickets, or the whole project with `read:all`). Paginated. |
|
|
75
76
|
| `get_ticket` | project key | Fetch one ticket by display id (e.g. `ACM-7`). |
|
|
76
|
-
| `update_ticket` | project key | Change a ticket's workflow status and/or customer-facing public summary. |
|
|
77
|
+
| `update_ticket` | project key | Change a ticket's workflow status, its priority, and/or customer-facing public summary. Setting `priority` records a **triage ruling** — confirming the current value counts as one — which a project enforcing triage requires before a ticket can close. Priority is applied before status, so one call can triage and close. A ticket created through this API arrives untriaged: the priority sent at creation is the reporter's proposal, not a ruling. *(1.2.0+)* |
|
|
77
78
|
| `add_communication` | project key | Post a customer-facing communication (external comment) on a ticket. |
|
|
79
|
+
| `link_tickets` | project key | Link two tickets. `relation` is one of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `DUPLICATE_OF`, `RELATES_TO`, read from the perspective of the ticket in `display_id` — the same vocabulary `get_ticket` returns in `linkedTickets`, so a relation round-trips. ⚠️ `close_as_duplicate: true` also **closes the `display_id` ticket and emails its reporter**; it requires `relation=DUPLICATES` and is not undone by `unlink_tickets`. |
|
|
80
|
+
| `unlink_tickets` | project key | Remove a link. `relation` must match how the link reads from the ticket in `display_id`. |
|
|
81
|
+
|
|
82
|
+
> **Cross-project links are omitted from every response.** These endpoints can only create links
|
|
83
|
+
> within your own project, but a staff member can link one of your tickets to a ticket in another
|
|
84
|
+
> project from the Punchline UI. Those are filtered out of `linkedTickets` and `links` alike — so a
|
|
85
|
+
> ticket may carry links you cannot see, and a `409 Link already exists` from `link_tickets` can
|
|
86
|
+
> refer to one of them.
|
|
78
87
|
| `whoami` | project key | Introspect the configured key: project, key name, scopes, last use. Useful for self-diagnosing 403s. |
|
|
79
88
|
| `get_changelog` | project key | Recent externally-visible ticket changes for the project. |
|
|
80
89
|
| `create_project` | user key | Provision a new Punchline project. Returns the project and a ONE-TIME project API key. |
|
|
@@ -88,7 +97,7 @@ Or run the server directly and check the startup log on stderr:
|
|
|
88
97
|
|
|
89
98
|
```bash
|
|
90
99
|
PUNCHLINE_API_KEY=pnchl_live_... node dist/index.js
|
|
91
|
-
# punchline-mcp:
|
|
100
|
+
# punchline-mcp: 9 tool(s) registered [create_ticket, list_tickets, get_ticket, update_ticket, add_communication, link_tickets, unlink_tickets, whoami, get_changelog]
|
|
92
101
|
```
|
|
93
102
|
|
|
94
103
|
## Errors
|
package/dist/client.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { DEFAULT_BASE_URL, PunchlineClient } from './client.js';
|
|
3
3
|
import { registerCommunicationTools, COMMUNICATION_TOOL_NAMES } from './tools/communications.js';
|
|
4
|
+
import { registerLinkTools, LINK_TOOL_NAMES } from './tools/links.js';
|
|
4
5
|
import { registerMetaTools, META_TOOL_NAMES } from './tools/meta.js';
|
|
5
6
|
import { registerProvisioningTools, PROVISIONING_TOOL_NAMES } from './tools/provisioning.js';
|
|
6
7
|
import { registerTicketTools, TICKET_TOOL_NAMES } from './tools/tickets.js';
|
|
@@ -29,8 +30,9 @@ export function buildServer(config) {
|
|
|
29
30
|
else if (config.apiKey.startsWith(PROJECT_KEY_PREFIX)) {
|
|
30
31
|
registerTicketTools(server, client);
|
|
31
32
|
registerCommunicationTools(server, client);
|
|
33
|
+
registerLinkTools(server, client);
|
|
32
34
|
registerMetaTools(server, client);
|
|
33
|
-
registeredTools.push(...TICKET_TOOL_NAMES, ...COMMUNICATION_TOOL_NAMES, ...META_TOOL_NAMES);
|
|
35
|
+
registeredTools.push(...TICKET_TOOL_NAMES, ...COMMUNICATION_TOOL_NAMES, ...LINK_TOOL_NAMES, ...META_TOOL_NAMES);
|
|
34
36
|
}
|
|
35
37
|
else {
|
|
36
38
|
throw new ConfigError(`Unrecognized API key prefix. Expected ${PROJECT_KEY_PREFIX}… or ${USER_KEY_PREFIX}….`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ticketPath } from './tickets.js';
|
|
3
|
+
import { tool } from './util.js';
|
|
4
|
+
/**
|
|
5
|
+
* The wire vocabulary is directional and mirrors what `get_ticket` returns in
|
|
6
|
+
* `linkedTickets`, so a relation read from one call can be passed straight back
|
|
7
|
+
* into another. BLOCKED_BY and DUPLICATE_OF are stored by flipping source and
|
|
8
|
+
* target server-side.
|
|
9
|
+
*/
|
|
10
|
+
export const LINK_RELATIONS = [
|
|
11
|
+
'BLOCKS',
|
|
12
|
+
'BLOCKED_BY',
|
|
13
|
+
'DUPLICATES',
|
|
14
|
+
'DUPLICATE_OF',
|
|
15
|
+
'RELATES_TO',
|
|
16
|
+
];
|
|
17
|
+
export const LINK_TOOL_NAMES = ['link_tickets', 'unlink_tickets'];
|
|
18
|
+
function linksPath(displayId) {
|
|
19
|
+
return `${ticketPath(displayId)}/links`;
|
|
20
|
+
}
|
|
21
|
+
export function registerLinkTools(server, client) {
|
|
22
|
+
server.registerTool('link_tickets', {
|
|
23
|
+
description: 'Link two tickets in the project bound to the API key. `relation` is read from the ' +
|
|
24
|
+
'perspective of the ticket in `display_id`, using the same vocabulary `get_ticket` ' +
|
|
25
|
+
'returns in `linkedTickets`. Both tickets must be in this project and visible to the key. ' +
|
|
26
|
+
'WARNING: `close_as_duplicate` is destructive — it closes the `display_id` ticket and ' +
|
|
27
|
+
'emails its reporter. It requires relation=DUPLICATES and cannot be undone by unlinking. ' +
|
|
28
|
+
'Note: links to tickets in OTHER projects are omitted from every response this server ' +
|
|
29
|
+
'returns, so a ticket may carry links you cannot see; a 409 "link already exists" can ' +
|
|
30
|
+
'therefore refer to a link absent from get_ticket.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
display_id: z.string().min(1).describe('The ticket the relation is stated from, e.g. ACM-7'),
|
|
33
|
+
target_display_id: z.string().min(1).describe('The other ticket, e.g. ACM-9'),
|
|
34
|
+
relation: z.enum(LINK_RELATIONS).describe('From display_id\'s perspective: BLOCKS (it blocks the target), BLOCKED_BY (it is ' +
|
|
35
|
+
'blocked by the target), DUPLICATES (it is a duplicate of the target), DUPLICATE_OF ' +
|
|
36
|
+
'(the target duplicates it), RELATES_TO (undirected)'),
|
|
37
|
+
close_as_duplicate: z.boolean().optional().describe('Default false. When true, also closes the display_id ticket as a duplicate and emails ' +
|
|
38
|
+
'its reporter. Rejected with 400 unless relation is exactly DUPLICATES — that guard is ' +
|
|
39
|
+
'what keeps the closed ticket the one you named, rather than the target.'),
|
|
40
|
+
},
|
|
41
|
+
}, tool(async (a) => client.request('POST', linksPath(a.display_id), {
|
|
42
|
+
body: {
|
|
43
|
+
targetDisplayId: a.target_display_id,
|
|
44
|
+
relation: a.relation,
|
|
45
|
+
closeAsDuplicate: a.close_as_duplicate ?? false,
|
|
46
|
+
},
|
|
47
|
+
})));
|
|
48
|
+
server.registerTool('unlink_tickets', {
|
|
49
|
+
description: 'Remove a link between two tickets. `relation` must match how the link reads from the ' +
|
|
50
|
+
'ticket in `display_id` — pass back the value `get_ticket` reported in `linkedTickets`. ' +
|
|
51
|
+
'Removing a duplicate link does NOT reopen a ticket that was closed by ' +
|
|
52
|
+
'close_as_duplicate; that stays a separate status change.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
display_id: z.string().min(1).describe('The ticket the relation is stated from, e.g. ACM-7'),
|
|
55
|
+
target_display_id: z.string().min(1).describe('The other ticket, e.g. ACM-9'),
|
|
56
|
+
relation: z.enum(LINK_RELATIONS).describe('As reported by get_ticket for this ticket'),
|
|
57
|
+
},
|
|
58
|
+
}, tool(async (a) => {
|
|
59
|
+
// The API answers 204 with no body, which the client surfaces as undefined —
|
|
60
|
+
// JSON.stringify(undefined) is not a string, so return an explicit result
|
|
61
|
+
// rather than letting that reach the tool output.
|
|
62
|
+
await client.request('DELETE', `${linksPath(a.display_id)}/${encodeURIComponent(a.target_display_id)}`, {
|
|
63
|
+
query: { relation: a.relation },
|
|
64
|
+
});
|
|
65
|
+
return { deleted: true, displayId: a.display_id, targetDisplayId: a.target_display_id, relation: a.relation };
|
|
66
|
+
}));
|
|
67
|
+
}
|
package/dist/tools/tickets.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { tool } from './util.js';
|
|
3
|
-
export const TICKET_TYPES = ['BUG', 'FEATURE', 'TASK', '
|
|
3
|
+
export const TICKET_TYPES = ['BUG', 'FEATURE', 'TASK', 'ENHANCEMENT', 'IDEA', 'TECHNICAL'];
|
|
4
4
|
export const TICKET_PRIORITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
|
5
5
|
export const TICKET_TOOL_NAMES = ['create_ticket', 'list_tickets', 'get_ticket', 'update_ticket'];
|
|
6
|
+
/**
|
|
7
|
+
* Appended to the description of every tool whose response carries ticket links.
|
|
8
|
+
* The API filters cross-project links out of all external responses, so an agent that
|
|
9
|
+
* treats an absent link as "no such link exists" will be wrong.
|
|
10
|
+
*/
|
|
11
|
+
export const CROSS_PROJECT_LINK_NOTE = ' Links to tickets in other projects are omitted from the response — a ticket may carry links'
|
|
12
|
+
+ ' that are not shown here.';
|
|
6
13
|
export const TICKETS_PATH = '/api/v1/external/tickets';
|
|
7
14
|
export function ticketPath(displayId) {
|
|
8
15
|
return `${TICKETS_PATH}/${encodeURIComponent(displayId)}`;
|
|
9
16
|
}
|
|
10
17
|
export function registerTicketTools(server, client) {
|
|
11
18
|
server.registerTool('create_ticket', {
|
|
12
|
-
description: 'Create a ticket in the project bound to the API key. Supports idempotent retries via idempotency_key.'
|
|
19
|
+
description: 'Create a ticket in the project bound to the API key. Supports idempotent retries via idempotency_key.'
|
|
20
|
+
+ ' custom_fields lets you stamp name-keyed values that other project tooling (e.g. outbound webhooks) reads back'
|
|
21
|
+
+ ' later; each name must match a custom-field definition already configured on the project — an unknown name is'
|
|
22
|
+
+ ' rejected with a 400 error rather than silently ignored.'
|
|
23
|
+
+ CROSS_PROJECT_LINK_NOTE,
|
|
13
24
|
inputSchema: {
|
|
14
25
|
title: z.string().min(1).max(500).describe('Ticket title'),
|
|
15
26
|
reporter_email: z.string().min(1).email().max(320).describe('Reporter email (required by the API)'),
|
|
@@ -23,6 +34,11 @@ export function registerTicketTools(server, client) {
|
|
|
23
34
|
app_version: z.string().max(100).optional(),
|
|
24
35
|
idempotency_key: z.string().max(255).optional()
|
|
25
36
|
.describe('Same key on the same API key returns the original ticket instead of creating a duplicate'),
|
|
37
|
+
custom_fields: z.array(z.object({
|
|
38
|
+
name: z.string().min(1).max(255).describe('Must match a custom-field name configured on the project'),
|
|
39
|
+
value: z.string().max(4096),
|
|
40
|
+
})).max(50).optional()
|
|
41
|
+
.describe('Name-keyed custom field values, e.g. [{ name: "rubedyne_user_id", value: "3172" }]. An unknown name is rejected with 400.'),
|
|
26
42
|
},
|
|
27
43
|
}, tool(async (a) => client.request('POST', TICKETS_PATH, {
|
|
28
44
|
body: {
|
|
@@ -37,10 +53,12 @@ export function registerTicketTools(server, client) {
|
|
|
37
53
|
userAgent: a.user_agent,
|
|
38
54
|
appVersion: a.app_version,
|
|
39
55
|
idempotencyKey: a.idempotency_key,
|
|
56
|
+
customFields: a.custom_fields,
|
|
40
57
|
},
|
|
41
58
|
})));
|
|
42
59
|
server.registerTool('list_tickets', {
|
|
43
|
-
description: 'List tickets visible to this API key (own tickets; whole project with the read:all scope). Paginated.'
|
|
60
|
+
description: 'List tickets visible to this API key (own tickets; whole project with the read:all scope). Paginated.'
|
|
61
|
+
+ CROSS_PROJECT_LINK_NOTE,
|
|
44
62
|
inputSchema: {
|
|
45
63
|
page: z.number().int().min(0).optional().describe('0-based page index'),
|
|
46
64
|
size: z.number().int().min(1).max(100).optional().describe('Page size (default 20)'),
|
|
@@ -62,24 +80,28 @@ export function registerTicketTools(server, client) {
|
|
|
62
80
|
},
|
|
63
81
|
})));
|
|
64
82
|
server.registerTool('get_ticket', {
|
|
65
|
-
description: 'Fetch one ticket by display id (project prefix + number, e.g. ACM-7).'
|
|
83
|
+
description: 'Fetch one ticket by display id (project prefix + number, e.g. ACM-7).'
|
|
84
|
+
+ CROSS_PROJECT_LINK_NOTE,
|
|
66
85
|
inputSchema: {
|
|
67
86
|
display_id: z.string().min(1).describe('e.g. ACM-7'),
|
|
68
87
|
},
|
|
69
88
|
}, tool(async (a) => client.request('GET', ticketPath(a.display_id))));
|
|
70
89
|
server.registerTool('update_ticket', {
|
|
71
|
-
description: 'Update a ticket: change its workflow status and/or the customer-facing public summary. At least one field is required; an empty public_summary clears it.'
|
|
90
|
+
description: 'Update a ticket: change its workflow status, its priority, and/or the customer-facing public summary. At least one field is required; an empty public_summary clears it. Setting priority records a triage ruling — confirming the existing value counts — which is what a project requiring triage before close demands. Priority is applied before status, so one call can triage and close.'
|
|
91
|
+
+ CROSS_PROJECT_LINK_NOTE,
|
|
72
92
|
inputSchema: {
|
|
73
93
|
display_id: z.string().min(1),
|
|
74
94
|
status: z.string().max(100).optional(),
|
|
75
95
|
public_summary: z.string().max(10000).optional(),
|
|
96
|
+
priority: z.enum(TICKET_PRIORITIES).optional()
|
|
97
|
+
.describe('Records a triage ruling. Required before closing in a project that enforces triage.'),
|
|
76
98
|
},
|
|
77
99
|
}, tool(async (a) => {
|
|
78
|
-
if (a.status === undefined && a.public_summary === undefined) {
|
|
79
|
-
throw new Error('Provide status and/or public_summary');
|
|
100
|
+
if (a.status === undefined && a.public_summary === undefined && a.priority === undefined) {
|
|
101
|
+
throw new Error('Provide status, priority and/or public_summary');
|
|
80
102
|
}
|
|
81
103
|
return client.request('PATCH', ticketPath(a.display_id), {
|
|
82
|
-
body: { status: a.status, publicSummary: a.public_summary },
|
|
104
|
+
body: { status: a.status, publicSummary: a.public_summary, priority: a.priority },
|
|
83
105
|
});
|
|
84
106
|
}));
|
|
85
107
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "punchline-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "MCP stdio server exposing the Punchline external API as tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,15 +17,21 @@
|
|
|
17
17
|
"tickets",
|
|
18
18
|
"agent-tools"
|
|
19
19
|
],
|
|
20
|
-
"bin": {
|
|
21
|
-
|
|
20
|
+
"bin": {
|
|
21
|
+
"punchline-mcp": "dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
22
26
|
"scripts": {
|
|
23
27
|
"build": "tsc",
|
|
24
28
|
"test": "vitest run",
|
|
25
29
|
"start": "node dist/index.js",
|
|
26
30
|
"prepublishOnly": "npm run build"
|
|
27
31
|
},
|
|
28
|
-
"engines": {
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
29
35
|
"dependencies": {
|
|
30
36
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
31
37
|
"zod": "^3.24.0"
|