@foundation0/git 1.2.4 → 1.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/mcp/README.md +51 -1
- package/mcp/src/client.ts +10 -1
- package/mcp/src/server.ts +621 -39
- package/package.json +1 -1
- package/src/actions-api.ts +150 -4
- package/src/ci-api.ts +544 -0
- package/src/git-service-api.ts +87 -16
- package/src/index.ts +1 -0
- package/src/issue-dependencies.ts +80 -16
- package/src/platform/gitea-adapter.ts +16 -4
package/mcp/src/server.ts
CHANGED
|
@@ -16,6 +16,8 @@ type ToolInvocationPayload = {
|
|
|
16
16
|
[key: string]: unknown
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
type McpToolOutputFormat = 'terse' | 'debug'
|
|
20
|
+
|
|
19
21
|
type BatchToolCall = {
|
|
20
22
|
tool: string
|
|
21
23
|
args?: unknown[]
|
|
@@ -32,8 +34,7 @@ type BatchResult = {
|
|
|
32
34
|
index: number
|
|
33
35
|
tool: string
|
|
34
36
|
isError: boolean
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
+
} & (McpTerseOk | McpTerseErr)
|
|
37
38
|
|
|
38
39
|
type ToolDefinition = {
|
|
39
40
|
name: string
|
|
@@ -44,21 +45,205 @@ type ToolDefinition = {
|
|
|
44
45
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
45
46
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
46
47
|
|
|
48
|
+
const tryParseJsonObject = (value: string): unknown => {
|
|
49
|
+
const trimmed = value.trim()
|
|
50
|
+
if (!trimmed) return {}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(trimmed) as unknown
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
56
|
+
throw new Error(`Invalid args JSON: ${message}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const normalizeArgumentPayload = (payload: unknown): unknown => {
|
|
61
|
+
if (typeof payload === 'string' || payload instanceof String) {
|
|
62
|
+
const parsed = tryParseJsonObject(String(payload))
|
|
63
|
+
if (!isRecord(parsed)) {
|
|
64
|
+
const kind = Array.isArray(parsed) ? 'array' : typeof parsed
|
|
65
|
+
throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
|
|
66
|
+
}
|
|
67
|
+
return parsed
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return payload
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const toTrimmedString = (value: unknown): string => (value === null || value === undefined ? '' : String(value)).trim()
|
|
74
|
+
|
|
75
|
+
const toPositiveInteger = (value: unknown): number | null => {
|
|
76
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
77
|
+
return Math.floor(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof value !== 'string') return null
|
|
81
|
+
|
|
82
|
+
const trimmed = value.trim()
|
|
83
|
+
if (!trimmed) return null
|
|
84
|
+
const parsed = Number(trimmed)
|
|
85
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
86
|
+
return Math.floor(parsed)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const pickFirst = <T>(...candidates: Array<T | null | undefined>): T | null => {
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
if (candidate !== null && candidate !== undefined) return candidate
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pickRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
|
|
97
|
+
|
|
98
|
+
const parseOutputFormat = (value: unknown): McpToolOutputFormat | null => {
|
|
99
|
+
if (value === null || value === undefined) return null
|
|
100
|
+
const raw = toTrimmedString(value).toLowerCase()
|
|
101
|
+
if (!raw) return null
|
|
102
|
+
if (raw === 'debug') return 'debug'
|
|
103
|
+
if (raw === 'terse') return 'terse'
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const extractOutputControls = (payload: unknown): { format: McpToolOutputFormat | null } => {
|
|
108
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
109
|
+
if (!isRecord(normalized)) {
|
|
110
|
+
return { format: null }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const topLevelFormat = parseOutputFormat(normalized.format)
|
|
114
|
+
const optionsFormat = parseOutputFormat(pickRecord(normalized.options).format)
|
|
115
|
+
|
|
116
|
+
// Support either { format:"debug" } or { options:{ format:"debug" } }.
|
|
117
|
+
return { format: pickFirst(topLevelFormat, optionsFormat) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type McpTerseOk = {
|
|
121
|
+
ok: true
|
|
122
|
+
data: unknown
|
|
123
|
+
meta: {
|
|
124
|
+
status: number
|
|
125
|
+
}
|
|
126
|
+
debug?: unknown
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type McpTerseErr = {
|
|
130
|
+
ok: false
|
|
131
|
+
error: {
|
|
132
|
+
code: string
|
|
133
|
+
status?: number
|
|
134
|
+
message: string
|
|
135
|
+
details?: unknown
|
|
136
|
+
hint?: string
|
|
137
|
+
retryable: boolean
|
|
138
|
+
}
|
|
139
|
+
meta?: {
|
|
140
|
+
status?: number
|
|
141
|
+
}
|
|
142
|
+
debug?: unknown
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const httpErrorCodeForStatus = (status: number): string => {
|
|
146
|
+
if (status === 400) return 'HTTP_BAD_REQUEST'
|
|
147
|
+
if (status === 401) return 'HTTP_UNAUTHORIZED'
|
|
148
|
+
if (status === 403) return 'HTTP_FORBIDDEN'
|
|
149
|
+
if (status === 404) return 'HTTP_NOT_FOUND'
|
|
150
|
+
if (status === 409) return 'HTTP_CONFLICT'
|
|
151
|
+
if (status === 422) return 'HTTP_UNPROCESSABLE_ENTITY'
|
|
152
|
+
if (status === 429) return 'HTTP_RATE_LIMITED'
|
|
153
|
+
if (status >= 500) return 'HTTP_SERVER_ERROR'
|
|
154
|
+
if (status >= 400) return 'HTTP_ERROR'
|
|
155
|
+
return 'UNKNOWN'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const buildErrorMessage = (status: number | undefined, body: unknown): string => {
|
|
159
|
+
if (typeof body === 'string') {
|
|
160
|
+
const trimmed = body.trim()
|
|
161
|
+
if (trimmed) {
|
|
162
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0]
|
|
163
|
+
return firstLine.length > 0 ? firstLine : trimmed
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (isRecord(body) && typeof body.message === 'string' && body.message.trim()) {
|
|
168
|
+
return body.message.trim()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (typeof status === 'number') {
|
|
172
|
+
return `HTTP ${status}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return 'Request failed'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const toMcpEnvelope = (
|
|
179
|
+
result: GitServiceApiExecutionResult,
|
|
180
|
+
format: McpToolOutputFormat,
|
|
181
|
+
): { isError: boolean; envelope: McpTerseOk | McpTerseErr } => {
|
|
182
|
+
const sanitized = redactSecretsForMcpOutput(result)
|
|
183
|
+
|
|
184
|
+
if (result.ok && result.status < 400) {
|
|
185
|
+
const envelope: McpTerseOk = {
|
|
186
|
+
ok: true,
|
|
187
|
+
data: result.body,
|
|
188
|
+
meta: {
|
|
189
|
+
status: result.status,
|
|
190
|
+
},
|
|
191
|
+
...(format === 'debug' ? { debug: sanitized } : {}),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { isError: false, envelope: redactSecretsForMcpOutput(envelope) as McpTerseOk }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const status = result.status
|
|
198
|
+
const envelope: McpTerseErr = {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: {
|
|
201
|
+
code: httpErrorCodeForStatus(status),
|
|
202
|
+
status,
|
|
203
|
+
message: buildErrorMessage(status, result.body),
|
|
204
|
+
details: result.body,
|
|
205
|
+
retryable: status >= 500 || status === 429,
|
|
206
|
+
},
|
|
207
|
+
meta: {
|
|
208
|
+
status,
|
|
209
|
+
},
|
|
210
|
+
...(format === 'debug' ? { debug: sanitized } : {}),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { isError: true, envelope: redactSecretsForMcpOutput(envelope) as McpTerseErr }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const toMcpThrownErrorEnvelope = (error: unknown): McpTerseErr => ({
|
|
217
|
+
ok: false,
|
|
218
|
+
error: {
|
|
219
|
+
code: 'TOOL_ERROR',
|
|
220
|
+
message: redactSecretsInText(error instanceof Error ? error.message : String(error)),
|
|
221
|
+
retryable: false,
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
47
225
|
const normalizePayload = (payload: unknown): { args: string[]; options: Record<string, unknown> } => {
|
|
48
|
-
|
|
226
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
227
|
+
|
|
228
|
+
if (normalized === null || normalized === undefined) {
|
|
49
229
|
return {
|
|
50
230
|
args: [],
|
|
51
231
|
options: {},
|
|
52
232
|
}
|
|
53
233
|
}
|
|
54
234
|
|
|
55
|
-
|
|
56
|
-
|
|
235
|
+
if (!isRecord(normalized)) {
|
|
236
|
+
const kind = Array.isArray(normalized) ? 'array' : typeof normalized
|
|
237
|
+
throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const explicitArgs = Array.isArray(normalized.args) ? normalized.args : undefined
|
|
241
|
+
const explicitOptions = isRecord(normalized.options) ? normalized.options : undefined
|
|
57
242
|
|
|
58
243
|
const args = explicitArgs ? explicitArgs.map((entry) => String(entry)) : []
|
|
59
244
|
const options: Record<string, unknown> = explicitOptions ? { ...explicitOptions } : {}
|
|
60
245
|
|
|
61
|
-
for (const [key, value] of Object.entries(
|
|
246
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
62
247
|
if (key === 'args' || key === 'options') {
|
|
63
248
|
continue
|
|
64
249
|
}
|
|
@@ -74,6 +259,17 @@ const normalizePayload = (payload: unknown): { args: string[]; options: Record<s
|
|
|
74
259
|
}
|
|
75
260
|
}
|
|
76
261
|
|
|
262
|
+
const OMITTED_OPTION_KEYS = new Set(['format'])
|
|
263
|
+
|
|
264
|
+
const stripMcpOnlyOptions = (options: Record<string, unknown>): Record<string, unknown> => {
|
|
265
|
+
const next: Record<string, unknown> = {}
|
|
266
|
+
for (const [key, value] of Object.entries(options)) {
|
|
267
|
+
if (OMITTED_OPTION_KEYS.has(key)) continue
|
|
268
|
+
next[key] = value
|
|
269
|
+
}
|
|
270
|
+
return next
|
|
271
|
+
}
|
|
272
|
+
|
|
77
273
|
const normalizeBatchToolCall = (
|
|
78
274
|
call: unknown,
|
|
79
275
|
index: number,
|
|
@@ -107,22 +303,23 @@ const normalizeBatchToolCall = (
|
|
|
107
303
|
}
|
|
108
304
|
|
|
109
305
|
const normalizeBatchPayload = (payload: unknown): BatchToolCallPayload => {
|
|
110
|
-
|
|
306
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
307
|
+
if (!isRecord(normalized)) {
|
|
111
308
|
throw new Error('Batch tool call requires an object payload')
|
|
112
309
|
}
|
|
113
310
|
|
|
114
|
-
if (!Array.isArray(
|
|
311
|
+
if (!Array.isArray(normalized.calls)) {
|
|
115
312
|
throw new Error('Batch tool call requires a "calls" array')
|
|
116
313
|
}
|
|
117
314
|
|
|
118
|
-
const calls =
|
|
315
|
+
const calls = (normalized.calls as unknown[]).map((call, index) => normalizeBatchToolCall(call, index))
|
|
119
316
|
|
|
120
317
|
return {
|
|
121
318
|
calls: calls.map(({ tool, payload }) => ({
|
|
122
319
|
tool,
|
|
123
320
|
...payload,
|
|
124
321
|
})),
|
|
125
|
-
continueOnError: Boolean(
|
|
322
|
+
continueOnError: Boolean((normalized as Record<string, unknown>).continueOnError),
|
|
126
323
|
}
|
|
127
324
|
}
|
|
128
325
|
|
|
@@ -165,26 +362,216 @@ export type GitMcpServerInstance = {
|
|
|
165
362
|
const buildToolName = (tool: ToolDefinition, prefix?: string): string =>
|
|
166
363
|
prefix ? `${prefix}.${tool.name}` : tool.name
|
|
167
364
|
|
|
365
|
+
const isLogsForRunTailTool = (toolName: string): boolean => toolName.endsWith('jobs.logsForRunTail')
|
|
366
|
+
const isArtifactsByRunTool = (toolName: string): boolean => toolName.endsWith('runs.artifacts')
|
|
367
|
+
const isDiagnoseLatestFailureTool = (toolName: string): boolean =>
|
|
368
|
+
toolName.endsWith('diagnoseLatestFailure') && toolName.includes('.actions.')
|
|
369
|
+
|
|
370
|
+
const buildGenericToolSchema = (): Record<string, unknown> => ({
|
|
371
|
+
type: 'object',
|
|
372
|
+
additionalProperties: true,
|
|
373
|
+
properties: {
|
|
374
|
+
args: {
|
|
375
|
+
type: 'array',
|
|
376
|
+
items: { type: 'string' },
|
|
377
|
+
description: 'Positional arguments for the git API method (strings are safest).',
|
|
378
|
+
},
|
|
379
|
+
options: {
|
|
380
|
+
type: 'object',
|
|
381
|
+
additionalProperties: true,
|
|
382
|
+
description: 'Method options (query/headers/body payload). Extra top-level keys are merged into options.',
|
|
383
|
+
},
|
|
384
|
+
format: {
|
|
385
|
+
type: 'string',
|
|
386
|
+
enum: ['terse', 'debug'],
|
|
387
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const buildArtifactsByRunSchema = (): Record<string, unknown> => ({
|
|
393
|
+
type: 'object',
|
|
394
|
+
additionalProperties: true,
|
|
395
|
+
properties: {
|
|
396
|
+
owner: {
|
|
397
|
+
type: 'string',
|
|
398
|
+
description: 'Repository owner/org. Optional if the server was started with a default owner.',
|
|
399
|
+
},
|
|
400
|
+
repo: {
|
|
401
|
+
type: 'string',
|
|
402
|
+
description: 'Repository name. Optional if the server was started with a default repo.',
|
|
403
|
+
},
|
|
404
|
+
runId: {
|
|
405
|
+
description: 'Workflow run id (alias: run_id).',
|
|
406
|
+
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
|
|
407
|
+
},
|
|
408
|
+
run_id: {
|
|
409
|
+
description: 'Alias for runId.',
|
|
410
|
+
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
|
|
411
|
+
},
|
|
412
|
+
format: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
enum: ['terse', 'debug'],
|
|
415
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
416
|
+
},
|
|
417
|
+
// Legacy positional forms:
|
|
418
|
+
// - Preferred by humans/LLMs: [owner, repo, runId]
|
|
419
|
+
// - Back-compat with the underlying helper signature: [runId, owner, repo]
|
|
420
|
+
args: {
|
|
421
|
+
type: 'array',
|
|
422
|
+
items: {},
|
|
423
|
+
description:
|
|
424
|
+
'Legacy positional form. Prefer named params. If used, pass [owner, repo, runId] (recommended) or the legacy [runId, owner, repo]. You can also pass only [runId] if defaults are configured.',
|
|
425
|
+
},
|
|
426
|
+
options: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
additionalProperties: true,
|
|
429
|
+
description: 'Options object (query/headers/body payload). Extra top-level keys are merged into options.',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
anyOf: [{ required: ['runId'] }, { required: ['run_id'] }, { required: ['args'] }],
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
|
|
436
|
+
type: 'object',
|
|
437
|
+
additionalProperties: true,
|
|
438
|
+
properties: {
|
|
439
|
+
// Preferred named form (no positional confusion).
|
|
440
|
+
owner: {
|
|
441
|
+
type: 'string',
|
|
442
|
+
description: 'Repository owner/org. Optional if the server was started with a default owner.',
|
|
443
|
+
},
|
|
444
|
+
repo: {
|
|
445
|
+
type: 'string',
|
|
446
|
+
description: 'Repository name. Optional if the server was started with a default repo.',
|
|
447
|
+
},
|
|
448
|
+
headSha: {
|
|
449
|
+
type: 'string',
|
|
450
|
+
description: 'Commit SHA for the run (alias: head_sha).',
|
|
451
|
+
},
|
|
452
|
+
head_sha: {
|
|
453
|
+
type: 'string',
|
|
454
|
+
description: 'Alias for headSha.',
|
|
455
|
+
},
|
|
456
|
+
runNumber: {
|
|
457
|
+
type: 'integer',
|
|
458
|
+
minimum: 1,
|
|
459
|
+
description: 'Workflow run_number (alias: run_number).',
|
|
460
|
+
},
|
|
461
|
+
run_number: {
|
|
462
|
+
type: 'integer',
|
|
463
|
+
minimum: 1,
|
|
464
|
+
description: 'Alias for runNumber.',
|
|
465
|
+
},
|
|
466
|
+
maxLines: {
|
|
467
|
+
type: 'integer',
|
|
468
|
+
minimum: 1,
|
|
469
|
+
description: 'Max lines to return from the end of the logs.',
|
|
470
|
+
},
|
|
471
|
+
maxBytes: {
|
|
472
|
+
type: 'integer',
|
|
473
|
+
minimum: 1,
|
|
474
|
+
description: 'Max bytes to return from the end of the logs.',
|
|
475
|
+
},
|
|
476
|
+
contains: {
|
|
477
|
+
type: 'string',
|
|
478
|
+
description: 'If set, only return log lines containing this substring.',
|
|
479
|
+
},
|
|
480
|
+
format: {
|
|
481
|
+
type: 'string',
|
|
482
|
+
enum: ['terse', 'debug'],
|
|
483
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
// Legacy / compatibility: allow calling with positional args.
|
|
487
|
+
args: {
|
|
488
|
+
type: 'array',
|
|
489
|
+
items: {},
|
|
490
|
+
description:
|
|
491
|
+
'Legacy positional form. Preferred is named params. If used, pass [headSha, runNumber] (or the legacy-mistake [owner, repo] with options.headSha/options.runNumber).',
|
|
492
|
+
},
|
|
493
|
+
options: {
|
|
494
|
+
type: 'object',
|
|
495
|
+
additionalProperties: true,
|
|
496
|
+
description:
|
|
497
|
+
'Options object. Supports owner/repo, maxLines/maxBytes/contains, and format. Extra top-level keys are merged into options.',
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
anyOf: [
|
|
501
|
+
{ required: ['headSha', 'runNumber'] },
|
|
502
|
+
{ required: ['head_sha', 'run_number'] },
|
|
503
|
+
{ required: ['headSha', 'run_number'] },
|
|
504
|
+
{ required: ['head_sha', 'runNumber'] },
|
|
505
|
+
],
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
const buildDiagnoseLatestFailureSchema = (): Record<string, unknown> => ({
|
|
509
|
+
type: 'object',
|
|
510
|
+
additionalProperties: true,
|
|
511
|
+
properties: {
|
|
512
|
+
owner: {
|
|
513
|
+
type: 'string',
|
|
514
|
+
description: 'Repository owner/org. Optional if the server was started with a default owner.',
|
|
515
|
+
},
|
|
516
|
+
repo: {
|
|
517
|
+
type: 'string',
|
|
518
|
+
description: 'Repository name. Optional if the server was started with a default repo.',
|
|
519
|
+
},
|
|
520
|
+
workflowName: {
|
|
521
|
+
type: 'string',
|
|
522
|
+
description: 'Optional filter: match failing runs by name/display_title (case-insensitive substring).',
|
|
523
|
+
},
|
|
524
|
+
limit: {
|
|
525
|
+
type: 'integer',
|
|
526
|
+
minimum: 1,
|
|
527
|
+
description: 'How many tasks/runs to fetch before filtering (default: 50).',
|
|
528
|
+
},
|
|
529
|
+
maxLines: {
|
|
530
|
+
type: 'integer',
|
|
531
|
+
minimum: 1,
|
|
532
|
+
description: 'Max lines to return from the end of the logs (default: 200).',
|
|
533
|
+
},
|
|
534
|
+
maxBytes: {
|
|
535
|
+
type: 'integer',
|
|
536
|
+
minimum: 1,
|
|
537
|
+
description: 'Max bytes to return from the end of the logs.',
|
|
538
|
+
},
|
|
539
|
+
contains: {
|
|
540
|
+
type: 'string',
|
|
541
|
+
description: 'If set, only return log lines containing this substring.',
|
|
542
|
+
},
|
|
543
|
+
format: {
|
|
544
|
+
type: 'string',
|
|
545
|
+
enum: ['terse', 'debug'],
|
|
546
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
547
|
+
},
|
|
548
|
+
args: {
|
|
549
|
+
type: 'array',
|
|
550
|
+
items: { type: 'string' },
|
|
551
|
+
description: 'Legacy positional form: [owner, repo] plus options.workflowName/maxLines/etc.',
|
|
552
|
+
},
|
|
553
|
+
options: {
|
|
554
|
+
type: 'object',
|
|
555
|
+
additionalProperties: true,
|
|
556
|
+
description: 'Options object. Extra top-level keys are merged into options.',
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
const buildToolInputSchema = (tool: ToolDefinition): Record<string, unknown> =>
|
|
562
|
+
isLogsForRunTailTool(tool.name)
|
|
563
|
+
? buildLogsForRunTailSchema()
|
|
564
|
+
: isArtifactsByRunTool(tool.name)
|
|
565
|
+
? buildArtifactsByRunSchema()
|
|
566
|
+
: isDiagnoseLatestFailureTool(tool.name)
|
|
567
|
+
? buildDiagnoseLatestFailureSchema()
|
|
568
|
+
: buildGenericToolSchema()
|
|
569
|
+
|
|
168
570
|
const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?: string) => {
|
|
169
571
|
const toolNames = tools.map((tool) => ({
|
|
170
572
|
name: buildToolName(tool, prefix),
|
|
171
573
|
description: `Call git API method ${tool.path.join('.')}`,
|
|
172
|
-
inputSchema:
|
|
173
|
-
type: 'object',
|
|
174
|
-
additionalProperties: true,
|
|
175
|
-
properties: {
|
|
176
|
-
args: {
|
|
177
|
-
type: 'array',
|
|
178
|
-
items: { type: 'string' },
|
|
179
|
-
description: 'Positional path arguments for the git API method',
|
|
180
|
-
},
|
|
181
|
-
options: {
|
|
182
|
-
type: 'object',
|
|
183
|
-
additionalProperties: true,
|
|
184
|
-
description: 'Method options (for query, headers, payload/body/json/data)',
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
},
|
|
574
|
+
inputSchema: buildToolInputSchema(tool),
|
|
188
575
|
}))
|
|
189
576
|
|
|
190
577
|
const batchTool = {
|
|
@@ -216,6 +603,11 @@ const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?:
|
|
|
216
603
|
additionalProperties: true,
|
|
217
604
|
description: 'Tool invocation options',
|
|
218
605
|
},
|
|
606
|
+
format: {
|
|
607
|
+
type: 'string',
|
|
608
|
+
enum: ['terse', 'debug'],
|
|
609
|
+
description: 'Per-call output format (default: "terse").',
|
|
610
|
+
},
|
|
219
611
|
},
|
|
220
612
|
required: ['tool'],
|
|
221
613
|
},
|
|
@@ -226,6 +618,11 @@ const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?:
|
|
|
226
618
|
description: 'Whether to continue when a call in the batch fails',
|
|
227
619
|
default: false,
|
|
228
620
|
},
|
|
621
|
+
format: {
|
|
622
|
+
type: 'string',
|
|
623
|
+
enum: ['terse', 'debug'],
|
|
624
|
+
description: 'Default output format for calls that do not specify one (default: "terse").',
|
|
625
|
+
},
|
|
229
626
|
},
|
|
230
627
|
required: ['calls'],
|
|
231
628
|
},
|
|
@@ -242,15 +639,174 @@ const invokeTool = async (
|
|
|
242
639
|
payload: unknown,
|
|
243
640
|
): Promise<GitServiceApiExecutionResult> => {
|
|
244
641
|
const { args, options } = normalizePayload(payload)
|
|
642
|
+
const cleanedOptions = stripMcpOnlyOptions(options)
|
|
245
643
|
const invocationArgs: unknown[] = args
|
|
246
644
|
|
|
247
|
-
if (Object.keys(
|
|
248
|
-
invocationArgs.push(
|
|
645
|
+
if (Object.keys(cleanedOptions).length > 0) {
|
|
646
|
+
invocationArgs.push(cleanedOptions)
|
|
249
647
|
}
|
|
250
648
|
|
|
251
649
|
return tool.method(...invocationArgs)
|
|
252
650
|
}
|
|
253
651
|
|
|
652
|
+
const pickArgsFromNormalizedPayload = (normalized: unknown, record: Record<string, unknown>): unknown[] => {
|
|
653
|
+
if (Array.isArray(normalized)) {
|
|
654
|
+
return normalized
|
|
655
|
+
}
|
|
656
|
+
return Array.isArray(record.args) ? record.args : []
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const pickNestedRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
|
|
660
|
+
|
|
661
|
+
const normalizeQueryRecord = (query: unknown): Record<string, unknown> => {
|
|
662
|
+
if (isRecord(query)) {
|
|
663
|
+
return query
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!Array.isArray(query)) {
|
|
667
|
+
return {}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const merged: Record<string, unknown> = {}
|
|
671
|
+
|
|
672
|
+
for (const entry of query) {
|
|
673
|
+
if (!isRecord(entry)) continue
|
|
674
|
+
|
|
675
|
+
const name = typeof entry.name === 'string' ? entry.name.trim() : ''
|
|
676
|
+
if (name) {
|
|
677
|
+
merged[name] = entry.value
|
|
678
|
+
continue
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Support [{ headSha: "..." }, { runNumber: 11 }] style arrays.
|
|
682
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
683
|
+
if (value !== undefined) merged[key] = value
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return merged
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const invokeLogsForRunTailTool = async (tool: ToolDefinition, payload: unknown): Promise<GitServiceApiExecutionResult> => {
|
|
691
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
692
|
+
const record = isRecord(normalized) ? normalized : {}
|
|
693
|
+
const args = pickArgsFromNormalizedPayload(normalized, record)
|
|
694
|
+
const options = pickRecord(record.options)
|
|
695
|
+
const query = normalizeQueryRecord(options.query)
|
|
696
|
+
const data = pickNestedRecord(options.data)
|
|
697
|
+
|
|
698
|
+
const headShaNamed = toTrimmedString(
|
|
699
|
+
pickFirst(
|
|
700
|
+
record.headSha,
|
|
701
|
+
record.head_sha,
|
|
702
|
+
options.headSha,
|
|
703
|
+
options.head_sha,
|
|
704
|
+
query.headSha,
|
|
705
|
+
query.head_sha,
|
|
706
|
+
data.headSha,
|
|
707
|
+
data.head_sha,
|
|
708
|
+
),
|
|
709
|
+
)
|
|
710
|
+
const runNumberNamed = pickFirst(
|
|
711
|
+
toPositiveInteger(pickFirst(record.runNumber, record.run_number, options.runNumber, options.run_number)),
|
|
712
|
+
toPositiveInteger(pickFirst(query.runNumber, query.run_number, data.runNumber, data.run_number)),
|
|
713
|
+
null,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
// Positional preferred legacy: [headSha, runNumber]
|
|
717
|
+
const headShaPositional = args.length >= 1 ? toTrimmedString(args[0]) : ''
|
|
718
|
+
const runNumberPositional = args.length >= 2 ? toPositiveInteger(args[1]) : null
|
|
719
|
+
|
|
720
|
+
// Legacy-mistake support: args=[owner, repo], options has headSha/runNumber.
|
|
721
|
+
const shouldTreatArgsAsOwnerRepo =
|
|
722
|
+
args.length >= 2 &&
|
|
723
|
+
(!headShaPositional || !runNumberPositional) &&
|
|
724
|
+
Boolean(headShaNamed) &&
|
|
725
|
+
Boolean(runNumberNamed)
|
|
726
|
+
|
|
727
|
+
const ownerFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[0]) : ''
|
|
728
|
+
const repoFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[1]) : ''
|
|
729
|
+
|
|
730
|
+
const owner =
|
|
731
|
+
toTrimmedString(pickFirst(record.owner, options.owner, query.owner, data.owner, ownerFromArgs)) || undefined
|
|
732
|
+
const repo = toTrimmedString(pickFirst(record.repo, options.repo, query.repo, data.repo, repoFromArgs)) || undefined
|
|
733
|
+
|
|
734
|
+
const sha = toTrimmedString(pickFirst(headShaNamed || null, headShaPositional || null)) || ''
|
|
735
|
+
const run = pickFirst(runNumberNamed, runNumberPositional) ?? null
|
|
736
|
+
|
|
737
|
+
if (!sha || !run) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
'headSha (string) and runNumber (positive integer) are required. Preferred invocation: { owner, repo, headSha, runNumber, maxLines }. (Compatibility: options.query.{headSha,runNumber} is also accepted.)',
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const containsFromQuery = typeof query.contains === 'string' ? query.contains : undefined
|
|
744
|
+
const maxLinesFromQuery = toPositiveInteger(query.maxLines)
|
|
745
|
+
const maxBytesFromQuery = toPositiveInteger(query.maxBytes)
|
|
746
|
+
|
|
747
|
+
const cleanedOptions = stripMcpOnlyOptions({
|
|
748
|
+
...options,
|
|
749
|
+
...(containsFromQuery ? { contains: containsFromQuery } : {}),
|
|
750
|
+
...(maxLinesFromQuery ? { maxLines: maxLinesFromQuery } : {}),
|
|
751
|
+
...(maxBytesFromQuery ? { maxBytes: maxBytesFromQuery } : {}),
|
|
752
|
+
...(owner ? { owner } : {}),
|
|
753
|
+
...(repo ? { repo } : {}),
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
return tool.method(sha, run, cleanedOptions)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const invokeArtifactsByRunTool = async (
|
|
760
|
+
tool: ToolDefinition,
|
|
761
|
+
payload: unknown,
|
|
762
|
+
): Promise<GitServiceApiExecutionResult> => {
|
|
763
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
764
|
+
const record = isRecord(normalized) ? normalized : {}
|
|
765
|
+
const args = pickArgsFromNormalizedPayload(normalized, record)
|
|
766
|
+
const options = pickRecord(record.options)
|
|
767
|
+
|
|
768
|
+
const runIdNamed = toTrimmedString(pickFirst(record.runId, record.run_id, options.runId, options.run_id)) || ''
|
|
769
|
+
|
|
770
|
+
const a0 = args.length >= 1 ? toTrimmedString(args[0]) : ''
|
|
771
|
+
const a1 = args.length >= 2 ? toTrimmedString(args[1]) : ''
|
|
772
|
+
const a2 = args.length >= 3 ? toTrimmedString(args[2]) : ''
|
|
773
|
+
|
|
774
|
+
// Heuristic:
|
|
775
|
+
// - If named runId is set, use it.
|
|
776
|
+
// - Else if args look like [owner, repo, runId], use that.
|
|
777
|
+
// - Else treat args as legacy [runId, owner, repo] (or [runId] with defaults).
|
|
778
|
+
const shouldTreatAsOwnerRepoRunId = Boolean(a0) && Boolean(a1) && Boolean(a2) && !runIdNamed
|
|
779
|
+
|
|
780
|
+
const owner =
|
|
781
|
+
toTrimmedString(pickFirst(record.owner, options.owner, shouldTreatAsOwnerRepoRunId ? a0 : a1)) || undefined
|
|
782
|
+
const repo =
|
|
783
|
+
toTrimmedString(pickFirst(record.repo, options.repo, shouldTreatAsOwnerRepoRunId ? a1 : a2)) || undefined
|
|
784
|
+
const runId = toTrimmedString(pickFirst(runIdNamed || null, shouldTreatAsOwnerRepoRunId ? a2 : a0)) || ''
|
|
785
|
+
|
|
786
|
+
if (!runId) {
|
|
787
|
+
throw new Error('runId is required. Preferred invocation: { owner, repo, runId }.')
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const cleanedOptions = stripMcpOnlyOptions({
|
|
791
|
+
...options,
|
|
792
|
+
...(owner ? { owner } : {}),
|
|
793
|
+
...(repo ? { repo } : {}),
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
// Underlying helper signature is (runId, owner?, repo?, options?).
|
|
797
|
+
// We always pass runId first, and owner/repo if we have them.
|
|
798
|
+
if (owner && repo) {
|
|
799
|
+
return tool.method(runId, owner, repo, cleanedOptions)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (owner && !repo) {
|
|
803
|
+
// Unusual: allow passing only owner explicitly.
|
|
804
|
+
return tool.method(runId, owner, cleanedOptions)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return tool.method(runId, cleanedOptions)
|
|
808
|
+
}
|
|
809
|
+
|
|
254
810
|
export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpServerInstance => {
|
|
255
811
|
const api = createGitServiceApi(options)
|
|
256
812
|
const tools = collectGitTools(api)
|
|
@@ -281,6 +837,8 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
281
837
|
if (requestedName === batchToolName) {
|
|
282
838
|
try {
|
|
283
839
|
const { calls, continueOnError } = normalizeBatchPayload(request.params.arguments)
|
|
840
|
+
const batchControls = extractOutputControls(request.params.arguments)
|
|
841
|
+
|
|
284
842
|
const executions = calls.map(({ tool, args = [], options = {} }, index) => ({ tool, args, options, index }))
|
|
285
843
|
const results = await Promise.all(
|
|
286
844
|
executions.map(async ({ tool, args, options, index }) => {
|
|
@@ -290,17 +848,34 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
290
848
|
index,
|
|
291
849
|
tool,
|
|
292
850
|
isError: true,
|
|
293
|
-
|
|
851
|
+
...({
|
|
852
|
+
ok: false,
|
|
853
|
+
error: {
|
|
854
|
+
code: 'UNKNOWN_TOOL',
|
|
855
|
+
message: `Unknown tool: ${tool}`,
|
|
856
|
+
retryable: false,
|
|
857
|
+
},
|
|
858
|
+
} satisfies McpTerseErr),
|
|
294
859
|
} as BatchResult
|
|
295
860
|
}
|
|
296
861
|
|
|
297
862
|
try {
|
|
298
|
-
const
|
|
863
|
+
const mergedPayload = { args, options }
|
|
864
|
+
const callControls = extractOutputControls(mergedPayload)
|
|
865
|
+
const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
|
|
866
|
+
|
|
867
|
+
const data = await (isLogsForRunTailTool(toolDefinition.name)
|
|
868
|
+
? invokeLogsForRunTailTool(toolDefinition, mergedPayload)
|
|
869
|
+
: isArtifactsByRunTool(toolDefinition.name)
|
|
870
|
+
? invokeArtifactsByRunTool(toolDefinition, mergedPayload)
|
|
871
|
+
: invokeTool(toolDefinition, mergedPayload))
|
|
872
|
+
|
|
873
|
+
const { isError, envelope } = toMcpEnvelope(data, effectiveFormat)
|
|
299
874
|
return {
|
|
300
875
|
index,
|
|
301
876
|
tool,
|
|
302
|
-
isError
|
|
303
|
-
|
|
877
|
+
isError,
|
|
878
|
+
...(envelope as McpTerseOk | McpTerseErr),
|
|
304
879
|
} as BatchResult
|
|
305
880
|
} catch (error) {
|
|
306
881
|
if (continueOnError) {
|
|
@@ -308,7 +883,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
308
883
|
index,
|
|
309
884
|
tool,
|
|
310
885
|
isError: true,
|
|
311
|
-
|
|
886
|
+
...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
|
|
312
887
|
} as BatchResult
|
|
313
888
|
}
|
|
314
889
|
throw error
|
|
@@ -321,7 +896,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
321
896
|
content: [
|
|
322
897
|
{
|
|
323
898
|
type: 'text',
|
|
324
|
-
text: JSON.stringify(redactSecretsForMcpOutput(results)
|
|
899
|
+
text: JSON.stringify(redactSecretsForMcpOutput(results)),
|
|
325
900
|
},
|
|
326
901
|
],
|
|
327
902
|
}
|
|
@@ -331,7 +906,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
331
906
|
content: [
|
|
332
907
|
{
|
|
333
908
|
type: 'text',
|
|
334
|
-
text:
|
|
909
|
+
text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
|
|
335
910
|
},
|
|
336
911
|
],
|
|
337
912
|
}
|
|
@@ -345,13 +920,20 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
345
920
|
}
|
|
346
921
|
|
|
347
922
|
try {
|
|
348
|
-
const
|
|
349
|
-
const
|
|
923
|
+
const controls = extractOutputControls(request.params.arguments)
|
|
924
|
+
const result = await (isLogsForRunTailTool(tool.name)
|
|
925
|
+
? invokeLogsForRunTailTool(tool, request.params.arguments)
|
|
926
|
+
: isArtifactsByRunTool(tool.name)
|
|
927
|
+
? invokeArtifactsByRunTool(tool, request.params.arguments)
|
|
928
|
+
: invokeTool(tool, request.params.arguments))
|
|
929
|
+
|
|
930
|
+
const { isError, envelope } = toMcpEnvelope(result, controls.format ?? 'terse')
|
|
350
931
|
return {
|
|
932
|
+
...(isError ? { isError: true } : {}),
|
|
351
933
|
content: [
|
|
352
934
|
{
|
|
353
935
|
type: 'text',
|
|
354
|
-
text: JSON.stringify(
|
|
936
|
+
text: JSON.stringify(envelope),
|
|
355
937
|
},
|
|
356
938
|
],
|
|
357
939
|
}
|
|
@@ -361,7 +943,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
361
943
|
content: [
|
|
362
944
|
{
|
|
363
945
|
type: 'text',
|
|
364
|
-
text:
|
|
946
|
+
text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
|
|
365
947
|
},
|
|
366
948
|
],
|
|
367
949
|
}
|