punchline-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 drmzperx
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,104 @@
1
+ # punchline-mcp
2
+
3
+ A local stdio [MCP](https://modelcontextprotocol.io) server that exposes the Punchline external
4
+ API as agent tools. Authorization is entirely server-side — the tools you get depend on the API
5
+ key you configure, not on any client-side flag.
6
+
7
+ ## Setup
8
+
9
+ The server is published to npm — no repo access needed, just Node ≥ 18. With the Claude Code CLI:
10
+
11
+ ```bash
12
+ claude mcp add punchline --env PUNCHLINE_API_KEY=pnchl_live_... -- npx -y punchline-mcp
13
+ ```
14
+
15
+ Or with the generic MCP client JSON config:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "punchline": {
21
+ "command": "npx",
22
+ "args": ["-y", "punchline-mcp"],
23
+ "env": { "PUNCHLINE_API_KEY": "pnchl_live_..." }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ ### From source
30
+
31
+ Working from a checkout of this repo instead:
32
+
33
+ ```bash
34
+ cd punchline-mcp
35
+ npm install
36
+ npm run build
37
+ ```
38
+
39
+ Then register the built entry point in place of npx — e.g. with the Claude Code CLI:
40
+
41
+ ```bash
42
+ claude mcp add punchline --env PUNCHLINE_API_KEY=pnchl_live_... -- node /path/to/punchline/punchline-mcp/dist/index.js
43
+ ```
44
+
45
+ (For the JSON config, use `"command": "node"` and `"args": ["/path/to/punchline/punchline-mcp/dist/index.js"]`.)
46
+
47
+ ## Environment variables
48
+
49
+ | Variable | Required | Default | Notes |
50
+ |---|---|---|---|
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 | `http://proding.co:8050` | Punchline API origin. |
53
+
54
+ Any other key prefix, or a missing key, fails at startup with a `ConfigError` on stderr.
55
+
56
+ ## Key-type behavior
57
+
58
+ The server registers a different tool set depending on the key prefix:
59
+
60
+ - **`pnchl_live_…` (project key)** — registers the 7 ticket/project tools: `create_ticket`,
61
+ `list_tickets`, `get_ticket`, `update_ticket`, `add_communication`, `whoami`, `get_changelog`.
62
+ - **`pnchl_user_…` (user provisioning key)** — registers only `create_project`, which returns a
63
+ ONE-TIME project API key.
64
+
65
+ A single server instance only ever exposes one set. To give an agent both capabilities, register
66
+ the server twice under two names with two different keys, e.g. `punchline` (project key) and
67
+ `punchline-provisioning` (user key).
68
+
69
+ ## Tools
70
+
71
+ | Tool | Available with | Description |
72
+ |---|---|---|
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
+ | `list_tickets` | project key | List tickets visible to the key (own tickets, or the whole project with `read:all`). Paginated. |
75
+ | `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
+ | `add_communication` | project key | Post a customer-facing communication (external comment) on a ticket. |
78
+ | `whoami` | project key | Introspect the configured key: project, key name, scopes, last use. Useful for self-diagnosing 403s. |
79
+ | `get_changelog` | project key | Recent externally-visible ticket changes for the project. |
80
+ | `create_project` | user key | Provision a new Punchline project. Returns the project and a ONE-TIME project API key. |
81
+
82
+ ## Manual smoke test
83
+
84
+ With a real key configured in your MCP client, ask the agent to run `whoami` and confirm it
85
+ returns your project/key details.
86
+
87
+ Or run the server directly and check the startup log on stderr:
88
+
89
+ ```bash
90
+ PUNCHLINE_API_KEY=pnchl_live_... node dist/index.js
91
+ # punchline-mcp: 7 tool(s) registered [create_ticket, list_tickets, get_ticket, update_ticket, add_communication, whoami, get_changelog]
92
+ ```
93
+
94
+ ## Errors
95
+
96
+ Tool errors carry the API's problem detail: 403 scope errors (e.g. missing `write:own`), 409
97
+ conflicts (e.g. duplicate project slug/prefix), and 429 rate limits with `Retry-After`.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ npx vitest run # unit tests
103
+ npm run build # tsc --noEmit-equivalent build to dist/
104
+ ```
package/dist/client.js ADDED
@@ -0,0 +1,67 @@
1
+ export const DEFAULT_BASE_URL = 'http://proding.co:8050';
2
+ export class ApiError extends Error {
3
+ status;
4
+ title;
5
+ detail;
6
+ retryAfter;
7
+ rateLimitRemaining;
8
+ rateLimitLimit;
9
+ constructor(status, title, detail, retryAfter, rateLimitRemaining, rateLimitLimit) {
10
+ super(`${status} ${title}${detail ? `: ${detail}` : ''}`);
11
+ this.status = status;
12
+ this.title = title;
13
+ this.detail = detail;
14
+ this.retryAfter = retryAfter;
15
+ this.rateLimitRemaining = rateLimitRemaining;
16
+ this.rateLimitLimit = rateLimitLimit;
17
+ this.name = 'ApiError';
18
+ }
19
+ }
20
+ export class PunchlineClient {
21
+ apiKey;
22
+ baseUrl;
23
+ constructor(apiKey, baseUrl = DEFAULT_BASE_URL) {
24
+ this.apiKey = apiKey;
25
+ this.baseUrl = baseUrl;
26
+ }
27
+ async request(method, path, opts = {}) {
28
+ const url = new URL(path, this.baseUrl);
29
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
30
+ if (v !== undefined)
31
+ url.searchParams.set(k, String(v));
32
+ }
33
+ let res;
34
+ try {
35
+ res = await fetch(url, {
36
+ method,
37
+ headers: {
38
+ 'X-Punchline-Key': this.apiKey,
39
+ ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
40
+ },
41
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
42
+ });
43
+ }
44
+ catch (err) {
45
+ throw new Error(`Cannot reach Punchline API at ${this.baseUrl}: ${err.message}`);
46
+ }
47
+ if (!res.ok) {
48
+ let title = res.statusText || 'Error';
49
+ let detail;
50
+ const raw = await res.text();
51
+ try {
52
+ const problem = JSON.parse(raw);
53
+ if (problem.title)
54
+ title = problem.title;
55
+ detail = problem.detail;
56
+ }
57
+ catch {
58
+ // non-JSON error body — fall back to a raw text snippet for diagnostics
59
+ detail = raw.slice(0, 200) || undefined;
60
+ }
61
+ throw new ApiError(res.status, title, detail, res.headers.get('Retry-After') ?? undefined, res.headers.get('X-RateLimit-Remaining') ?? undefined, res.headers.get('X-RateLimit-Limit') ?? undefined);
62
+ }
63
+ if (res.status === 204)
64
+ return undefined;
65
+ return (await res.json());
66
+ }
67
+ }
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { buildServer, ConfigError, parseConfig } from './server.js';
4
+ async function main() {
5
+ const config = parseConfig(process.env);
6
+ const { server, registeredTools } = buildServer(config);
7
+ // The stdio protocol owns stdout — diagnostics MUST go to stderr.
8
+ console.error(`punchline-mcp: ${registeredTools.length} tool(s) registered [${registeredTools.join(', ')}]`);
9
+ await server.connect(new StdioServerTransport());
10
+ }
11
+ main().catch((err) => {
12
+ console.error(err instanceof ConfigError ? `punchline-mcp: ${err.message}` : err);
13
+ process.exit(1);
14
+ });
package/dist/server.js ADDED
@@ -0,0 +1,39 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { DEFAULT_BASE_URL, PunchlineClient } from './client.js';
3
+ import { registerCommunicationTools, COMMUNICATION_TOOL_NAMES } from './tools/communications.js';
4
+ import { registerMetaTools, META_TOOL_NAMES } from './tools/meta.js';
5
+ import { registerProvisioningTools, PROVISIONING_TOOL_NAMES } from './tools/provisioning.js';
6
+ import { registerTicketTools, TICKET_TOOL_NAMES } from './tools/tickets.js';
7
+ export const PROJECT_KEY_PREFIX = 'pnchl_live_';
8
+ export const USER_KEY_PREFIX = 'pnchl_user_';
9
+ export class ConfigError extends Error {
10
+ }
11
+ export function parseConfig(env) {
12
+ const apiKey = env.PUNCHLINE_API_KEY?.trim();
13
+ if (!apiKey) {
14
+ throw new ConfigError('PUNCHLINE_API_KEY is required: a pnchl_live_… project key (ticket tools) or a pnchl_user_… provisioning key (create_project).');
15
+ }
16
+ if (!apiKey.startsWith(PROJECT_KEY_PREFIX) && !apiKey.startsWith(USER_KEY_PREFIX)) {
17
+ throw new ConfigError(`Unrecognized API key prefix. Expected ${PROJECT_KEY_PREFIX}… (project key) or ${USER_KEY_PREFIX}… (user provisioning key).`);
18
+ }
19
+ return { apiKey, baseUrl: env.PUNCHLINE_BASE_URL?.trim() || DEFAULT_BASE_URL };
20
+ }
21
+ export function buildServer(config) {
22
+ const server = new McpServer({ name: 'punchline', version: '0.1.0' });
23
+ const client = new PunchlineClient(config.apiKey, config.baseUrl);
24
+ const registeredTools = [];
25
+ if (config.apiKey.startsWith(USER_KEY_PREFIX)) {
26
+ registerProvisioningTools(server, client);
27
+ registeredTools.push(...PROVISIONING_TOOL_NAMES);
28
+ }
29
+ else if (config.apiKey.startsWith(PROJECT_KEY_PREFIX)) {
30
+ registerTicketTools(server, client);
31
+ registerCommunicationTools(server, client);
32
+ registerMetaTools(server, client);
33
+ registeredTools.push(...TICKET_TOOL_NAMES, ...COMMUNICATION_TOOL_NAMES, ...META_TOOL_NAMES);
34
+ }
35
+ else {
36
+ throw new ConfigError(`Unrecognized API key prefix. Expected ${PROJECT_KEY_PREFIX}… or ${USER_KEY_PREFIX}….`);
37
+ }
38
+ return { server, registeredTools };
39
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ import { ticketPath } from './tickets.js';
3
+ import { tool } from './util.js';
4
+ export const COMMUNICATION_TOOL_NAMES = ['add_communication'];
5
+ export function registerCommunicationTools(server, client) {
6
+ server.registerTool('add_communication', {
7
+ description: 'Post a customer-facing communication (external comment) on a ticket.',
8
+ inputSchema: {
9
+ display_id: z.string().min(1).describe('e.g. ACM-7'),
10
+ body: z.string().min(1).max(10000).describe('Message text'),
11
+ },
12
+ }, tool(async (a) => client.request('POST', `${ticketPath(a.display_id)}/communications`, {
13
+ body: { body: a.body },
14
+ })));
15
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import { TICKET_TYPES } from './tickets.js';
3
+ import { tool } from './util.js';
4
+ export const META_TOOL_NAMES = ['whoami', 'get_changelog'];
5
+ export function registerMetaTools(server, client) {
6
+ server.registerTool('whoami', {
7
+ description: 'Introspect the configured API key: project, key name, scopes, last use. Use this to self-diagnose 403 errors.',
8
+ inputSchema: {},
9
+ }, tool(async () => client.request('GET', '/api/v1/external/me')));
10
+ server.registerTool('get_changelog', {
11
+ description: 'Recent externally-visible ticket changes for the project bound to this key.',
12
+ inputSchema: {
13
+ from: z.string().datetime().optional().describe('ISO-8601 instant'),
14
+ to: z.string().datetime().optional().describe('ISO-8601 instant'),
15
+ type: z.enum(TICKET_TYPES).optional(),
16
+ },
17
+ }, tool(async (a) => client.request('GET', '/api/v1/external/changelog', {
18
+ query: { from: a.from, to: a.to, type: a.type },
19
+ })));
20
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { formatError } from './util.js';
3
+ export const PROVISIONING_TOOL_NAMES = ['create_project'];
4
+ export function registerProvisioningTools(server, client) {
5
+ server.registerTool('create_project', {
6
+ description: 'Provision a new Punchline project (requires a pnchl_user_ provisioning key). Returns the project and a ONE-TIME project API key.',
7
+ inputSchema: {
8
+ name: z.string().max(255),
9
+ slug: z.string().regex(/^[a-z0-9-]{2,100}$/, 'lowercase letters, digits, hyphens; 2-100 chars'),
10
+ prefix: z.string().regex(/^[A-Z]{2,10}$/, '2-10 uppercase letters'),
11
+ description: z.string().max(65535).optional(),
12
+ },
13
+ }, async (a) => {
14
+ try {
15
+ const data = await client.request('POST', '/api/v1/external/projects', {
16
+ body: { name: a.name, slug: a.slug, prefix: a.prefix, description: a.description },
17
+ });
18
+ const text = [
19
+ 'Project created. IMPORTANT: the apiKey below is a ONE-TIME project key —',
20
+ 'surface it to the user now; do NOT log it or persist it anywhere.',
21
+ JSON.stringify(data, null, 2),
22
+ ].join('\n');
23
+ return { content: [{ type: 'text', text }] };
24
+ }
25
+ catch (err) {
26
+ return { content: [{ type: 'text', text: formatError(err) }], isError: true };
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ import { tool } from './util.js';
3
+ export const TICKET_TYPES = ['BUG', 'FEATURE', 'TASK', 'IMPROVEMENT', 'IDEA', 'TECHNICAL'];
4
+ export const TICKET_PRIORITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
5
+ export const TICKET_TOOL_NAMES = ['create_ticket', 'list_tickets', 'get_ticket', 'update_ticket'];
6
+ export const TICKETS_PATH = '/api/v1/external/tickets';
7
+ export function ticketPath(displayId) {
8
+ return `${TICKETS_PATH}/${encodeURIComponent(displayId)}`;
9
+ }
10
+ export function registerTicketTools(server, client) {
11
+ server.registerTool('create_ticket', {
12
+ description: 'Create a ticket in the project bound to the API key. Supports idempotent retries via idempotency_key.',
13
+ inputSchema: {
14
+ title: z.string().min(1).max(500).describe('Ticket title'),
15
+ reporter_email: z.string().min(1).email().max(320).describe('Reporter email (required by the API)'),
16
+ description: z.string().max(50000).optional(),
17
+ type: z.enum(TICKET_TYPES).optional().describe('Defaults to BUG'),
18
+ priority: z.enum(TICKET_PRIORITIES).optional().describe('Defaults to MEDIUM'),
19
+ labels: z.array(z.string().max(100)).max(100).optional().describe('Label names; unknown labels are created'),
20
+ reporter_name: z.string().max(500).optional(),
21
+ page_url: z.string().max(2048).optional(),
22
+ user_agent: z.string().max(512).optional(),
23
+ app_version: z.string().max(100).optional(),
24
+ idempotency_key: z.string().max(255).optional()
25
+ .describe('Same key on the same API key returns the original ticket instead of creating a duplicate'),
26
+ },
27
+ }, tool(async (a) => client.request('POST', TICKETS_PATH, {
28
+ body: {
29
+ title: a.title,
30
+ reporterEmail: a.reporter_email,
31
+ description: a.description,
32
+ type: a.type,
33
+ priority: a.priority,
34
+ labels: a.labels,
35
+ reporterName: a.reporter_name,
36
+ pageUrl: a.page_url,
37
+ userAgent: a.user_agent,
38
+ appVersion: a.app_version,
39
+ idempotencyKey: a.idempotency_key,
40
+ },
41
+ })));
42
+ server.registerTool('list_tickets', {
43
+ description: 'List tickets visible to this API key (own tickets; whole project with the read:all scope). Paginated.',
44
+ inputSchema: {
45
+ page: z.number().int().min(0).optional().describe('0-based page index'),
46
+ size: z.number().int().min(1).max(100).optional().describe('Page size (default 20)'),
47
+ reporter_email: z.string().optional(),
48
+ status: z.string().optional().describe('Workflow status name, e.g. OPEN'),
49
+ type: z.enum(TICKET_TYPES).optional(),
50
+ created_after: z.string().datetime().optional().describe('ISO-8601 instant'),
51
+ created_before: z.string().datetime().optional().describe('ISO-8601 instant'),
52
+ },
53
+ }, tool(async (a) => client.request('GET', TICKETS_PATH, {
54
+ query: {
55
+ page: a.page,
56
+ size: a.size,
57
+ reporterEmail: a.reporter_email,
58
+ status: a.status,
59
+ type: a.type,
60
+ createdAfter: a.created_after,
61
+ createdBefore: a.created_before,
62
+ },
63
+ })));
64
+ server.registerTool('get_ticket', {
65
+ description: 'Fetch one ticket by display id (project prefix + number, e.g. ACM-7).',
66
+ inputSchema: {
67
+ display_id: z.string().min(1).describe('e.g. ACM-7'),
68
+ },
69
+ }, tool(async (a) => client.request('GET', ticketPath(a.display_id))));
70
+ 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.',
72
+ inputSchema: {
73
+ display_id: z.string().min(1),
74
+ status: z.string().max(100).optional(),
75
+ public_summary: z.string().max(10000).optional(),
76
+ },
77
+ }, tool(async (a) => {
78
+ if (a.status === undefined && a.public_summary === undefined) {
79
+ throw new Error('Provide status and/or public_summary');
80
+ }
81
+ return client.request('PATCH', ticketPath(a.display_id), {
82
+ body: { status: a.status, publicSummary: a.public_summary },
83
+ });
84
+ }));
85
+ }
@@ -0,0 +1,31 @@
1
+ import { ApiError } from '../client.js';
2
+ export function formatError(err) {
3
+ if (err instanceof ApiError) {
4
+ let msg = `${err.status} ${err.title}${err.detail ? `: ${err.detail}` : ''}`;
5
+ if (err.status === 429) {
6
+ const bits = [];
7
+ if (err.retryAfter)
8
+ bits.push(`Retry-After: ${err.retryAfter}s`);
9
+ if (err.rateLimitRemaining !== undefined)
10
+ bits.push(`X-RateLimit-Remaining: ${err.rateLimitRemaining}`);
11
+ if (err.rateLimitLimit !== undefined)
12
+ bits.push(`X-RateLimit-Limit: ${err.rateLimitLimit}`);
13
+ if (bits.length)
14
+ msg += ` (${bits.join(', ')})`;
15
+ }
16
+ return msg;
17
+ }
18
+ return err instanceof Error ? err.message : String(err);
19
+ }
20
+ /** Wraps a data-returning handler into an MCP tool result (pretty JSON text; errors as isError). */
21
+ export function tool(fn) {
22
+ return async (args) => {
23
+ try {
24
+ const data = await fn(args);
25
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
26
+ }
27
+ catch (err) {
28
+ return { content: [{ type: 'text', text: formatError(err) }], isError: true };
29
+ }
30
+ };
31
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "punchline-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP stdio server exposing the Punchline external API as tools",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/drmzperx/punchline.git",
10
+ "directory": "punchline-mcp"
11
+ },
12
+ "homepage": "https://github.com/drmzperx/punchline/tree/master/punchline-mcp#readme",
13
+ "keywords": [
14
+ "mcp",
15
+ "modelcontextprotocol",
16
+ "punchline",
17
+ "tickets",
18
+ "agent-tools"
19
+ ],
20
+ "bin": { "punchline-mcp": "dist/index.js" },
21
+ "files": ["dist"],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "vitest run",
25
+ "start": "node dist/index.js",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "engines": { "node": ">=18" },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.12.0",
31
+ "zod": "^3.24.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.10.0",
35
+ "typescript": "^5.7.0",
36
+ "vitest": "^2.1.0"
37
+ }
38
+ }