@shortcut/mcp 0.15.1 → 0.16.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.
@@ -0,0 +1,430 @@
1
+ import { CustomMcpServer, DocumentTools, EpicTools, IterationTools, ObjectiveTools, ShortcutClientWrapper, StoryTools, TeamTools, UserTools, WorkflowTools } from "./workflows-ChA_XcfF.js";
2
+ import { ShortcutClient } from "@shortcut/client";
3
+ import { randomUUID } from "node:crypto";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
+ import express from "express";
7
+ import pino from "pino";
8
+
9
+ //#region src/server-http.ts
10
+ const DEFAULT_PORT = 9292;
11
+ const BEARER_PREFIX = "Bearer ";
12
+ const SESSION_TIMEOUT_MS = 1800 * 1e3;
13
+ const HEADERS = {
14
+ AUTHORIZATION: "authorization",
15
+ X_SHORTCUT_API_TOKEN: "x-shortcut-api-token",
16
+ MCP_SESSION_ID: "mcp-session-id",
17
+ LAST_EVENT_ID: "last-event-id"
18
+ };
19
+ const JSON_RPC_ERRORS = {
20
+ UNAUTHORIZED: {
21
+ code: -32e3,
22
+ message: "Unauthorized"
23
+ },
24
+ BAD_REQUEST: {
25
+ code: -32e3,
26
+ message: "Bad Request"
27
+ },
28
+ SESSION_NOT_FOUND: {
29
+ code: -32001,
30
+ message: "Session not found"
31
+ },
32
+ INVALID_TOKEN: {
33
+ code: -32002,
34
+ message: "Invalid API token"
35
+ },
36
+ INTERNAL_ERROR: {
37
+ code: -32603,
38
+ message: "Internal server error"
39
+ }
40
+ };
41
+ const logger = pino({
42
+ level: process.env.LOG_LEVEL || "info",
43
+ transport: process.env.NODE_ENV !== "production" ? {
44
+ target: "pino-pretty",
45
+ options: {
46
+ colorize: true,
47
+ translateTime: "HH:MM:ss",
48
+ ignore: "pid,hostname"
49
+ }
50
+ } : void 0
51
+ });
52
+ function loadConfig() {
53
+ let isReadonly = process.env.SHORTCUT_READONLY !== "false";
54
+ let enabledTools = parseToolsList(process.env.SHORTCUT_TOOLS || "");
55
+ if (process.argv.length >= 3) process.argv.slice(2).map((arg) => arg.split("=")).forEach(([name, value]) => {
56
+ if (name === "SHORTCUT_READONLY") isReadonly = value !== "false";
57
+ if (name === "SHORTCUT_TOOLS") enabledTools = parseToolsList(value);
58
+ });
59
+ return {
60
+ port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
61
+ isReadonly,
62
+ enabledTools,
63
+ sessionTimeoutMs: SESSION_TIMEOUT_MS
64
+ };
65
+ }
66
+ function parseToolsList(toolsStr) {
67
+ return toolsStr.split(",").map((tool) => tool.trim()).filter(Boolean);
68
+ }
69
+ var SessionManager = class {
70
+ sessions = /* @__PURE__ */ new Map();
71
+ cleanupInterval = null;
72
+ constructor(timeoutMs) {
73
+ this.timeoutMs = timeoutMs;
74
+ this.cleanupInterval = setInterval(() => this.cleanupStaleSessions(), 6e4);
75
+ }
76
+ has(sessionId) {
77
+ return this.sessions.has(sessionId);
78
+ }
79
+ get(sessionId) {
80
+ const session = this.sessions.get(sessionId);
81
+ if (session) session.lastAccessedAt = /* @__PURE__ */ new Date();
82
+ return session;
83
+ }
84
+ add(sessionId, transport, apiToken) {
85
+ this.sessions.set(sessionId, {
86
+ transport,
87
+ apiToken,
88
+ createdAt: /* @__PURE__ */ new Date(),
89
+ lastAccessedAt: /* @__PURE__ */ new Date()
90
+ });
91
+ logger.info({ sessionId }, "Session initialized");
92
+ }
93
+ remove(sessionId) {
94
+ const session = this.sessions.get(sessionId);
95
+ if (session) {
96
+ this.sessions.delete(sessionId);
97
+ logger.info({ sessionId }, "Session removed");
98
+ }
99
+ }
100
+ validateToken(sessionId, providedToken) {
101
+ const session = this.sessions.get(sessionId);
102
+ if (!session) return false;
103
+ return session.apiToken === providedToken;
104
+ }
105
+ cleanupStaleSessions() {
106
+ const now = Date.now();
107
+ const staleSessionIds = [];
108
+ for (const [sessionId, session] of this.sessions.entries()) {
109
+ const timeSinceLastAccess = now - session.lastAccessedAt.getTime();
110
+ if (timeSinceLastAccess > this.timeoutMs) staleSessionIds.push(sessionId);
111
+ }
112
+ if (staleSessionIds.length > 0) {
113
+ logger.info({ count: staleSessionIds.length }, "Cleaning up stale sessions");
114
+ for (const sessionId of staleSessionIds) {
115
+ const session = this.sessions.get(sessionId);
116
+ if (session) {
117
+ session.transport.close().catch((error) => {
118
+ logger.error({
119
+ sessionId,
120
+ error
121
+ }, "Error closing stale transport");
122
+ });
123
+ this.remove(sessionId);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ async closeAll() {
129
+ logger.info("Shutting down server...");
130
+ if (this.cleanupInterval) clearInterval(this.cleanupInterval);
131
+ const closePromises = [];
132
+ for (const [sessionId, session] of this.sessions.entries()) {
133
+ logger.debug({ sessionId }, "Closing session");
134
+ closePromises.push(session.transport.close().catch((error) => {
135
+ logger.error({
136
+ sessionId,
137
+ error
138
+ }, "Error closing transport");
139
+ }));
140
+ this.remove(sessionId);
141
+ }
142
+ await Promise.all(closePromises);
143
+ logger.info("Server shutdown complete");
144
+ }
145
+ };
146
+ function extractApiToken(req) {
147
+ const authHeader = req.headers[HEADERS.AUTHORIZATION];
148
+ if (authHeader?.startsWith(BEARER_PREFIX)) return authHeader.slice(7);
149
+ const customHeader = req.headers[HEADERS.X_SHORTCUT_API_TOKEN];
150
+ if (typeof customHeader === "string") return customHeader;
151
+ return null;
152
+ }
153
+ /**
154
+ * Validates the API token by attempting to fetch current user info.
155
+ * This ensures the token is valid before creating a session.
156
+ */
157
+ async function validateApiToken(token) {
158
+ try {
159
+ const client = new ShortcutClient(token);
160
+ await client.getCurrentMemberInfo();
161
+ return true;
162
+ } catch (error) {
163
+ logger.debug({ error: error instanceof Error ? error.message : error }, "API token validation failed");
164
+ return false;
165
+ }
166
+ }
167
+ function sendUnauthorizedError(res, message) {
168
+ res.status(401).json({
169
+ jsonrpc: "2.0",
170
+ error: {
171
+ ...JSON_RPC_ERRORS.UNAUTHORIZED,
172
+ message: message || "API token required. Provide via Authorization: Bearer <token> or X-Shortcut-API-Token: <token>"
173
+ },
174
+ id: null
175
+ });
176
+ }
177
+ function sendInvalidTokenError(res, requestId) {
178
+ res.status(401).json({
179
+ jsonrpc: "2.0",
180
+ error: {
181
+ ...JSON_RPC_ERRORS.INVALID_TOKEN,
182
+ message: "Invalid or expired API token. Please check your credentials."
183
+ },
184
+ id: requestId || null
185
+ });
186
+ }
187
+ function sendSessionNotFoundError(res, sessionId, requestId) {
188
+ logger.warn({ sessionId }, "Session not found - may have expired or server restarted");
189
+ res.status(404).json({
190
+ jsonrpc: "2.0",
191
+ error: {
192
+ ...JSON_RPC_ERRORS.SESSION_NOT_FOUND,
193
+ message: "Session not found or expired. Please re-initialize the connection."
194
+ },
195
+ id: requestId || null
196
+ });
197
+ }
198
+ function sendBadRequestError(res, message, requestId) {
199
+ res.status(400).json({
200
+ jsonrpc: "2.0",
201
+ error: {
202
+ ...JSON_RPC_ERRORS.BAD_REQUEST,
203
+ message
204
+ },
205
+ id: requestId || null
206
+ });
207
+ }
208
+ function sendInternalError(res, requestId) {
209
+ if (!res.headersSent) res.status(500).json({
210
+ jsonrpc: "2.0",
211
+ error: JSON_RPC_ERRORS.INTERNAL_ERROR,
212
+ id: requestId || null
213
+ });
214
+ }
215
+ function createServerInstance(apiToken, config) {
216
+ const server = new CustomMcpServer({
217
+ readonly: config.isReadonly,
218
+ tools: config.enabledTools
219
+ });
220
+ const client = new ShortcutClientWrapper(new ShortcutClient(apiToken));
221
+ UserTools.create(client, server);
222
+ StoryTools.create(client, server);
223
+ IterationTools.create(client, server);
224
+ EpicTools.create(client, server);
225
+ ObjectiveTools.create(client, server);
226
+ TeamTools.create(client, server);
227
+ WorkflowTools.create(client, server);
228
+ DocumentTools.create(client, server);
229
+ return server;
230
+ }
231
+ async function createTransport(apiToken, config, sessionManager) {
232
+ let transport = null;
233
+ transport = new StreamableHTTPServerTransport({
234
+ sessionIdGenerator: () => randomUUID(),
235
+ onsessioninitialized: (sid) => {
236
+ if (transport) sessionManager.add(sid, transport, apiToken);
237
+ }
238
+ });
239
+ transport.onclose = () => {
240
+ if (transport) {
241
+ const sid = transport.sessionId;
242
+ if (sid && sessionManager.has(sid)) sessionManager.remove(sid);
243
+ }
244
+ };
245
+ const server = createServerInstance(apiToken, config);
246
+ await server.connect(transport);
247
+ return transport;
248
+ }
249
+ async function handleMcpPost(req, res, sessionManager, config) {
250
+ const sessionId = req.headers[HEADERS.MCP_SESSION_ID];
251
+ const apiToken = extractApiToken(req);
252
+ const requestId = req.body?.id;
253
+ const reqLogger = logger.child({
254
+ sessionId: sessionId || "new",
255
+ method: "POST"
256
+ });
257
+ reqLogger.debug({ hasToken: !!apiToken }, "Received POST request");
258
+ try {
259
+ if (sessionId && sessionManager.has(sessionId)) {
260
+ if (!apiToken) {
261
+ sendUnauthorizedError(res);
262
+ return;
263
+ }
264
+ if (!sessionManager.validateToken(sessionId, apiToken)) {
265
+ reqLogger.warn("Token mismatch for session");
266
+ sendUnauthorizedError(res, "API token does not match the session");
267
+ return;
268
+ }
269
+ const session = sessionManager.get(sessionId);
270
+ await session.transport.handleRequest(req, res, req.body);
271
+ return;
272
+ }
273
+ if (isInitializeRequest(req.body)) {
274
+ if (!apiToken) {
275
+ sendUnauthorizedError(res);
276
+ return;
277
+ }
278
+ reqLogger.info("Validating API token");
279
+ const isValid = await validateApiToken(apiToken);
280
+ if (!isValid) {
281
+ reqLogger.warn("API token validation failed");
282
+ sendInvalidTokenError(res, requestId);
283
+ return;
284
+ }
285
+ reqLogger.info("API token validated, creating session");
286
+ const transport = await createTransport(apiToken, config, sessionManager);
287
+ await transport.handleRequest(req, res, req.body);
288
+ return;
289
+ }
290
+ if (sessionId && !sessionManager.has(sessionId)) {
291
+ sendSessionNotFoundError(res, sessionId, requestId);
292
+ return;
293
+ }
294
+ sendBadRequestError(res, "No session ID provided for non-initialization request", requestId);
295
+ } catch (error) {
296
+ reqLogger.error({ error }, "Error handling MCP POST request");
297
+ sendInternalError(res, requestId);
298
+ }
299
+ }
300
+ async function handleMcpGet(req, res, sessionManager) {
301
+ const sessionId = req.headers[HEADERS.MCP_SESSION_ID];
302
+ const apiToken = extractApiToken(req);
303
+ const reqLogger = logger.child({
304
+ sessionId,
305
+ method: "GET"
306
+ });
307
+ if (!sessionId || !sessionManager.has(sessionId)) {
308
+ res.status(400).send("Invalid or missing session ID");
309
+ return;
310
+ }
311
+ if (!apiToken) {
312
+ sendUnauthorizedError(res);
313
+ return;
314
+ }
315
+ if (!sessionManager.validateToken(sessionId, apiToken)) {
316
+ reqLogger.warn("Token mismatch for GET request");
317
+ sendUnauthorizedError(res, "API token does not match the session");
318
+ return;
319
+ }
320
+ const lastEventId = req.headers[HEADERS.LAST_EVENT_ID];
321
+ if (lastEventId) reqLogger.info({ lastEventId }, "Client reconnecting with Last-Event-ID");
322
+ else reqLogger.info("Establishing SSE stream");
323
+ try {
324
+ const session = sessionManager.get(sessionId);
325
+ await session.transport.handleRequest(req, res);
326
+ } catch (error) {
327
+ reqLogger.error({ error }, "Error handling MCP GET request");
328
+ if (!res.headersSent) res.status(500).send("Internal server error");
329
+ }
330
+ }
331
+ async function handleMcpDelete(req, res, sessionManager) {
332
+ const sessionId = req.headers[HEADERS.MCP_SESSION_ID];
333
+ const apiToken = extractApiToken(req);
334
+ const reqLogger = logger.child({
335
+ sessionId,
336
+ method: "DELETE"
337
+ });
338
+ if (!sessionId || !sessionManager.has(sessionId)) {
339
+ res.status(400).send("Invalid or missing session ID");
340
+ return;
341
+ }
342
+ if (!apiToken) {
343
+ sendUnauthorizedError(res);
344
+ return;
345
+ }
346
+ if (!sessionManager.validateToken(sessionId, apiToken)) {
347
+ reqLogger.warn("Token mismatch for DELETE request");
348
+ sendUnauthorizedError(res, "API token does not match the session");
349
+ return;
350
+ }
351
+ reqLogger.info("Terminating session");
352
+ try {
353
+ const session = sessionManager.get(sessionId);
354
+ await session.transport.handleRequest(req, res);
355
+ } catch (error) {
356
+ reqLogger.error({ error }, "Error handling session termination");
357
+ if (!res.headersSent) res.status(500).send("Error processing session termination");
358
+ }
359
+ }
360
+ function corsMiddleware(req, res, next) {
361
+ res.header("Access-Control-Allow-Origin", "*");
362
+ res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
363
+ res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Shortcut-API-Token, Mcp-Session-Id, Last-Event-Id");
364
+ res.header("Access-Control-Expose-Headers", "Mcp-Session-Id");
365
+ if (req.method === "OPTIONS") {
366
+ res.sendStatus(204);
367
+ return;
368
+ }
369
+ next();
370
+ }
371
+ /**
372
+ * Request logging middleware for debugging and audit purposes
373
+ */
374
+ function loggingMiddleware(req, _res, next) {
375
+ const sessionId = req.headers[HEADERS.MCP_SESSION_ID];
376
+ const hasToken = !!extractApiToken(req);
377
+ logger.debug({
378
+ method: req.method,
379
+ path: req.path,
380
+ sessionId: sessionId || "none",
381
+ hasToken
382
+ }, "Incoming request");
383
+ next();
384
+ }
385
+ async function startServer() {
386
+ const config = loadConfig();
387
+ const sessionManager = new SessionManager(config.sessionTimeoutMs);
388
+ const app = express();
389
+ app.use(express.json());
390
+ app.use(corsMiddleware);
391
+ app.use(loggingMiddleware);
392
+ app.get("/health", (_req, res) => {
393
+ res.json({
394
+ status: "ok",
395
+ service: "shortcut-mcp-server",
396
+ transport: "streamable-http",
397
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
398
+ version: "2025-06-18"
399
+ });
400
+ });
401
+ app.post("/mcp", (req, res) => handleMcpPost(req, res, sessionManager, config));
402
+ app.get("/mcp", (req, res) => handleMcpGet(req, res, sessionManager));
403
+ app.delete("/mcp", (req, res) => handleMcpDelete(req, res, sessionManager));
404
+ app.listen(config.port, () => {
405
+ logger.info({
406
+ port: config.port,
407
+ readonly: config.isReadonly,
408
+ sessionTTL: `${config.sessionTimeoutMs / 1e3 / 60}m`,
409
+ enabledTools: config.enabledTools.length > 0 ? config.enabledTools : "all",
410
+ mcpSpec: "2025-06-18"
411
+ }, "Shortcut MCP Server (Streamable HTTP) started");
412
+ logger.info(`Server URL: http://localhost:${config.port}`);
413
+ logger.info(`Health check: http://localhost:${config.port}/health`);
414
+ logger.info(`MCP endpoint: http://localhost:${config.port}/mcp`);
415
+ });
416
+ process.on("SIGINT", async () => {
417
+ await sessionManager.closeAll();
418
+ process.exit(0);
419
+ });
420
+ process.on("SIGTERM", async () => {
421
+ await sessionManager.closeAll();
422
+ process.exit(0);
423
+ });
424
+ }
425
+ startServer().catch((error) => {
426
+ logger.fatal({ error }, "Fatal error starting server");
427
+ process.exit(1);
428
+ });
429
+
430
+ //#endregion