@andypai/agent-kanban 0.1.0 → 0.3.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 +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +493 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +8 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-DEnUD0fq.css +0 -1
- package/ui/dist/assets/index-DMRjw1nI.js +0 -40
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server'
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ErrorCode as JsonRpcErrorCode,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
McpError,
|
|
8
|
+
isInitializeRequest,
|
|
9
|
+
type CallToolResult,
|
|
10
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
11
|
+
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
|
|
12
|
+
import type {
|
|
13
|
+
JsonSchemaValidatorResult,
|
|
14
|
+
JsonSchemaType,
|
|
15
|
+
} from '@modelcontextprotocol/sdk/validation'
|
|
16
|
+
import type { TrackerCore } from './core.ts'
|
|
17
|
+
import { TrackerMcpError, toMcpError, toTrackerMcpError, trackerMcpJsonRpcCode } from './errors.ts'
|
|
18
|
+
import type { TrackerMcpAuthResolver, TrackerMcpServer, TrackerMcpTool } from './types.ts'
|
|
19
|
+
|
|
20
|
+
const EMPTY_OBJECT_SCHEMA = {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {},
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
} as JsonSchemaType
|
|
25
|
+
|
|
26
|
+
interface RegisteredTrackerTool<TScope> {
|
|
27
|
+
tool: TrackerMcpTool<TScope>
|
|
28
|
+
validateInput(input: unknown): JsonSchemaValidatorResult<unknown>
|
|
29
|
+
validateOutput?(output: unknown): JsonSchemaValidatorResult<unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SessionEntry<TScope> {
|
|
33
|
+
server: Server
|
|
34
|
+
transport: WebStandardStreamableHTTPServerTransport
|
|
35
|
+
sessionId?: string
|
|
36
|
+
tools: Map<string, RegisteredTrackerTool<TScope>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ticketIdFromArgs(args: unknown): string | undefined {
|
|
44
|
+
if (!isRecord(args) || typeof args.ticketId !== 'string') return undefined
|
|
45
|
+
return args.ticketId
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function serializeToolResult(result: unknown): string {
|
|
49
|
+
if (typeof result === 'string') return result
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(result ?? null)
|
|
52
|
+
} catch {
|
|
53
|
+
return String(result)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toCallToolResult(result: unknown): CallToolResult {
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: 'text', text: serializeToolResult(result) }],
|
|
60
|
+
structuredContent: { result: result ?? null },
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function httpJsonRpcError(status: number, code: number, message: string): Response {
|
|
65
|
+
return Response.json(
|
|
66
|
+
{
|
|
67
|
+
jsonrpc: '2.0',
|
|
68
|
+
error: { code, message },
|
|
69
|
+
id: null,
|
|
70
|
+
},
|
|
71
|
+
{ status },
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ticketIdSchema(extra: Record<string, JsonSchemaType> = {}): JsonSchemaType {
|
|
76
|
+
return {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: { ticketId: { type: 'string' }, ...extra },
|
|
79
|
+
required: ['ticketId', ...Object.keys(extra)],
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
} as JsonSchemaType
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function defaultTools<TScope>(core: TrackerCore<TScope>): TrackerMcpTool<TScope>[] {
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
name: 'getTicket',
|
|
88
|
+
description: 'Fetch a ticket by id.',
|
|
89
|
+
inputSchema: ticketIdSchema(),
|
|
90
|
+
handler: ({ scope, args }) =>
|
|
91
|
+
core.handlers.getTicket({ scope, ...(args as { ticketId: string }) }),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'listComments',
|
|
95
|
+
description: 'List comments for a ticket.',
|
|
96
|
+
inputSchema: ticketIdSchema(),
|
|
97
|
+
handler: ({ scope, args }) =>
|
|
98
|
+
core.handlers.listComments({ scope, ...(args as { ticketId: string }) }),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'getBoard',
|
|
102
|
+
description: 'Fetch the current board state.',
|
|
103
|
+
inputSchema: EMPTY_OBJECT_SCHEMA,
|
|
104
|
+
handler: ({ scope }) => core.handlers.getBoard({ scope }),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'postComment',
|
|
108
|
+
description: 'Create a comment on a ticket.',
|
|
109
|
+
inputSchema: ticketIdSchema({ body: { type: 'string' } as JsonSchemaType }),
|
|
110
|
+
handler: ({ scope, args }) =>
|
|
111
|
+
core.handlers.postComment({ scope, ...(args as { ticketId: string; body: string }) }),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'updateComment',
|
|
115
|
+
description: 'Update an existing ticket comment.',
|
|
116
|
+
inputSchema: ticketIdSchema({
|
|
117
|
+
commentId: { type: 'string' } as JsonSchemaType,
|
|
118
|
+
body: { type: 'string' } as JsonSchemaType,
|
|
119
|
+
}),
|
|
120
|
+
handler: ({ scope, args }) =>
|
|
121
|
+
core.handlers.updateComment({
|
|
122
|
+
scope,
|
|
123
|
+
...(args as { ticketId: string; commentId: string; body: string }),
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'moveTicket',
|
|
128
|
+
description: 'Move a ticket to another column.',
|
|
129
|
+
inputSchema: ticketIdSchema({ column: { type: 'string' } as JsonSchemaType }),
|
|
130
|
+
handler: ({ scope, args }) =>
|
|
131
|
+
core.handlers.moveTicket({
|
|
132
|
+
scope,
|
|
133
|
+
...(args as { ticketId: string; column: string }),
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function registerTools<TScope>(
|
|
140
|
+
tools: TrackerMcpTool<TScope>[],
|
|
141
|
+
): Map<string, RegisteredTrackerTool<TScope>> {
|
|
142
|
+
const validatorProvider = new AjvJsonSchemaValidator()
|
|
143
|
+
const registry = new Map<string, RegisteredTrackerTool<TScope>>()
|
|
144
|
+
|
|
145
|
+
for (const tool of tools) {
|
|
146
|
+
if (registry.has(tool.name)) {
|
|
147
|
+
throw new Error(`Duplicate tracker MCP tool name '${tool.name}'`)
|
|
148
|
+
}
|
|
149
|
+
registry.set(tool.name, {
|
|
150
|
+
tool,
|
|
151
|
+
validateInput: validatorProvider.getValidator(tool.inputSchema),
|
|
152
|
+
validateOutput: tool.outputSchema
|
|
153
|
+
? validatorProvider.getValidator(tool.outputSchema)
|
|
154
|
+
: undefined,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return registry
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isInitializePayload(body: unknown): boolean {
|
|
162
|
+
return Array.isArray(body)
|
|
163
|
+
? body.some((message) => isInitializeRequest(message))
|
|
164
|
+
: isInitializeRequest(body)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createSessionServer<TScope>(
|
|
168
|
+
core: TrackerCore<TScope>,
|
|
169
|
+
entry: SessionEntry<TScope>,
|
|
170
|
+
): Server {
|
|
171
|
+
const server = new Server(
|
|
172
|
+
{ name: 'agent-kanban-tracker-mcp', version: '1.0.0' },
|
|
173
|
+
{ capabilities: { tools: {} } },
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async function throwReportedToolError(input: {
|
|
177
|
+
scope: TScope | null
|
|
178
|
+
tool: string
|
|
179
|
+
startedAt: number
|
|
180
|
+
args?: unknown
|
|
181
|
+
error: TrackerMcpError
|
|
182
|
+
}): Promise<never> {
|
|
183
|
+
await core.notifyToolError({
|
|
184
|
+
scope: input.scope,
|
|
185
|
+
tool: input.tool,
|
|
186
|
+
ticketId: ticketIdFromArgs(input.args),
|
|
187
|
+
durationMs: Date.now() - input.startedAt,
|
|
188
|
+
error: input.error,
|
|
189
|
+
})
|
|
190
|
+
throw toMcpError(input.error)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
194
|
+
tools: Array.from(entry.tools.values()).map(({ tool }) => ({
|
|
195
|
+
name: tool.name,
|
|
196
|
+
description: tool.description,
|
|
197
|
+
inputSchema: tool.inputSchema,
|
|
198
|
+
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
199
|
+
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
200
|
+
})),
|
|
201
|
+
}))
|
|
202
|
+
|
|
203
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
204
|
+
const registered = entry.tools.get(request.params.name)
|
|
205
|
+
const rawScope = extra.authInfo?.extra?.['scope']
|
|
206
|
+
const rawRequest = extra.authInfo?.extra?.['request']
|
|
207
|
+
const scope = rawScope as TScope | null
|
|
208
|
+
const originalRequest = rawRequest instanceof Request ? rawRequest : null
|
|
209
|
+
const startedAt = Date.now()
|
|
210
|
+
|
|
211
|
+
if (!registered) {
|
|
212
|
+
const error = new TrackerMcpError({
|
|
213
|
+
code: 'validation_failed',
|
|
214
|
+
publicMessage: `Unknown tool '${request.params.name}'`,
|
|
215
|
+
})
|
|
216
|
+
return throwReportedToolError({
|
|
217
|
+
scope,
|
|
218
|
+
tool: request.params.name,
|
|
219
|
+
startedAt,
|
|
220
|
+
args: request.params.arguments,
|
|
221
|
+
error,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!scope || !originalRequest) {
|
|
226
|
+
throw new McpError(JsonRpcErrorCode.InternalError, 'Missing authenticated request context')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const tool = registered
|
|
230
|
+
const validated = tool.validateInput(request.params.arguments ?? {})
|
|
231
|
+
if (!validated.valid) {
|
|
232
|
+
const error = new TrackerMcpError({
|
|
233
|
+
code: 'validation_failed',
|
|
234
|
+
publicMessage: validated.errorMessage ?? `Invalid arguments for '${tool.tool.name}'`,
|
|
235
|
+
})
|
|
236
|
+
return throwReportedToolError({
|
|
237
|
+
scope,
|
|
238
|
+
tool: tool.tool.name,
|
|
239
|
+
startedAt,
|
|
240
|
+
args: request.params.arguments,
|
|
241
|
+
error,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let result: unknown
|
|
246
|
+
try {
|
|
247
|
+
result = await tool.tool.handler({
|
|
248
|
+
scope,
|
|
249
|
+
args: validated.data as Record<string, unknown>,
|
|
250
|
+
request: originalRequest,
|
|
251
|
+
})
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw toMcpError(error)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (tool.validateOutput) {
|
|
257
|
+
const validatedOutput = tool.validateOutput(result)
|
|
258
|
+
if (!validatedOutput.valid) {
|
|
259
|
+
const error = new TrackerMcpError({
|
|
260
|
+
code: 'validation_failed',
|
|
261
|
+
publicMessage: validatedOutput.errorMessage ?? `Invalid output for '${tool.tool.name}'`,
|
|
262
|
+
})
|
|
263
|
+
return throwReportedToolError({
|
|
264
|
+
scope,
|
|
265
|
+
tool: tool.tool.name,
|
|
266
|
+
startedAt,
|
|
267
|
+
args: request.params.arguments,
|
|
268
|
+
error,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return toCallToolResult(result)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
return server
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function createTrackerMcpServer<TScope>(input: {
|
|
280
|
+
core: TrackerCore<TScope>
|
|
281
|
+
auth: TrackerMcpAuthResolver<TScope>
|
|
282
|
+
tools?: 'default' | TrackerMcpTool<TScope>[]
|
|
283
|
+
}): TrackerMcpServer {
|
|
284
|
+
const tools =
|
|
285
|
+
input.tools === 'default' || input.tools === undefined ? defaultTools(input.core) : input.tools
|
|
286
|
+
|
|
287
|
+
const toolRegistry = registerTools(tools)
|
|
288
|
+
const sessions = new Map<string, SessionEntry<TScope>>()
|
|
289
|
+
const inflight = new Set<Promise<unknown>>()
|
|
290
|
+
let closed = false
|
|
291
|
+
|
|
292
|
+
async function trackInflight<T>(promise: Promise<T>): Promise<T> {
|
|
293
|
+
inflight.add(promise)
|
|
294
|
+
try {
|
|
295
|
+
return await promise
|
|
296
|
+
} finally {
|
|
297
|
+
inflight.delete(promise)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function parsePostBody(request: Request): Promise<unknown> {
|
|
302
|
+
try {
|
|
303
|
+
return await request.json()
|
|
304
|
+
} catch (error) {
|
|
305
|
+
throw new TrackerMcpError({
|
|
306
|
+
code: 'validation_failed',
|
|
307
|
+
publicMessage: 'Invalid JSON request body',
|
|
308
|
+
cause: error,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function authenticate(request: Request): Promise<{
|
|
314
|
+
scope: TScope
|
|
315
|
+
authInfo: {
|
|
316
|
+
token: string
|
|
317
|
+
clientId: string
|
|
318
|
+
scopes: string[]
|
|
319
|
+
extra: Record<string, unknown>
|
|
320
|
+
}
|
|
321
|
+
}> {
|
|
322
|
+
try {
|
|
323
|
+
const scope = await input.auth({
|
|
324
|
+
request,
|
|
325
|
+
url: new URL(request.url),
|
|
326
|
+
headers: request.headers,
|
|
327
|
+
})
|
|
328
|
+
return {
|
|
329
|
+
scope,
|
|
330
|
+
authInfo: {
|
|
331
|
+
token: 'tracker-mcp',
|
|
332
|
+
clientId: 'tracker-mcp',
|
|
333
|
+
scopes: [],
|
|
334
|
+
extra: {
|
|
335
|
+
scope,
|
|
336
|
+
request,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error instanceof TrackerMcpError && error.code === 'auth_failed') throw error
|
|
342
|
+
throw new TrackerMcpError({
|
|
343
|
+
code: 'auth_failed',
|
|
344
|
+
publicMessage:
|
|
345
|
+
error instanceof TrackerMcpError
|
|
346
|
+
? (error.publicMessage ?? error.message)
|
|
347
|
+
: 'Authentication failed',
|
|
348
|
+
cause: error,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function createSession(): Promise<SessionEntry<TScope>> {
|
|
354
|
+
const entry = {} as SessionEntry<TScope>
|
|
355
|
+
entry.tools = toolRegistry
|
|
356
|
+
entry.transport = new WebStandardStreamableHTTPServerTransport({
|
|
357
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
358
|
+
onsessioninitialized: (sessionId) => {
|
|
359
|
+
entry.sessionId = sessionId
|
|
360
|
+
sessions.set(sessionId, entry)
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
entry.server = createSessionServer(input.core, entry)
|
|
364
|
+
entry.transport.onclose = () => {
|
|
365
|
+
if (entry.sessionId) sessions.delete(entry.sessionId)
|
|
366
|
+
}
|
|
367
|
+
await entry.server.connect(entry.transport)
|
|
368
|
+
return entry
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function handleAuthenticatedRequest(request: Request): Promise<Response> {
|
|
372
|
+
const method = request.method.toUpperCase()
|
|
373
|
+
const { authInfo } = await authenticate(request)
|
|
374
|
+
const sessionId = request.headers.get('mcp-session-id')
|
|
375
|
+
|
|
376
|
+
if (method === 'POST') {
|
|
377
|
+
const parsedBody = await parsePostBody(request)
|
|
378
|
+
|
|
379
|
+
if (sessionId) {
|
|
380
|
+
const existing = sessions.get(sessionId)
|
|
381
|
+
if (!existing) {
|
|
382
|
+
return httpJsonRpcError(
|
|
383
|
+
404,
|
|
384
|
+
trackerMcpJsonRpcCode('validation_failed'),
|
|
385
|
+
'Session not found',
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
return existing.transport.handleRequest(request, { parsedBody, authInfo })
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!isInitializePayload(parsedBody)) {
|
|
392
|
+
return httpJsonRpcError(
|
|
393
|
+
400,
|
|
394
|
+
trackerMcpJsonRpcCode('validation_failed'),
|
|
395
|
+
'Initialization required before calling tools',
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const entry = await createSession()
|
|
400
|
+
return entry.transport.handleRequest(request, { parsedBody, authInfo })
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (method === 'GET' || method === 'DELETE') {
|
|
404
|
+
if (!sessionId) {
|
|
405
|
+
return httpJsonRpcError(
|
|
406
|
+
400,
|
|
407
|
+
trackerMcpJsonRpcCode('validation_failed'),
|
|
408
|
+
'Session ID header is required',
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
const existing = sessions.get(sessionId)
|
|
412
|
+
if (!existing) {
|
|
413
|
+
return httpJsonRpcError(
|
|
414
|
+
404,
|
|
415
|
+
trackerMcpJsonRpcCode('validation_failed'),
|
|
416
|
+
'Session not found',
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
return existing.transport.handleRequest(request, { authInfo })
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return httpJsonRpcError(
|
|
423
|
+
405,
|
|
424
|
+
JsonRpcErrorCode.InvalidRequest,
|
|
425
|
+
`Unsupported MCP HTTP method '${request.method}'`,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
async fetch(request: Request): Promise<Response> {
|
|
431
|
+
if (closed) {
|
|
432
|
+
return httpJsonRpcError(
|
|
433
|
+
503,
|
|
434
|
+
JsonRpcErrorCode.ConnectionClosed,
|
|
435
|
+
'Tracker MCP server is closed',
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const responsePromise = (async () => {
|
|
440
|
+
const authStartedAt = Date.now()
|
|
441
|
+
try {
|
|
442
|
+
return await handleAuthenticatedRequest(request)
|
|
443
|
+
} catch (error) {
|
|
444
|
+
const trackerError = toTrackerMcpError(error)
|
|
445
|
+
if (trackerError.code === 'auth_failed') {
|
|
446
|
+
await input.core.notifyAuthFailure({
|
|
447
|
+
request,
|
|
448
|
+
durationMs: Date.now() - authStartedAt,
|
|
449
|
+
error: trackerError,
|
|
450
|
+
})
|
|
451
|
+
return httpJsonRpcError(
|
|
452
|
+
401,
|
|
453
|
+
trackerMcpJsonRpcCode('auth_failed'),
|
|
454
|
+
trackerError.publicMessage ?? 'Unauthenticated',
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (trackerError.code === 'validation_failed') {
|
|
459
|
+
return httpJsonRpcError(
|
|
460
|
+
400,
|
|
461
|
+
trackerMcpJsonRpcCode('validation_failed'),
|
|
462
|
+
trackerError.publicMessage ?? trackerError.message,
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return httpJsonRpcError(
|
|
467
|
+
500,
|
|
468
|
+
trackerMcpJsonRpcCode(trackerError.code),
|
|
469
|
+
trackerError.publicMessage ?? trackerError.message,
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
})()
|
|
473
|
+
|
|
474
|
+
return trackInflight(responsePromise)
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
async selfPing(): Promise<void> {
|
|
478
|
+
if (closed) {
|
|
479
|
+
throw new Error('Tracker MCP server is closed')
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
async close(signal?: globalThis.AbortSignal): Promise<void> {
|
|
484
|
+
closed = true
|
|
485
|
+
|
|
486
|
+
const closeAll = (async () => {
|
|
487
|
+
const entries = Array.from(sessions.values())
|
|
488
|
+
await Promise.allSettled(entries.map((entry) => entry.server.close()))
|
|
489
|
+
await Promise.allSettled(Array.from(inflight))
|
|
490
|
+
})()
|
|
491
|
+
|
|
492
|
+
if (!signal) {
|
|
493
|
+
await closeAll
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
498
|
+
if (signal.aborted) {
|
|
499
|
+
reject(signal.reason ?? new Error('Tracker MCP close aborted'))
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
signal.addEventListener(
|
|
503
|
+
'abort',
|
|
504
|
+
() => reject(signal.reason ?? new Error('Tracker MCP close aborted')),
|
|
505
|
+
{ once: true },
|
|
506
|
+
)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
await Promise.race([closeAll, abortPromise])
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
|
2
|
+
import type { JsonSchemaType } from '@modelcontextprotocol/sdk/validation'
|
|
3
|
+
import type { TaskComment } from '../types.ts'
|
|
4
|
+
import type { TrackerMcpError, TrackerMcpErrorCode } from './errors.ts'
|
|
5
|
+
|
|
6
|
+
export type TrackerMcpAuthResolver<TScope> = (ctx: {
|
|
7
|
+
request: Request
|
|
8
|
+
url: URL
|
|
9
|
+
headers: globalThis.Headers
|
|
10
|
+
}) => Promise<TScope>
|
|
11
|
+
|
|
12
|
+
export interface TrackerMcpPolicy<TScope> {
|
|
13
|
+
canReadTicket(scope: TScope, ticketId: string): Promise<void> | void
|
|
14
|
+
canPostComment(scope: TScope, ticketId: string, body: string): Promise<void> | void
|
|
15
|
+
canUpdateComment(
|
|
16
|
+
scope: TScope,
|
|
17
|
+
ticketId: string,
|
|
18
|
+
comment: TaskComment,
|
|
19
|
+
body: string,
|
|
20
|
+
): Promise<void> | void
|
|
21
|
+
canMoveTicket(scope: TScope, ticketId: string, destinationColumn: string): Promise<void> | void
|
|
22
|
+
filterComment?(scope: TScope, comment: TaskComment): Promise<boolean> | boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TrackerMcpHooks<TScope> {
|
|
26
|
+
onAuthFailure?(event: {
|
|
27
|
+
request: Request
|
|
28
|
+
durationMs: number
|
|
29
|
+
errorCode: 'auth_failed'
|
|
30
|
+
error: TrackerMcpError
|
|
31
|
+
}): Promise<void> | void
|
|
32
|
+
|
|
33
|
+
onToolStart?(event: { scope: TScope; tool: string; ticketId?: string }): Promise<void> | void
|
|
34
|
+
|
|
35
|
+
onToolResult?(event: {
|
|
36
|
+
scope: TScope
|
|
37
|
+
tool: string
|
|
38
|
+
ticketId?: string
|
|
39
|
+
durationMs: number
|
|
40
|
+
result?: Record<string, unknown>
|
|
41
|
+
}): Promise<void> | void
|
|
42
|
+
|
|
43
|
+
onToolError?(event: {
|
|
44
|
+
scope: TScope | null
|
|
45
|
+
tool: string
|
|
46
|
+
ticketId?: string
|
|
47
|
+
durationMs: number
|
|
48
|
+
errorCode: TrackerMcpErrorCode
|
|
49
|
+
error: TrackerMcpError
|
|
50
|
+
}): Promise<void> | void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TrackerMcpToolHandlerContext<TScope, TArgs = Record<string, unknown>> {
|
|
54
|
+
scope: TScope
|
|
55
|
+
args: TArgs
|
|
56
|
+
request?: Request
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TrackerMcpTool<TScope, TArgs = Record<string, unknown>, TResult = unknown> {
|
|
60
|
+
name: string
|
|
61
|
+
description?: string
|
|
62
|
+
inputSchema: JsonSchemaType
|
|
63
|
+
annotations?: ToolAnnotations
|
|
64
|
+
outputSchema?: JsonSchemaType
|
|
65
|
+
handler(input: TrackerMcpToolHandlerContext<TScope, TArgs>): Promise<TResult> | TResult
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TrackerMcpServer {
|
|
69
|
+
fetch(req: Request): Promise<Response>
|
|
70
|
+
selfPing(): Promise<void>
|
|
71
|
+
close(signal?: globalThis.AbortSignal): Promise<void>
|
|
72
|
+
}
|
|
@@ -5,6 +5,7 @@ export const LOCAL_CAPABILITIES: ProviderCapabilities = {
|
|
|
5
5
|
taskUpdate: true,
|
|
6
6
|
taskMove: true,
|
|
7
7
|
taskDelete: true,
|
|
8
|
+
comment: true,
|
|
8
9
|
activity: true,
|
|
9
10
|
metrics: true,
|
|
10
11
|
columnCrud: true,
|
|
@@ -17,6 +18,20 @@ export const LINEAR_CAPABILITIES: ProviderCapabilities = {
|
|
|
17
18
|
taskUpdate: true,
|
|
18
19
|
taskMove: true,
|
|
19
20
|
taskDelete: false,
|
|
21
|
+
comment: true,
|
|
22
|
+
activity: false,
|
|
23
|
+
metrics: false,
|
|
24
|
+
columnCrud: false,
|
|
25
|
+
bulk: false,
|
|
26
|
+
configEdit: false,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const JIRA_CAPABILITIES: ProviderCapabilities = {
|
|
30
|
+
taskCreate: true,
|
|
31
|
+
taskUpdate: true,
|
|
32
|
+
taskMove: true,
|
|
33
|
+
taskDelete: false,
|
|
34
|
+
comment: true,
|
|
20
35
|
activity: false,
|
|
21
36
|
metrics: false,
|
|
22
37
|
columnCrud: false,
|
package/src/providers/index.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
2
|
import { getDbPath, initSchema, seedDefaultColumns } from '../db.ts'
|
|
3
3
|
import { providerNotConfigured } from './errors.ts'
|
|
4
|
+
import { JiraProvider } from './jira.ts'
|
|
4
5
|
import { LinearProvider } from './linear.ts'
|
|
5
6
|
import { LocalProvider } from './local.ts'
|
|
6
7
|
import type { KanbanProvider } from './types.ts'
|
|
7
8
|
|
|
8
9
|
export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvider {
|
|
9
|
-
const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear'
|
|
10
|
+
const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear' | 'jira'
|
|
11
|
+
|
|
10
12
|
if (providerType === 'linear') {
|
|
11
13
|
const apiKey = process.env['LINEAR_API_KEY']
|
|
12
14
|
const teamId = process.env['LINEAR_TEAM_ID']
|
|
@@ -18,6 +20,34 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
|
|
|
18
20
|
return new LinearProvider(db, teamId!, apiKey!)
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
if (providerType === 'jira') {
|
|
24
|
+
const baseUrl = process.env['JIRA_BASE_URL']
|
|
25
|
+
const email = process.env['JIRA_EMAIL']
|
|
26
|
+
const apiToken = process.env['JIRA_API_TOKEN']
|
|
27
|
+
const projectKey = process.env['JIRA_PROJECT_KEY']
|
|
28
|
+
const missing: string[] = []
|
|
29
|
+
if (!baseUrl) missing.push('JIRA_BASE_URL')
|
|
30
|
+
if (!email) missing.push('JIRA_EMAIL')
|
|
31
|
+
if (!apiToken) missing.push('JIRA_API_TOKEN')
|
|
32
|
+
if (!projectKey) missing.push('JIRA_PROJECT_KEY')
|
|
33
|
+
if (missing.length > 0) {
|
|
34
|
+
providerNotConfigured(
|
|
35
|
+
`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required when KANBAN_PROVIDER=jira`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
const boardIdRaw = process.env['JIRA_BOARD_ID']
|
|
39
|
+
const boardId = boardIdRaw ? Number.parseInt(boardIdRaw, 10) : undefined
|
|
40
|
+
const defaultIssueType = process.env['JIRA_ISSUE_TYPE'] ?? 'Task'
|
|
41
|
+
return new JiraProvider(db, {
|
|
42
|
+
baseUrl: baseUrl!,
|
|
43
|
+
email: email!,
|
|
44
|
+
apiToken: apiToken!,
|
|
45
|
+
projectKey: projectKey!,
|
|
46
|
+
boardId: Number.isFinite(boardId) ? boardId : undefined,
|
|
47
|
+
defaultIssueType,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
initSchema(db)
|
|
22
52
|
seedDefaultColumns(db)
|
|
23
53
|
return new LocalProvider(db, dbPath)
|