@foundation0/git 1.3.0 → 1.3.2
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 +291 -291
- package/gitea-swagger.json +28627 -28627
- package/mcp/README.md +262 -247
- package/mcp/cli.mjs +37 -37
- package/mcp/src/cli.ts +76 -76
- package/mcp/src/client.ts +143 -134
- package/mcp/src/index.ts +7 -7
- package/mcp/src/redaction.ts +207 -207
- package/mcp/src/server.ts +2313 -814
- package/package.json +3 -1
- package/src/actions-api.ts +860 -637
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -544
- package/src/git-service-api.ts +822 -754
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -55
- package/src/issue-dependencies.ts +533 -533
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -460
- package/src/platform/gitea-rules.ts +129 -129
- package/src/platform/index.ts +44 -44
- package/src/repository.ts +151 -151
- package/src/spec-mock.ts +45 -45
package/mcp/src/server.ts
CHANGED
|
@@ -1,969 +1,2468 @@
|
|
|
1
|
-
import { createGitServiceApi } from '@foundation0/git'
|
|
1
|
+
import { callIssueDependenciesApi, createGitServiceApi, extractDependencyIssueNumbers } from '@foundation0/git'
|
|
2
2
|
import type {
|
|
3
3
|
GitServiceApi,
|
|
4
4
|
GitServiceApiExecutionResult,
|
|
5
5
|
GitServiceApiFactoryOptions,
|
|
6
6
|
GitServiceApiMethod,
|
|
7
|
-
} from '@foundation0/git'
|
|
8
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
9
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
10
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
11
|
-
import { redactSecretsForMcpOutput, redactSecretsInText } from './redaction'
|
|
12
|
-
|
|
13
|
-
type ToolInvocationPayload = {
|
|
14
|
-
args?: unknown[]
|
|
15
|
-
options?: Record<string, unknown>
|
|
16
|
-
[key: string]: unknown
|
|
17
|
-
}
|
|
18
|
-
|
|
7
|
+
} from '@foundation0/git'
|
|
8
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
10
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
11
|
+
import { redactSecretsForMcpOutput, redactSecretsInText } from './redaction'
|
|
12
|
+
|
|
13
|
+
type ToolInvocationPayload = {
|
|
14
|
+
args?: unknown[]
|
|
15
|
+
options?: Record<string, unknown>
|
|
16
|
+
[key: string]: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
19
|
type McpToolOutputFormat = 'terse' | 'debug'
|
|
20
20
|
|
|
21
|
-
type
|
|
22
|
-
tool: string
|
|
23
|
-
args?: unknown[]
|
|
24
|
-
options?: Record<string, unknown>
|
|
25
|
-
[key: string]: unknown
|
|
26
|
-
}
|
|
21
|
+
type McpFieldSelection = string[]
|
|
27
22
|
|
|
28
|
-
type
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
type McpCallControls = {
|
|
24
|
+
format: McpToolOutputFormat | null
|
|
25
|
+
validateOnly: boolean
|
|
26
|
+
fields: McpFieldSelection | null
|
|
27
|
+
full: boolean
|
|
31
28
|
}
|
|
29
|
+
|
|
30
|
+
type BatchToolCall = {
|
|
31
|
+
tool: string
|
|
32
|
+
args?: unknown[]
|
|
33
|
+
options?: Record<string, unknown>
|
|
34
|
+
[key: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type BatchToolCallPayload = {
|
|
38
|
+
calls: BatchToolCall[]
|
|
39
|
+
continueOnError: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type BatchResult = {
|
|
43
|
+
index: number
|
|
44
|
+
tool: string
|
|
45
|
+
isError: boolean
|
|
46
|
+
} & (McpTerseOk | McpTerseErr)
|
|
47
|
+
|
|
48
|
+
type ToolDefinition = {
|
|
49
|
+
name: string
|
|
50
|
+
path: string[]
|
|
51
|
+
method: GitServiceApiMethod
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type McpToolListEntry = {
|
|
55
|
+
name: string
|
|
56
|
+
description: string
|
|
57
|
+
inputSchema: Record<string, unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
61
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
62
|
+
|
|
63
|
+
const tryParseJsonObject = (value: string): unknown => {
|
|
64
|
+
const trimmed = value.trim()
|
|
65
|
+
if (!trimmed) return {}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(trimmed) as unknown
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
71
|
+
throw new Error(`Invalid args JSON: ${message}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const normalizeArgumentPayload = (payload: unknown): unknown => {
|
|
76
|
+
if (typeof payload === 'string' || payload instanceof String) {
|
|
77
|
+
const parsed = tryParseJsonObject(String(payload))
|
|
78
|
+
if (!isRecord(parsed)) {
|
|
79
|
+
const kind = Array.isArray(parsed) ? 'array' : typeof parsed
|
|
80
|
+
throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
|
|
81
|
+
}
|
|
82
|
+
return parsed
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return payload
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const toTrimmedString = (value: unknown): string => (value === null || value === undefined ? '' : String(value)).trim()
|
|
89
|
+
|
|
90
|
+
const toPositiveInteger = (value: unknown): number | null => {
|
|
91
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
92
|
+
return Math.floor(value)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof value !== 'string') return null
|
|
96
|
+
|
|
97
|
+
const trimmed = value.trim()
|
|
98
|
+
if (!trimmed) return null
|
|
99
|
+
const parsed = Number(trimmed)
|
|
100
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
101
|
+
return Math.floor(parsed)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const toNonNegativeInteger = (value: unknown): number | null => {
|
|
105
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
106
|
+
return Math.floor(value)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof value !== 'string') return null
|
|
110
|
+
|
|
111
|
+
const trimmed = value.trim()
|
|
112
|
+
if (!trimmed) return null
|
|
113
|
+
const parsed = Number(trimmed)
|
|
114
|
+
if (!Number.isFinite(parsed) || parsed < 0) return null
|
|
115
|
+
return Math.floor(parsed)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pickFirst = <T>(...candidates: Array<T | null | undefined>): T | null => {
|
|
119
|
+
for (const candidate of candidates) {
|
|
120
|
+
if (candidate !== null && candidate !== undefined) return candidate
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pickRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
|
|
126
|
+
|
|
127
|
+
const parseOutputFormat = (value: unknown): McpToolOutputFormat | null => {
|
|
128
|
+
if (value === null || value === undefined) return null
|
|
129
|
+
const raw = toTrimmedString(value).toLowerCase()
|
|
130
|
+
if (!raw) return null
|
|
131
|
+
if (raw === 'debug') return 'debug'
|
|
132
|
+
if (raw === 'terse') return 'terse'
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const parseBoolean = (value: unknown): boolean | null => {
|
|
137
|
+
if (value === null || value === undefined) return null
|
|
138
|
+
if (typeof value === 'boolean') return value
|
|
139
|
+
|
|
140
|
+
if (typeof value === 'string') {
|
|
141
|
+
const normalized = value.trim().toLowerCase()
|
|
142
|
+
if (!normalized) return null
|
|
143
|
+
if (normalized === 'true') return true
|
|
144
|
+
if (normalized === 'false') return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const parseFieldSelection = (value: unknown): McpFieldSelection | null => {
|
|
151
|
+
if (value === null || value === undefined) return null
|
|
152
|
+
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
const fields = value.map((entry) => toTrimmedString(entry)).filter((entry) => entry.length > 0)
|
|
155
|
+
return fields.length > 0 ? fields : null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (typeof value === 'string') {
|
|
159
|
+
const trimmed = value.trim()
|
|
160
|
+
if (!trimmed) return null
|
|
161
|
+
const fields = trimmed
|
|
162
|
+
.split(',')
|
|
163
|
+
.map((entry) => entry.trim())
|
|
164
|
+
.filter((entry) => entry.length > 0)
|
|
165
|
+
return fields.length > 0 ? fields : null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const extractMcpControls = (
|
|
172
|
+
payload: unknown,
|
|
173
|
+
): McpCallControls => {
|
|
174
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
175
|
+
if (!isRecord(normalized)) {
|
|
176
|
+
return { format: null, validateOnly: false, fields: null, full: false }
|
|
177
|
+
}
|
|
32
178
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
179
|
+
const topLevelFormat = parseOutputFormat(normalized.format)
|
|
180
|
+
const topLevelValidateOnly = parseBoolean(normalized.validateOnly)
|
|
181
|
+
const topLevelFields = parseFieldSelection(normalized.fields)
|
|
182
|
+
const topLevelFull = parseBoolean(normalized.full)
|
|
183
|
+
const optionsFormat = parseOutputFormat(pickRecord(normalized.options).format)
|
|
184
|
+
const optionsValidateOnly = parseBoolean(pickRecord(normalized.options).validateOnly)
|
|
185
|
+
const optionsFields = parseFieldSelection(pickRecord(normalized.options).fields)
|
|
186
|
+
const optionsFull = parseBoolean(pickRecord(normalized.options).full)
|
|
38
187
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
188
|
+
// Support either { format:"debug" } or { options:{ format:"debug" } }.
|
|
189
|
+
return {
|
|
190
|
+
format: pickFirst(topLevelFormat, optionsFormat),
|
|
191
|
+
validateOnly: Boolean(pickFirst(topLevelValidateOnly, optionsValidateOnly)),
|
|
192
|
+
fields: pickFirst(topLevelFields, optionsFields),
|
|
193
|
+
full: Boolean(pickFirst(topLevelFull, optionsFull)),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type McpTerseOk = {
|
|
198
|
+
ok: true
|
|
199
|
+
data: unknown
|
|
200
|
+
meta: {
|
|
201
|
+
status: number
|
|
202
|
+
}
|
|
203
|
+
debug?: unknown
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type McpTerseErr = {
|
|
207
|
+
ok: false
|
|
208
|
+
error: {
|
|
209
|
+
code: string
|
|
210
|
+
status?: number
|
|
211
|
+
message: string
|
|
212
|
+
details?: unknown
|
|
213
|
+
hint?: string
|
|
214
|
+
retryable: boolean
|
|
215
|
+
}
|
|
216
|
+
meta?: {
|
|
217
|
+
status?: number
|
|
218
|
+
}
|
|
219
|
+
debug?: unknown
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const httpErrorCodeForStatus = (status: number): string => {
|
|
223
|
+
if (status === 400) return 'HTTP_BAD_REQUEST'
|
|
224
|
+
if (status === 401) return 'HTTP_UNAUTHORIZED'
|
|
225
|
+
if (status === 403) return 'HTTP_FORBIDDEN'
|
|
226
|
+
if (status === 404) return 'HTTP_NOT_FOUND'
|
|
227
|
+
if (status === 409) return 'HTTP_CONFLICT'
|
|
228
|
+
if (status === 422) return 'HTTP_UNPROCESSABLE_ENTITY'
|
|
229
|
+
if (status === 429) return 'HTTP_RATE_LIMITED'
|
|
230
|
+
if (status >= 500) return 'HTTP_SERVER_ERROR'
|
|
231
|
+
if (status >= 400) return 'HTTP_ERROR'
|
|
232
|
+
return 'UNKNOWN'
|
|
43
233
|
}
|
|
44
234
|
|
|
45
|
-
const
|
|
46
|
-
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
47
|
-
|
|
48
|
-
const tryParseJsonObject = (value: string): unknown => {
|
|
49
|
-
const trimmed = value.trim()
|
|
50
|
-
if (!trimmed) return {}
|
|
51
|
-
|
|
235
|
+
const tryParseJsonLikeString = (value: string): unknown | null => {
|
|
52
236
|
try {
|
|
53
|
-
return JSON.parse(
|
|
54
|
-
} catch
|
|
55
|
-
|
|
56
|
-
throw new Error(`Invalid args JSON: ${message}`)
|
|
237
|
+
return JSON.parse(value) as unknown
|
|
238
|
+
} catch {
|
|
239
|
+
return null
|
|
57
240
|
}
|
|
58
241
|
}
|
|
59
242
|
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const kind = Array.isArray(parsed) ? 'array' : typeof parsed
|
|
65
|
-
throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
|
|
243
|
+
const extractBodyMessage = (body: unknown): string | null => {
|
|
244
|
+
if (isRecord(body)) {
|
|
245
|
+
if (typeof body.message === 'string' && body.message.trim()) {
|
|
246
|
+
return body.message.trim()
|
|
66
247
|
}
|
|
67
|
-
return parsed
|
|
68
|
-
}
|
|
69
248
|
|
|
70
|
-
|
|
71
|
-
|
|
249
|
+
if (typeof body.error === 'string' && body.error.trim()) {
|
|
250
|
+
return body.error.trim()
|
|
251
|
+
}
|
|
72
252
|
|
|
73
|
-
|
|
253
|
+
if (isRecord(body.error)) {
|
|
254
|
+
const nested = extractBodyMessage(body.error)
|
|
255
|
+
if (nested) return nested
|
|
256
|
+
}
|
|
257
|
+
}
|
|
74
258
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return Math.floor(value)
|
|
259
|
+
if (typeof body !== 'string') {
|
|
260
|
+
return null
|
|
78
261
|
}
|
|
79
262
|
|
|
80
|
-
|
|
263
|
+
const trimmed = body.trim()
|
|
264
|
+
if (!trimmed) {
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
81
267
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return Math.floor(parsed)
|
|
87
|
-
}
|
|
268
|
+
const parsed =
|
|
269
|
+
trimmed.startsWith('{') || trimmed.startsWith('[')
|
|
270
|
+
? tryParseJsonLikeString(trimmed)
|
|
271
|
+
: null
|
|
88
272
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
273
|
+
if (parsed && isRecord(parsed)) {
|
|
274
|
+
const parsedMessage = extractBodyMessage(parsed)
|
|
275
|
+
if (parsedMessage) return parsedMessage
|
|
92
276
|
}
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
277
|
|
|
96
|
-
const
|
|
278
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ''
|
|
279
|
+
if (firstLine === '{' || firstLine === '[') {
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
97
282
|
|
|
98
|
-
|
|
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
|
|
283
|
+
return firstLine.length > 0 ? firstLine : null
|
|
105
284
|
}
|
|
106
285
|
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
return
|
|
286
|
+
const buildErrorMessage = (status: number | undefined, body: unknown): string => {
|
|
287
|
+
const extracted = extractBodyMessage(body)
|
|
288
|
+
if (extracted) {
|
|
289
|
+
return extracted
|
|
111
290
|
}
|
|
112
291
|
|
|
113
|
-
|
|
114
|
-
|
|
292
|
+
if (typeof status === 'number') {
|
|
293
|
+
return `HTTP ${status}`
|
|
294
|
+
}
|
|
115
295
|
|
|
116
|
-
|
|
117
|
-
return { format: pickFirst(topLevelFormat, optionsFormat) }
|
|
296
|
+
return 'Request failed'
|
|
118
297
|
}
|
|
119
298
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
meta: {
|
|
124
|
-
status: number
|
|
125
|
-
}
|
|
126
|
-
debug?: unknown
|
|
299
|
+
const normalizeGiteaApiBase = (host: string): string => {
|
|
300
|
+
const trimmed = host.trim().replace(/\/$/, '')
|
|
301
|
+
return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
|
|
127
302
|
}
|
|
128
303
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
message: string
|
|
135
|
-
details?: unknown
|
|
136
|
-
hint?: string
|
|
137
|
-
retryable: boolean
|
|
304
|
+
const safeStringify = (value: unknown): string => {
|
|
305
|
+
try {
|
|
306
|
+
return JSON.stringify(value)
|
|
307
|
+
} catch {
|
|
308
|
+
return ''
|
|
138
309
|
}
|
|
139
|
-
|
|
140
|
-
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const blockingIssueMessagePattern = /close all issues blocking this pull request|issues blocking this pull request|blocking this pull request/i
|
|
313
|
+
|
|
314
|
+
const isBlockingIssueMessage = (status: number | undefined, body: unknown): boolean => {
|
|
315
|
+
const message = buildErrorMessage(status, body)
|
|
316
|
+
if (blockingIssueMessagePattern.test(message)) {
|
|
317
|
+
return true
|
|
141
318
|
}
|
|
142
|
-
|
|
319
|
+
|
|
320
|
+
const bodyText = typeof body === 'string' ? body : safeStringify(body)
|
|
321
|
+
return blockingIssueMessagePattern.test(bodyText)
|
|
143
322
|
}
|
|
144
323
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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'
|
|
324
|
+
type MergeFailureClassification = {
|
|
325
|
+
code: string
|
|
326
|
+
message: string
|
|
327
|
+
retryable: boolean
|
|
328
|
+
blockedByIssues: boolean
|
|
156
329
|
}
|
|
157
330
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
331
|
+
const classifyMergeFailure = (status: number | undefined, body: unknown): MergeFailureClassification => {
|
|
332
|
+
const message = buildErrorMessage(status, body)
|
|
333
|
+
const blockedByIssues = isBlockingIssueMessage(status, body)
|
|
334
|
+
const retryable =
|
|
335
|
+
!blockedByIssues &&
|
|
336
|
+
(status === 429 || (typeof status === 'number' && status >= 500) || /try again later/i.test(message))
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
code: blockedByIssues ? 'PR_BLOCKED_BY_ISSUES' : 'MERGE_FAILED',
|
|
340
|
+
message,
|
|
341
|
+
retryable,
|
|
342
|
+
blockedByIssues,
|
|
165
343
|
}
|
|
344
|
+
}
|
|
166
345
|
|
|
167
|
-
|
|
168
|
-
|
|
346
|
+
const inferHeadContainedFromCompareBody = (body: unknown): boolean | null => {
|
|
347
|
+
if (!isRecord(body)) {
|
|
348
|
+
return null
|
|
169
349
|
}
|
|
170
350
|
|
|
171
|
-
|
|
172
|
-
|
|
351
|
+
const totalCommits = Number((body as Record<string, unknown>).total_commits)
|
|
352
|
+
if (Number.isFinite(totalCommits)) {
|
|
353
|
+
return totalCommits === 0
|
|
173
354
|
}
|
|
174
355
|
|
|
175
|
-
|
|
356
|
+
const commits = (body as Record<string, unknown>).commits
|
|
357
|
+
if (Array.isArray(commits)) {
|
|
358
|
+
return commits.length === 0
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return null
|
|
176
362
|
}
|
|
363
|
+
|
|
364
|
+
const isNonEmptyString = (value: unknown): boolean => typeof value === 'string' && value.trim().length > 0
|
|
177
365
|
|
|
178
|
-
const
|
|
179
|
-
result: GitServiceApiExecutionResult,
|
|
180
|
-
format: McpToolOutputFormat,
|
|
181
|
-
): { isError: boolean; envelope: McpTerseOk | McpTerseErr } => {
|
|
182
|
-
const sanitized = redactSecretsForMcpOutput(result)
|
|
366
|
+
const isEmptyObject = (value: unknown): boolean => isRecord(value) && Object.keys(value).length === 0
|
|
183
367
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
...(format === 'debug' ? { debug: sanitized } : {}),
|
|
192
|
-
}
|
|
368
|
+
const isEffectivelyEmpty = (value: unknown): boolean => {
|
|
369
|
+
if (value === null || value === undefined) return true
|
|
370
|
+
if (typeof value === 'string') return value.trim().length === 0
|
|
371
|
+
if (Array.isArray(value)) return value.length === 0
|
|
372
|
+
if (isEmptyObject(value)) return true
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
193
375
|
|
|
194
|
-
|
|
195
|
-
|
|
376
|
+
const hasKey = (value: Record<string, unknown>, key: string): boolean =>
|
|
377
|
+
Object.prototype.hasOwnProperty.call(value, key)
|
|
196
378
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
}
|
|
379
|
+
const isLikelyUserRecord = (value: Record<string, unknown>): boolean =>
|
|
380
|
+
(hasKey(value, 'login') || hasKey(value, 'username')) &&
|
|
381
|
+
(hasKey(value, 'avatar_url') || hasKey(value, 'html_url') || hasKey(value, 'is_admin'))
|
|
212
382
|
|
|
213
|
-
|
|
214
|
-
|
|
383
|
+
const isLikelyRepoRecord = (value: Record<string, unknown>): boolean =>
|
|
384
|
+
(hasKey(value, 'full_name') || hasKey(value, 'default_branch')) &&
|
|
385
|
+
(hasKey(value, 'private') || hasKey(value, 'clone_url') || hasKey(value, 'ssh_url'))
|
|
215
386
|
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
error: {
|
|
219
|
-
code: 'TOOL_ERROR',
|
|
220
|
-
message: redactSecretsInText(error instanceof Error ? error.message : String(error)),
|
|
221
|
-
retryable: false,
|
|
222
|
-
},
|
|
223
|
-
})
|
|
387
|
+
const isLikelyLabelRecord = (value: Record<string, unknown>): boolean =>
|
|
388
|
+
hasKey(value, 'name') && hasKey(value, 'color') && (hasKey(value, 'id') || hasKey(value, 'description'))
|
|
224
389
|
|
|
225
|
-
const
|
|
226
|
-
|
|
390
|
+
const isLikelyMilestoneRecord = (value: Record<string, unknown>): boolean =>
|
|
391
|
+
hasKey(value, 'title') && hasKey(value, 'state') && (hasKey(value, 'due_on') || hasKey(value, 'closed_at'))
|
|
227
392
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
args: [],
|
|
231
|
-
options: {},
|
|
232
|
-
}
|
|
233
|
-
}
|
|
393
|
+
const isLikelyBranchRefRecord = (value: Record<string, unknown>): boolean =>
|
|
394
|
+
hasKey(value, 'ref') && hasKey(value, 'sha') && hasKey(value, 'repo')
|
|
234
395
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
|
|
238
|
-
}
|
|
396
|
+
const isLikelyPullRequestRecord = (value: Record<string, unknown>): boolean =>
|
|
397
|
+
hasKey(value, 'number') && (hasKey(value, 'head') || hasKey(value, 'base') || hasKey(value, 'merged'))
|
|
239
398
|
|
|
240
|
-
|
|
241
|
-
|
|
399
|
+
const isLikelyIssueRecord = (value: Record<string, unknown>): boolean =>
|
|
400
|
+
hasKey(value, 'number') && hasKey(value, 'title') && hasKey(value, 'state') && !isLikelyPullRequestRecord(value)
|
|
242
401
|
|
|
243
|
-
|
|
244
|
-
|
|
402
|
+
const isLikelyReviewRecord = (value: Record<string, unknown>): boolean =>
|
|
403
|
+
(hasKey(value, 'commit_id') && hasKey(value, 'state') && hasKey(value, 'user')) || hasKey(value, 'submitted_at')
|
|
245
404
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
405
|
+
const compactRecordByKeys = (
|
|
406
|
+
value: Record<string, unknown>,
|
|
407
|
+
keys: string[],
|
|
408
|
+
compact: (entry: unknown, keyHint?: string) => unknown,
|
|
409
|
+
): Record<string, unknown> => {
|
|
410
|
+
const next: Record<string, unknown> = {}
|
|
250
411
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
412
|
+
for (const key of keys) {
|
|
413
|
+
if (!hasKey(value, key)) continue
|
|
414
|
+
const compacted = compact(value[key], key)
|
|
415
|
+
if (isEffectivelyEmpty(compacted)) continue
|
|
416
|
+
next[key] = compacted
|
|
254
417
|
}
|
|
255
418
|
|
|
256
|
-
return
|
|
257
|
-
args,
|
|
258
|
-
options,
|
|
259
|
-
}
|
|
419
|
+
return next
|
|
260
420
|
}
|
|
261
421
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
const stripMcpOnlyOptions = (options: Record<string, unknown>): Record<string, unknown> => {
|
|
422
|
+
const compactUserRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> => {
|
|
265
423
|
const next: Record<string, unknown> = {}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
424
|
+
const id = value.id
|
|
425
|
+
const login = toTrimmedString(pickFirst(value.login, value.username))
|
|
426
|
+
const fullName = toTrimmedString(value.full_name)
|
|
427
|
+
const htmlUrl = toTrimmedString(value.html_url)
|
|
428
|
+
|
|
429
|
+
if (id !== undefined && id !== null) next.id = id
|
|
430
|
+
if (login) next.login = login
|
|
431
|
+
if (fullName) next.full_name = fullName
|
|
432
|
+
if (htmlUrl) next.html_url = htmlUrl
|
|
433
|
+
|
|
434
|
+
if (Object.keys(next).length > 0) return next
|
|
435
|
+
return compactRecordByKeys(value, ['login', 'username', 'id'], compact)
|
|
271
436
|
}
|
|
272
437
|
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
438
|
+
const compactRepoRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
439
|
+
compactRecordByKeys(value, ['id', 'name', 'full_name', 'private', 'default_branch', 'archived', 'html_url'], compact)
|
|
440
|
+
|
|
441
|
+
const compactLabelRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
442
|
+
compactRecordByKeys(value, ['id', 'name', 'color', 'description'], compact)
|
|
443
|
+
|
|
444
|
+
const compactMilestoneRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
445
|
+
compactRecordByKeys(value, ['id', 'title', 'state', 'due_on', 'closed_at'], compact)
|
|
446
|
+
|
|
447
|
+
const compactBranchRefRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
448
|
+
compactRecordByKeys(value, ['label', 'ref', 'sha', 'repo'], compact)
|
|
449
|
+
|
|
450
|
+
const compactPullRequestRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
451
|
+
compactRecordByKeys(
|
|
452
|
+
value,
|
|
453
|
+
[
|
|
454
|
+
'id',
|
|
455
|
+
'number',
|
|
456
|
+
'title',
|
|
457
|
+
'body',
|
|
458
|
+
'state',
|
|
459
|
+
'draft',
|
|
460
|
+
'is_locked',
|
|
461
|
+
'comments',
|
|
462
|
+
'additions',
|
|
463
|
+
'deletions',
|
|
464
|
+
'changed_files',
|
|
465
|
+
'mergeable',
|
|
466
|
+
'merged',
|
|
467
|
+
'merged_at',
|
|
468
|
+
'merge_commit_sha',
|
|
469
|
+
'allow_maintainer_edit',
|
|
470
|
+
'html_url',
|
|
471
|
+
'user',
|
|
472
|
+
'labels',
|
|
473
|
+
'milestone',
|
|
474
|
+
'assignee',
|
|
475
|
+
'assignees',
|
|
476
|
+
'requested_reviewers',
|
|
477
|
+
'base',
|
|
478
|
+
'head',
|
|
479
|
+
'merge_base',
|
|
480
|
+
'created_at',
|
|
481
|
+
'updated_at',
|
|
482
|
+
'closed_at',
|
|
483
|
+
],
|
|
484
|
+
compact,
|
|
485
|
+
)
|
|
280
486
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
487
|
+
const compactIssueRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
488
|
+
compactRecordByKeys(
|
|
489
|
+
value,
|
|
490
|
+
[
|
|
491
|
+
'id',
|
|
492
|
+
'number',
|
|
493
|
+
'title',
|
|
494
|
+
'body',
|
|
495
|
+
'state',
|
|
496
|
+
'user',
|
|
497
|
+
'labels',
|
|
498
|
+
'milestone',
|
|
499
|
+
'assignee',
|
|
500
|
+
'assignees',
|
|
501
|
+
'comments',
|
|
502
|
+
'html_url',
|
|
503
|
+
'created_at',
|
|
504
|
+
'updated_at',
|
|
505
|
+
'closed_at',
|
|
506
|
+
],
|
|
507
|
+
compact,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
const compactReviewRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
511
|
+
compactRecordByKeys(value, ['id', 'state', 'body', 'user', 'commit_id', 'submitted_at', 'html_url'], compact)
|
|
512
|
+
|
|
513
|
+
const compactResponseDataForDefaultMode = (value: unknown, keyHint?: string): unknown => {
|
|
514
|
+
const compact = compactResponseDataForDefaultMode
|
|
285
515
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
516
|
+
if (Array.isArray(value)) {
|
|
517
|
+
const next = value
|
|
518
|
+
.map((entry) => compact(entry, keyHint))
|
|
519
|
+
.filter((entry) => !isEffectivelyEmpty(entry))
|
|
520
|
+
return next
|
|
291
521
|
}
|
|
292
522
|
|
|
293
|
-
|
|
294
|
-
if (value
|
|
295
|
-
|
|
523
|
+
if (!isRecord(value)) {
|
|
524
|
+
if (typeof value === 'string') {
|
|
525
|
+
const trimmed = value.trim()
|
|
526
|
+
return trimmed.length > 0 ? value : undefined
|
|
296
527
|
}
|
|
528
|
+
return value
|
|
297
529
|
}
|
|
298
530
|
|
|
299
|
-
return
|
|
300
|
-
|
|
301
|
-
|
|
531
|
+
if (isLikelyPullRequestRecord(value)) return compactPullRequestRecord(value, compact)
|
|
532
|
+
if (isLikelyIssueRecord(value)) return compactIssueRecord(value, compact)
|
|
533
|
+
if (isLikelyReviewRecord(value)) return compactReviewRecord(value, compact)
|
|
534
|
+
if (isLikelyBranchRefRecord(value)) return compactBranchRefRecord(value, compact)
|
|
535
|
+
if (isLikelyMilestoneRecord(value)) return compactMilestoneRecord(value, compact)
|
|
536
|
+
if (isLikelyLabelRecord(value)) return compactLabelRecord(value, compact)
|
|
537
|
+
if (isLikelyRepoRecord(value)) return compactRepoRecord(value, compact)
|
|
538
|
+
if (isLikelyUserRecord(value)) return compactUserRecord(value, compact)
|
|
539
|
+
|
|
540
|
+
if (keyHint === 'user' || keyHint === 'owner' || keyHint === 'assignee' || keyHint === 'merged_by') {
|
|
541
|
+
return compactUserRecord(value, compact)
|
|
302
542
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const normalizeBatchPayload = (payload: unknown): BatchToolCallPayload => {
|
|
306
|
-
const normalized = normalizeArgumentPayload(payload)
|
|
307
|
-
if (!isRecord(normalized)) {
|
|
308
|
-
throw new Error('Batch tool call requires an object payload')
|
|
543
|
+
if (keyHint === 'repo') {
|
|
544
|
+
return compactRepoRecord(value, compact)
|
|
309
545
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
throw new Error('Batch tool call requires a "calls" array')
|
|
546
|
+
if (keyHint === 'base' || keyHint === 'head') {
|
|
547
|
+
return compactBranchRefRecord(value, compact)
|
|
313
548
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
tool,
|
|
320
|
-
...payload,
|
|
321
|
-
})),
|
|
322
|
-
continueOnError: Boolean((normalized as Record<string, unknown>).continueOnError),
|
|
549
|
+
if (keyHint === 'milestone') {
|
|
550
|
+
return compactMilestoneRecord(value, compact)
|
|
551
|
+
}
|
|
552
|
+
if (keyHint === 'label') {
|
|
553
|
+
return compactLabelRecord(value, compact)
|
|
323
554
|
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const collectGitTools = (api: GitServiceApi, parentPath: string[] = []): ToolDefinition[] => {
|
|
327
|
-
const tools: ToolDefinition[] = []
|
|
328
555
|
|
|
329
|
-
|
|
330
|
-
|
|
556
|
+
const next: Record<string, unknown> = {}
|
|
557
|
+
const hasHtmlUrl = isNonEmptyString(value.html_url)
|
|
331
558
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
name: currentPath.join('.'),
|
|
335
|
-
path: currentPath,
|
|
336
|
-
method: value as GitServiceApiMethod,
|
|
337
|
-
})
|
|
559
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
560
|
+
if (key === 'url' && hasHtmlUrl) {
|
|
338
561
|
continue
|
|
339
562
|
}
|
|
340
563
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
564
|
+
const compacted = compact(entryValue, key)
|
|
565
|
+
if (isEffectivelyEmpty(compacted)) continue
|
|
566
|
+
next[key] = compacted
|
|
344
567
|
}
|
|
345
568
|
|
|
346
|
-
return
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
export interface GitMcpServerOptions extends GitServiceApiFactoryOptions {
|
|
350
|
-
serverName?: string
|
|
351
|
-
serverVersion?: string
|
|
352
|
-
toolsPrefix?: string
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export type GitMcpServerInstance = {
|
|
356
|
-
api: GitServiceApi
|
|
357
|
-
tools: ToolDefinition[]
|
|
358
|
-
server: Server
|
|
359
|
-
run: () => Promise<Server>
|
|
569
|
+
return next
|
|
360
570
|
}
|
|
361
571
|
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
572
|
+
const toMcpEnvelope = (
|
|
573
|
+
result: GitServiceApiExecutionResult,
|
|
574
|
+
format: McpToolOutputFormat,
|
|
575
|
+
full: boolean,
|
|
576
|
+
): { isError: boolean; envelope: McpTerseOk | McpTerseErr } => {
|
|
577
|
+
const sanitized = redactSecretsForMcpOutput(result)
|
|
578
|
+
const body = full ? result.body : compactResponseDataForDefaultMode(result.body)
|
|
369
579
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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.',
|
|
580
|
+
if (result.ok && result.status < 400) {
|
|
581
|
+
const envelope: McpTerseOk = {
|
|
582
|
+
ok: true,
|
|
583
|
+
data: body,
|
|
584
|
+
meta: {
|
|
585
|
+
status: result.status,
|
|
586
|
+
},
|
|
587
|
+
...(format === 'debug' ? { debug: sanitized } : {}),
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return { isError: false, envelope: redactSecretsForMcpOutput(envelope) as McpTerseOk }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const status = result.status
|
|
594
|
+
const message = buildErrorMessage(status, result.body)
|
|
595
|
+
const retryable = status >= 500 || status === 429 || /try again later/i.test(message)
|
|
596
|
+
|
|
597
|
+
const envelope: McpTerseErr = {
|
|
598
|
+
ok: false,
|
|
599
|
+
error: {
|
|
600
|
+
code: httpErrorCodeForStatus(status),
|
|
601
|
+
status,
|
|
602
|
+
message,
|
|
603
|
+
details: body,
|
|
604
|
+
retryable,
|
|
403
605
|
},
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
606
|
+
meta: {
|
|
607
|
+
status,
|
|
608
|
+
},
|
|
609
|
+
...(format === 'debug' ? { debug: sanitized } : {}),
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return { isError: true, envelope: redactSecretsForMcpOutput(envelope) as McpTerseErr }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const toMcpThrownErrorEnvelope = (error: unknown): McpTerseErr => {
|
|
616
|
+
const message = redactSecretsInText(error instanceof Error ? error.message : String(error))
|
|
617
|
+
|
|
618
|
+
const hint =
|
|
619
|
+
message.includes('Missing required path arguments') || message.includes('Unresolved parameters')
|
|
620
|
+
? 'Likely missing required positional args. Prefer { args:[...], owner, repo } or set server defaults, and avoid putting path params inside options.data/options.query.'
|
|
621
|
+
: message.startsWith('Invalid args JSON:')
|
|
622
|
+
? 'If you are calling through a router/proxy, ensure tool arguments are a JSON object (not an array/string).'
|
|
623
|
+
: message.startsWith('Invalid args: expected a JSON object')
|
|
624
|
+
? 'Pass an object payload like { args:[...], options:{...} }. Some proxies require arguments to be a JSON string containing an object.'
|
|
625
|
+
: undefined
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
error: {
|
|
630
|
+
code: 'TOOL_ERROR',
|
|
631
|
+
message,
|
|
632
|
+
...(hint ? { hint } : {}),
|
|
633
|
+
retryable: false,
|
|
634
|
+
},
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const normalizePayload = (payload: unknown): { args: string[]; options: Record<string, unknown> } => {
|
|
639
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
640
|
+
|
|
641
|
+
if (normalized === null || normalized === undefined) {
|
|
642
|
+
return {
|
|
643
|
+
args: [],
|
|
644
|
+
options: {},
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!isRecord(normalized)) {
|
|
649
|
+
const kind = Array.isArray(normalized) ? 'array' : typeof normalized
|
|
650
|
+
throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const explicitArgs = Array.isArray(normalized.args) ? normalized.args : undefined
|
|
654
|
+
const explicitOptions = isRecord(normalized.options) ? normalized.options : undefined
|
|
655
|
+
|
|
656
|
+
const args = explicitArgs ? explicitArgs.map((entry) => String(entry)) : []
|
|
657
|
+
const options: Record<string, unknown> = explicitOptions ? { ...explicitOptions } : {}
|
|
658
|
+
|
|
659
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
660
|
+
if (key === 'args' || key === 'options') {
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (value !== undefined) {
|
|
665
|
+
options[key] = value
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
args,
|
|
671
|
+
options: normalizeNestedOptions(options),
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const OMITTED_OPTION_KEYS = new Set(['format', 'fields', 'validateOnly', 'full'])
|
|
676
|
+
|
|
677
|
+
const stripMcpOnlyOptions = (options: Record<string, unknown>): Record<string, unknown> => {
|
|
678
|
+
const next: Record<string, unknown> = {}
|
|
679
|
+
for (const [key, value] of Object.entries(options)) {
|
|
680
|
+
if (OMITTED_OPTION_KEYS.has(key)) continue
|
|
681
|
+
next[key] = value
|
|
682
|
+
}
|
|
683
|
+
return next
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const tryParseJson = (value: string): unknown => {
|
|
687
|
+
const trimmed = value.trim()
|
|
688
|
+
if (!trimmed) return undefined
|
|
689
|
+
try {
|
|
690
|
+
return JSON.parse(trimmed) as unknown
|
|
691
|
+
} catch (error) {
|
|
692
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
693
|
+
throw new Error(`Invalid nested JSON: ${message}`)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const normalizeNestedJsonOption = (value: unknown): unknown => {
|
|
698
|
+
if (typeof value !== 'string') return value
|
|
699
|
+
|
|
700
|
+
const trimmed = value.trim()
|
|
701
|
+
if (!trimmed) return value
|
|
702
|
+
|
|
703
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[') && trimmed !== 'null' && trimmed !== 'true' && trimmed !== 'false') {
|
|
704
|
+
return value
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return tryParseJson(trimmed)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const normalizeNestedOptions = (options: Record<string, unknown>): Record<string, unknown> => {
|
|
711
|
+
const next = { ...options }
|
|
712
|
+
|
|
713
|
+
if ('data' in next) {
|
|
714
|
+
next.data = normalizeNestedJsonOption(next.data)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if ('json' in next) {
|
|
718
|
+
next.json = normalizeNestedJsonOption(next.json)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if ('payload' in next) {
|
|
722
|
+
next.payload = normalizeNestedJsonOption(next.payload)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if ('query' in next) {
|
|
726
|
+
next.query = normalizeNestedJsonOption(next.query)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return next
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const applyFieldSelection = (data: unknown, fields: McpFieldSelection | null): unknown => {
|
|
733
|
+
if (!fields || fields.length === 0) return data
|
|
734
|
+
|
|
735
|
+
const pickFromRecord = (record: Record<string, unknown>): Record<string, unknown> => {
|
|
736
|
+
const out: Record<string, unknown> = {}
|
|
737
|
+
|
|
738
|
+
for (const field of fields) {
|
|
739
|
+
const parts = field.split('.').map((part) => part.trim()).filter(Boolean)
|
|
740
|
+
if (parts.length === 0) continue
|
|
741
|
+
|
|
742
|
+
let current: unknown = record
|
|
743
|
+
for (const part of parts) {
|
|
744
|
+
if (!isRecord(current)) {
|
|
745
|
+
current = undefined
|
|
746
|
+
break
|
|
747
|
+
}
|
|
748
|
+
current = (current as Record<string, unknown>)[part]
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (current === undefined) continue
|
|
752
|
+
|
|
753
|
+
let target: Record<string, unknown> = out
|
|
754
|
+
for (const part of parts.slice(0, -1)) {
|
|
755
|
+
if (!isRecord(target[part])) {
|
|
756
|
+
target[part] = {}
|
|
757
|
+
}
|
|
758
|
+
target = target[part] as Record<string, unknown>
|
|
759
|
+
}
|
|
760
|
+
target[parts[parts.length - 1]] = current
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return out
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (Array.isArray(data)) {
|
|
767
|
+
return data.map((entry) => (isRecord(entry) ? pickFromRecord(entry as Record<string, unknown>) : entry))
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (isRecord(data)) {
|
|
771
|
+
return pickFromRecord(data)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return data
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const normalizeBatchToolCall = (
|
|
778
|
+
call: unknown,
|
|
779
|
+
index: number,
|
|
780
|
+
): { tool: string; payload: ToolInvocationPayload } => {
|
|
781
|
+
if (!isRecord(call)) {
|
|
782
|
+
throw new Error(`Invalid batch call at index ${index}: expected object`)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const tool = typeof call.tool === 'string' ? call.tool.trim() : ''
|
|
786
|
+
if (!tool) {
|
|
787
|
+
throw new Error(`Invalid batch call at index ${index}: missing "tool"`)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const args = Array.isArray(call.args) ? call.args : []
|
|
791
|
+
const { options, ...extras } = call
|
|
792
|
+
const normalized: ToolInvocationPayload = {
|
|
793
|
+
args,
|
|
794
|
+
options: isRecord(options) ? options : {},
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
for (const [key, value] of Object.entries(extras)) {
|
|
798
|
+
if (value !== undefined) {
|
|
799
|
+
normalized.options[key] = value
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
tool,
|
|
805
|
+
payload: normalized,
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const normalizeBatchPayload = (payload: unknown): BatchToolCallPayload => {
|
|
810
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
811
|
+
if (!isRecord(normalized)) {
|
|
812
|
+
throw new Error('Batch tool call requires an object payload')
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (!Array.isArray(normalized.calls)) {
|
|
816
|
+
throw new Error('Batch tool call requires a "calls" array')
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const calls = (normalized.calls as unknown[]).map((call, index) => normalizeBatchToolCall(call, index))
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
calls: calls.map(({ tool, payload }) => ({
|
|
823
|
+
tool,
|
|
824
|
+
...payload,
|
|
825
|
+
})),
|
|
826
|
+
continueOnError: Boolean((normalized as Record<string, unknown>).continueOnError),
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const collectGitTools = (api: GitServiceApi, parentPath: string[] = []): ToolDefinition[] => {
|
|
831
|
+
const tools: ToolDefinition[] = []
|
|
832
|
+
|
|
833
|
+
for (const [segment, value] of Object.entries(api)) {
|
|
834
|
+
const currentPath = [...parentPath, segment]
|
|
835
|
+
|
|
836
|
+
if (typeof value === 'function') {
|
|
837
|
+
tools.push({
|
|
838
|
+
name: currentPath.join('.'),
|
|
839
|
+
path: currentPath,
|
|
840
|
+
method: value as GitServiceApiMethod,
|
|
841
|
+
})
|
|
842
|
+
continue
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (isRecord(value)) {
|
|
846
|
+
tools.push(...collectGitTools(value as GitServiceApi, currentPath))
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return tools
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export interface GitMcpServerOptions extends GitServiceApiFactoryOptions {
|
|
854
|
+
serverName?: string
|
|
855
|
+
serverVersion?: string
|
|
856
|
+
toolsPrefix?: string
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export type GitMcpServerInstance = {
|
|
860
|
+
api: GitServiceApi
|
|
861
|
+
tools: ToolDefinition[]
|
|
862
|
+
server: Server
|
|
863
|
+
listTools: () => McpToolListEntry[]
|
|
864
|
+
callTool: (toolName: string, payload: unknown) => Promise<{ isError: boolean; text: string }>
|
|
865
|
+
run: () => Promise<Server>
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const buildToolName = (tool: ToolDefinition, prefix?: string): string =>
|
|
869
|
+
prefix ? `${prefix}.${tool.name}` : tool.name
|
|
870
|
+
|
|
871
|
+
const isLogsForRunTailTool = (toolName: string): boolean => toolName.endsWith('jobs.logsForRunTail')
|
|
872
|
+
const isArtifactsByRunTool = (toolName: string): boolean => toolName.endsWith('runs.artifacts')
|
|
873
|
+
const isDiagnoseLatestFailureTool = (toolName: string): boolean =>
|
|
874
|
+
toolName.endsWith('diagnoseLatestFailure') && toolName.includes('.actions.')
|
|
875
|
+
|
|
876
|
+
const buildGenericToolSchema = (): Record<string, unknown> => ({
|
|
877
|
+
type: 'object',
|
|
878
|
+
additionalProperties: true,
|
|
879
|
+
properties: {
|
|
880
|
+
owner: {
|
|
881
|
+
type: 'string',
|
|
882
|
+
description: 'Repository owner/org. Optional if the server was started with defaults or context was set.',
|
|
883
|
+
},
|
|
884
|
+
repo: {
|
|
885
|
+
type: 'string',
|
|
886
|
+
description: 'Repository name. Optional if the server was started with defaults or context was set.',
|
|
887
|
+
},
|
|
888
|
+
args: {
|
|
889
|
+
type: 'array',
|
|
890
|
+
items: { type: 'string' },
|
|
891
|
+
description: 'Positional arguments for the git API method (strings are safest).',
|
|
892
|
+
},
|
|
893
|
+
options: {
|
|
894
|
+
type: 'object',
|
|
895
|
+
additionalProperties: true,
|
|
896
|
+
description: 'Method options (query/headers/body payload). Extra top-level keys are merged into options.',
|
|
897
|
+
},
|
|
898
|
+
validateOnly: {
|
|
899
|
+
type: 'boolean',
|
|
900
|
+
description:
|
|
901
|
+
'If true, do not execute the underlying HTTP request. Returns the normalized call payload (args/options) that would be sent.',
|
|
407
902
|
},
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
903
|
+
full: {
|
|
904
|
+
type: 'boolean',
|
|
905
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
411
906
|
},
|
|
907
|
+
fields: {
|
|
908
|
+
description:
|
|
909
|
+
'Optional field selection for the response body to reduce token usage. Accepts a string[] or a comma-separated string of dot-paths.',
|
|
910
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
911
|
+
},
|
|
912
|
+
format: {
|
|
913
|
+
type: 'string',
|
|
914
|
+
enum: ['terse', 'debug'],
|
|
915
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
const buildArtifactsByRunSchema = (): Record<string, unknown> => ({
|
|
921
|
+
type: 'object',
|
|
922
|
+
additionalProperties: true,
|
|
923
|
+
properties: {
|
|
924
|
+
owner: {
|
|
925
|
+
type: 'string',
|
|
926
|
+
description: 'Repository owner/org. Optional if the server was started with a default owner.',
|
|
927
|
+
},
|
|
928
|
+
repo: {
|
|
929
|
+
type: 'string',
|
|
930
|
+
description: 'Repository name. Optional if the server was started with a default repo.',
|
|
931
|
+
},
|
|
932
|
+
runId: {
|
|
933
|
+
description: 'Workflow run id (alias: run_id).',
|
|
934
|
+
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
|
|
935
|
+
},
|
|
936
|
+
run_id: {
|
|
937
|
+
description: 'Alias for runId.',
|
|
938
|
+
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
|
|
939
|
+
},
|
|
412
940
|
format: {
|
|
413
941
|
type: 'string',
|
|
414
942
|
enum: ['terse', 'debug'],
|
|
415
943
|
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
416
944
|
},
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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.',
|
|
945
|
+
full: {
|
|
946
|
+
type: 'boolean',
|
|
947
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
479
948
|
},
|
|
949
|
+
// Legacy positional forms:
|
|
950
|
+
// - Preferred by humans/LLMs: [owner, repo, runId]
|
|
951
|
+
// - Back-compat with the underlying helper signature: [runId, owner, repo]
|
|
952
|
+
args: {
|
|
953
|
+
type: 'array',
|
|
954
|
+
items: {},
|
|
955
|
+
description:
|
|
956
|
+
'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.',
|
|
957
|
+
},
|
|
958
|
+
options: {
|
|
959
|
+
type: 'object',
|
|
960
|
+
additionalProperties: true,
|
|
961
|
+
description: 'Options object (query/headers/body payload). Extra top-level keys are merged into options.',
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
anyOf: [{ required: ['runId'] }, { required: ['run_id'] }, { required: ['args'] }],
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
|
|
968
|
+
type: 'object',
|
|
969
|
+
additionalProperties: true,
|
|
970
|
+
properties: {
|
|
971
|
+
// Preferred named form (no positional confusion).
|
|
972
|
+
owner: {
|
|
973
|
+
type: 'string',
|
|
974
|
+
description: 'Repository owner/org. Optional if the server was started with a default owner.',
|
|
975
|
+
},
|
|
976
|
+
repo: {
|
|
977
|
+
type: 'string',
|
|
978
|
+
description: 'Repository name. Optional if the server was started with a default repo.',
|
|
979
|
+
},
|
|
980
|
+
headSha: {
|
|
981
|
+
type: 'string',
|
|
982
|
+
description: 'Commit SHA for the run (alias: head_sha).',
|
|
983
|
+
},
|
|
984
|
+
head_sha: {
|
|
985
|
+
type: 'string',
|
|
986
|
+
description: 'Alias for headSha.',
|
|
987
|
+
},
|
|
988
|
+
runNumber: {
|
|
989
|
+
type: 'integer',
|
|
990
|
+
minimum: 1,
|
|
991
|
+
description: 'Workflow run_number (alias: run_number).',
|
|
992
|
+
},
|
|
993
|
+
run_number: {
|
|
994
|
+
type: 'integer',
|
|
995
|
+
minimum: 1,
|
|
996
|
+
description: 'Alias for runNumber.',
|
|
997
|
+
},
|
|
998
|
+
maxLines: {
|
|
999
|
+
type: 'integer',
|
|
1000
|
+
minimum: 1,
|
|
1001
|
+
description: 'Max lines to return from the end of the logs.',
|
|
1002
|
+
},
|
|
1003
|
+
maxBytes: {
|
|
1004
|
+
type: 'integer',
|
|
1005
|
+
minimum: 1,
|
|
1006
|
+
description: 'Max bytes to return from the end of the logs.',
|
|
1007
|
+
},
|
|
1008
|
+
contains: {
|
|
1009
|
+
type: 'string',
|
|
1010
|
+
description: 'If set, only return log lines containing this substring.',
|
|
1011
|
+
},
|
|
480
1012
|
format: {
|
|
481
1013
|
type: 'string',
|
|
482
1014
|
enum: ['terse', 'debug'],
|
|
483
1015
|
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
484
1016
|
},
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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.',
|
|
1017
|
+
full: {
|
|
1018
|
+
type: 'boolean',
|
|
1019
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
542
1020
|
},
|
|
1021
|
+
|
|
1022
|
+
// Legacy / compatibility: allow calling with positional args.
|
|
1023
|
+
args: {
|
|
1024
|
+
type: 'array',
|
|
1025
|
+
items: {},
|
|
1026
|
+
description:
|
|
1027
|
+
'Legacy positional form. Preferred is named params. If used, pass [headSha, runNumber] (or the legacy-mistake [owner, repo] with options.headSha/options.runNumber).',
|
|
1028
|
+
},
|
|
1029
|
+
options: {
|
|
1030
|
+
type: 'object',
|
|
1031
|
+
additionalProperties: true,
|
|
1032
|
+
description:
|
|
1033
|
+
'Options object. Supports owner/repo, maxLines/maxBytes/contains, and format. Extra top-level keys are merged into options.',
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
anyOf: [
|
|
1037
|
+
{ required: ['headSha', 'runNumber'] },
|
|
1038
|
+
{ required: ['head_sha', 'run_number'] },
|
|
1039
|
+
{ required: ['headSha', 'run_number'] },
|
|
1040
|
+
{ required: ['head_sha', 'runNumber'] },
|
|
1041
|
+
],
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
const buildDiagnoseLatestFailureSchema = (): Record<string, unknown> => ({
|
|
1045
|
+
type: 'object',
|
|
1046
|
+
additionalProperties: true,
|
|
1047
|
+
properties: {
|
|
1048
|
+
owner: {
|
|
1049
|
+
type: 'string',
|
|
1050
|
+
description: 'Repository owner/org. Optional if the server was started with a default owner.',
|
|
1051
|
+
},
|
|
1052
|
+
repo: {
|
|
1053
|
+
type: 'string',
|
|
1054
|
+
description: 'Repository name. Optional if the server was started with a default repo.',
|
|
1055
|
+
},
|
|
1056
|
+
workflowName: {
|
|
1057
|
+
type: 'string',
|
|
1058
|
+
description: 'Optional filter: match failing runs by name/display_title (case-insensitive substring).',
|
|
1059
|
+
},
|
|
1060
|
+
limit: {
|
|
1061
|
+
type: 'integer',
|
|
1062
|
+
minimum: 1,
|
|
1063
|
+
description: 'How many tasks/runs to fetch before filtering (default: 50).',
|
|
1064
|
+
},
|
|
1065
|
+
maxLines: {
|
|
1066
|
+
type: 'integer',
|
|
1067
|
+
minimum: 1,
|
|
1068
|
+
description: 'Max lines to return from the end of the logs (default: 200).',
|
|
1069
|
+
},
|
|
1070
|
+
maxBytes: {
|
|
1071
|
+
type: 'integer',
|
|
1072
|
+
minimum: 1,
|
|
1073
|
+
description: 'Max bytes to return from the end of the logs.',
|
|
1074
|
+
},
|
|
1075
|
+
contains: {
|
|
1076
|
+
type: 'string',
|
|
1077
|
+
description: 'If set, only return log lines containing this substring.',
|
|
1078
|
+
},
|
|
543
1079
|
format: {
|
|
544
1080
|
type: 'string',
|
|
545
1081
|
enum: ['terse', 'debug'],
|
|
546
1082
|
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
547
1083
|
},
|
|
548
|
-
|
|
549
|
-
type: '
|
|
550
|
-
|
|
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.',
|
|
1084
|
+
full: {
|
|
1085
|
+
type: 'boolean',
|
|
1086
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
557
1087
|
},
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
:
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1088
|
+
args: {
|
|
1089
|
+
type: 'array',
|
|
1090
|
+
items: { type: 'string' },
|
|
1091
|
+
description: 'Legacy positional form: [owner, repo] plus options.workflowName/maxLines/etc.',
|
|
1092
|
+
},
|
|
1093
|
+
options: {
|
|
1094
|
+
type: 'object',
|
|
1095
|
+
additionalProperties: true,
|
|
1096
|
+
description: 'Options object. Extra top-level keys are merged into options.',
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
const buildToolInputSchema = (tool: ToolDefinition): Record<string, unknown> =>
|
|
1102
|
+
isLogsForRunTailTool(tool.name)
|
|
1103
|
+
? buildLogsForRunTailSchema()
|
|
1104
|
+
: isArtifactsByRunTool(tool.name)
|
|
1105
|
+
? buildArtifactsByRunSchema()
|
|
1106
|
+
: isDiagnoseLatestFailureTool(tool.name)
|
|
1107
|
+
? buildDiagnoseLatestFailureSchema()
|
|
1108
|
+
: buildGenericToolSchema()
|
|
1109
|
+
|
|
1110
|
+
const buildToolList = (
|
|
1111
|
+
tools: ToolDefinition[],
|
|
1112
|
+
batchToolName: string,
|
|
1113
|
+
prefix: string | undefined,
|
|
1114
|
+
customTools: McpToolListEntry[],
|
|
1115
|
+
) => {
|
|
1116
|
+
const toolNames: McpToolListEntry[] = tools.map((tool) => ({
|
|
1117
|
+
name: buildToolName(tool, prefix),
|
|
1118
|
+
description: `Call git API method ${tool.path.join('.')}`,
|
|
1119
|
+
inputSchema: buildToolInputSchema(tool),
|
|
1120
|
+
}))
|
|
1121
|
+
|
|
1122
|
+
const batchTool = {
|
|
1123
|
+
name: batchToolName,
|
|
1124
|
+
description:
|
|
1125
|
+
'Preferred mode for client calls: execute multiple MCP tool calls in one request with parallel execution.',
|
|
1126
|
+
inputSchema: {
|
|
1127
|
+
type: 'object',
|
|
1128
|
+
additionalProperties: true,
|
|
1129
|
+
properties: {
|
|
1130
|
+
calls: {
|
|
1131
|
+
type: 'array',
|
|
1132
|
+
minItems: 1,
|
|
1133
|
+
items: {
|
|
1134
|
+
type: 'object',
|
|
1135
|
+
additionalProperties: true,
|
|
1136
|
+
properties: {
|
|
1137
|
+
tool: {
|
|
1138
|
+
type: 'string',
|
|
1139
|
+
description: 'Full MCP tool name to execute',
|
|
1140
|
+
},
|
|
1141
|
+
args: {
|
|
1142
|
+
type: 'array',
|
|
1143
|
+
items: { type: 'string' },
|
|
1144
|
+
description: 'Positional args for the tool',
|
|
1145
|
+
},
|
|
1146
|
+
options: {
|
|
1147
|
+
type: 'object',
|
|
1148
|
+
additionalProperties: true,
|
|
1149
|
+
description: 'Tool invocation options',
|
|
1150
|
+
},
|
|
1151
|
+
validateOnly: {
|
|
1152
|
+
type: 'boolean',
|
|
1153
|
+
description: 'If true, validate and normalize without executing the underlying request.',
|
|
605
1154
|
},
|
|
606
|
-
|
|
607
|
-
type: '
|
|
608
|
-
|
|
609
|
-
description: 'Per-call output format (default: "terse").',
|
|
1155
|
+
full: {
|
|
1156
|
+
type: 'boolean',
|
|
1157
|
+
description: 'If true, return the unfiltered upstream response body for this call.',
|
|
610
1158
|
},
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1159
|
+
fields: {
|
|
1160
|
+
description:
|
|
1161
|
+
'Optional field selection for the response body (reduces token usage). Accepts a string[] or comma-separated string.',
|
|
1162
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
1163
|
+
},
|
|
1164
|
+
format: {
|
|
1165
|
+
type: 'string',
|
|
1166
|
+
enum: ['terse', 'debug'],
|
|
1167
|
+
description: 'Per-call output format (default: "terse").',
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
required: ['tool'],
|
|
1171
|
+
},
|
|
1172
|
+
description: 'List of tool calls to execute',
|
|
1173
|
+
},
|
|
1174
|
+
continueOnError: {
|
|
1175
|
+
type: 'boolean',
|
|
1176
|
+
description: 'Whether to continue when a call in the batch fails',
|
|
1177
|
+
default: false,
|
|
1178
|
+
},
|
|
1179
|
+
format: {
|
|
1180
|
+
type: 'string',
|
|
1181
|
+
enum: ['terse', 'debug'],
|
|
1182
|
+
description: 'Default output format for calls that do not specify one (default: "terse").',
|
|
1183
|
+
},
|
|
1184
|
+
validateOnly: {
|
|
617
1185
|
type: 'boolean',
|
|
618
|
-
description: '
|
|
619
|
-
default: false,
|
|
1186
|
+
description: 'If true, validate and normalize calls without executing them.',
|
|
620
1187
|
},
|
|
621
|
-
|
|
622
|
-
type: '
|
|
623
|
-
|
|
624
|
-
description: 'Default output format for calls that do not specify one (default: "terse").',
|
|
1188
|
+
full: {
|
|
1189
|
+
type: 'boolean',
|
|
1190
|
+
description: 'Default response mode for calls that do not specify one. If true, return unfiltered bodies.',
|
|
625
1191
|
},
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1192
|
+
fields: {
|
|
1193
|
+
description:
|
|
1194
|
+
'Default field selection for calls that do not specify one. Accepts a string[] or comma-separated string.',
|
|
1195
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
required: ['calls'],
|
|
1199
|
+
},
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
return [...toolNames, ...customTools, batchTool]
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const normalizeToolCallName = (prefix: string | undefined, toolName: string): string =>
|
|
1206
|
+
prefix ? toolName.replace(new RegExp(`^${prefix}\\.`), '') : toolName
|
|
1207
|
+
|
|
1208
|
+
const invokeTool = async (
|
|
1209
|
+
tool: ToolDefinition,
|
|
1210
|
+
payload: unknown,
|
|
1211
|
+
): Promise<GitServiceApiExecutionResult> => {
|
|
1212
|
+
const { args, options } = normalizePayload(payload)
|
|
1213
|
+
const cleanedOptions = stripMcpOnlyOptions(options)
|
|
1214
|
+
const invocationArgs: unknown[] = args
|
|
1215
|
+
|
|
1216
|
+
if (Object.keys(cleanedOptions).length > 0) {
|
|
1217
|
+
invocationArgs.push(cleanedOptions)
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return tool.method(...invocationArgs)
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const pickArgsFromNormalizedPayload = (normalized: unknown, record: Record<string, unknown>): unknown[] => {
|
|
1224
|
+
if (Array.isArray(normalized)) {
|
|
1225
|
+
return normalized
|
|
1226
|
+
}
|
|
1227
|
+
return Array.isArray(record.args) ? record.args : []
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const pickNestedRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
|
|
1231
|
+
|
|
1232
|
+
const normalizeQueryRecord = (query: unknown): Record<string, unknown> => {
|
|
1233
|
+
if (isRecord(query)) {
|
|
1234
|
+
return query
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (!Array.isArray(query)) {
|
|
1238
|
+
return {}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const merged: Record<string, unknown> = {}
|
|
1242
|
+
|
|
1243
|
+
for (const entry of query) {
|
|
1244
|
+
if (!isRecord(entry)) continue
|
|
1245
|
+
|
|
1246
|
+
const name = typeof entry.name === 'string' ? entry.name.trim() : ''
|
|
1247
|
+
if (name) {
|
|
1248
|
+
merged[name] = entry.value
|
|
1249
|
+
continue
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Support [{ headSha: "..." }, { runNumber: 11 }] style arrays.
|
|
1253
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
1254
|
+
if (value !== undefined) merged[key] = value
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return merged
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const invokeLogsForRunTailTool = async (tool: ToolDefinition, payload: unknown): Promise<GitServiceApiExecutionResult> => {
|
|
1262
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
1263
|
+
const record = isRecord(normalized) ? normalized : {}
|
|
1264
|
+
const args = pickArgsFromNormalizedPayload(normalized, record)
|
|
1265
|
+
const options = normalizeNestedOptions(pickRecord(record.options))
|
|
1266
|
+
const query = normalizeQueryRecord(options.query)
|
|
1267
|
+
const data = pickNestedRecord(options.data)
|
|
1268
|
+
|
|
1269
|
+
const headShaNamed = toTrimmedString(
|
|
1270
|
+
pickFirst(
|
|
1271
|
+
record.headSha,
|
|
1272
|
+
record.head_sha,
|
|
1273
|
+
options.headSha,
|
|
1274
|
+
options.head_sha,
|
|
1275
|
+
query.headSha,
|
|
1276
|
+
query.head_sha,
|
|
1277
|
+
data.headSha,
|
|
1278
|
+
data.head_sha,
|
|
1279
|
+
),
|
|
1280
|
+
)
|
|
1281
|
+
const runNumberNamed = pickFirst(
|
|
1282
|
+
toPositiveInteger(pickFirst(record.runNumber, record.run_number, options.runNumber, options.run_number)),
|
|
1283
|
+
toPositiveInteger(pickFirst(query.runNumber, query.run_number, data.runNumber, data.run_number)),
|
|
1284
|
+
null,
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
// Positional preferred legacy: [headSha, runNumber]
|
|
1288
|
+
const headShaPositional = args.length >= 1 ? toTrimmedString(args[0]) : ''
|
|
1289
|
+
const runNumberPositional = args.length >= 2 ? toPositiveInteger(args[1]) : null
|
|
1290
|
+
|
|
1291
|
+
// Legacy-mistake support: args=[owner, repo], options has headSha/runNumber.
|
|
1292
|
+
const shouldTreatArgsAsOwnerRepo =
|
|
1293
|
+
args.length >= 2 &&
|
|
1294
|
+
(!headShaPositional || !runNumberPositional) &&
|
|
1295
|
+
Boolean(headShaNamed) &&
|
|
1296
|
+
Boolean(runNumberNamed)
|
|
1297
|
+
|
|
1298
|
+
const ownerFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[0]) : ''
|
|
1299
|
+
const repoFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[1]) : ''
|
|
1300
|
+
|
|
1301
|
+
const owner =
|
|
1302
|
+
toTrimmedString(pickFirst(record.owner, options.owner, query.owner, data.owner, ownerFromArgs)) || undefined
|
|
1303
|
+
const repo = toTrimmedString(pickFirst(record.repo, options.repo, query.repo, data.repo, repoFromArgs)) || undefined
|
|
1304
|
+
|
|
1305
|
+
const sha = toTrimmedString(pickFirst(headShaNamed || null, headShaPositional || null)) || ''
|
|
1306
|
+
const run = pickFirst(runNumberNamed, runNumberPositional) ?? null
|
|
1307
|
+
|
|
1308
|
+
if (!sha || !run) {
|
|
1309
|
+
throw new Error(
|
|
1310
|
+
'headSha (string) and runNumber (positive integer) are required. Preferred invocation: { owner, repo, headSha, runNumber, maxLines }. (Compatibility: options.query.{headSha,runNumber} is also accepted.)',
|
|
1311
|
+
)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const containsFromQuery = typeof query.contains === 'string' ? query.contains : undefined
|
|
1315
|
+
const maxLinesFromQuery = toPositiveInteger(query.maxLines)
|
|
1316
|
+
const maxBytesFromQuery = toPositiveInteger(query.maxBytes)
|
|
1317
|
+
|
|
1318
|
+
const cleanedOptions = stripMcpOnlyOptions({
|
|
1319
|
+
...options,
|
|
1320
|
+
...(containsFromQuery ? { contains: containsFromQuery } : {}),
|
|
1321
|
+
...(maxLinesFromQuery ? { maxLines: maxLinesFromQuery } : {}),
|
|
1322
|
+
...(maxBytesFromQuery ? { maxBytes: maxBytesFromQuery } : {}),
|
|
1323
|
+
...(owner ? { owner } : {}),
|
|
1324
|
+
...(repo ? { repo } : {}),
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
return tool.method(sha, run, cleanedOptions)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const invokeArtifactsByRunTool = async (
|
|
1331
|
+
tool: ToolDefinition,
|
|
1332
|
+
payload: unknown,
|
|
1333
|
+
): Promise<GitServiceApiExecutionResult> => {
|
|
1334
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
1335
|
+
const record = isRecord(normalized) ? normalized : {}
|
|
1336
|
+
const args = pickArgsFromNormalizedPayload(normalized, record)
|
|
1337
|
+
const options = normalizeNestedOptions(pickRecord(record.options))
|
|
1338
|
+
|
|
1339
|
+
const runIdNamed = toTrimmedString(pickFirst(record.runId, record.run_id, options.runId, options.run_id)) || ''
|
|
1340
|
+
|
|
1341
|
+
const a0 = args.length >= 1 ? toTrimmedString(args[0]) : ''
|
|
1342
|
+
const a1 = args.length >= 2 ? toTrimmedString(args[1]) : ''
|
|
1343
|
+
const a2 = args.length >= 3 ? toTrimmedString(args[2]) : ''
|
|
1344
|
+
|
|
1345
|
+
// Heuristic:
|
|
1346
|
+
// - If named runId is set, use it.
|
|
1347
|
+
// - Else if args look like [owner, repo, runId], use that.
|
|
1348
|
+
// - Else treat args as legacy [runId, owner, repo] (or [runId] with defaults).
|
|
1349
|
+
const shouldTreatAsOwnerRepoRunId = Boolean(a0) && Boolean(a1) && Boolean(a2) && !runIdNamed
|
|
1350
|
+
|
|
1351
|
+
const owner =
|
|
1352
|
+
toTrimmedString(pickFirst(record.owner, options.owner, shouldTreatAsOwnerRepoRunId ? a0 : a1)) || undefined
|
|
1353
|
+
const repo =
|
|
1354
|
+
toTrimmedString(pickFirst(record.repo, options.repo, shouldTreatAsOwnerRepoRunId ? a1 : a2)) || undefined
|
|
1355
|
+
const runId = toTrimmedString(pickFirst(runIdNamed || null, shouldTreatAsOwnerRepoRunId ? a2 : a0)) || ''
|
|
1356
|
+
|
|
1357
|
+
if (!runId) {
|
|
1358
|
+
throw new Error('runId is required. Preferred invocation: { owner, repo, runId }.')
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const cleanedOptions = stripMcpOnlyOptions({
|
|
1362
|
+
...options,
|
|
1363
|
+
...(owner ? { owner } : {}),
|
|
1364
|
+
...(repo ? { repo } : {}),
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
// Underlying helper signature is (runId, owner?, repo?, options?).
|
|
1368
|
+
// We always pass runId first, and owner/repo if we have them.
|
|
1369
|
+
if (owner && repo) {
|
|
1370
|
+
return tool.method(runId, owner, repo, cleanedOptions)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (owner && !repo) {
|
|
1374
|
+
// Unusual: allow passing only owner explicitly.
|
|
1375
|
+
return tool.method(runId, owner, cleanedOptions)
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
return tool.method(runId, cleanedOptions)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpServerInstance => {
|
|
1382
|
+
const api = createGitServiceApi(options)
|
|
1383
|
+
const tools = collectGitTools(api)
|
|
1384
|
+
const prefix = options.toolsPrefix
|
|
1385
|
+
const batchToolName = prefix ? `${prefix}.batch` : 'batch'
|
|
1386
|
+
const contextSetToolName = prefix ? `${prefix}.context.set` : 'context.set'
|
|
1387
|
+
const contextGetToolName = prefix ? `${prefix}.context.get` : 'context.get'
|
|
1388
|
+
const searchToolsToolName = prefix ? `${prefix}.searchTools` : 'searchTools'
|
|
1389
|
+
const prPreflightToolName = prefix ? `${prefix}.pr.preflight` : 'pr.preflight'
|
|
1390
|
+
const prMergeAndVerifyToolName = prefix ? `${prefix}.pr.mergeAndVerify` : 'pr.mergeAndVerify'
|
|
1391
|
+
|
|
1392
|
+
const context: { owner?: string; repo?: string } = {
|
|
1393
|
+
owner: toTrimmedString(options.defaultOwner) || undefined,
|
|
1394
|
+
repo: toTrimmedString(options.defaultRepo) || undefined,
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const resolveOwnerRepo = (options: Record<string, unknown>): { owner?: string; repo?: string } => {
|
|
1398
|
+
const owner = toTrimmedString(pickFirst(options.owner, context.owner)) || undefined
|
|
1399
|
+
const repo = toTrimmedString(pickFirst(options.repo, context.repo)) || undefined
|
|
1400
|
+
return { owner, repo }
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const resolvePrNumber = (args: string[], options: Record<string, unknown>): string => {
|
|
1404
|
+
const fromArgs = args.length >= 1 ? toTrimmedString(args[0]) : ''
|
|
1405
|
+
const fromNamed = toTrimmedString(pickFirst(options.number, (options as Record<string, unknown>).prNumber, options.index))
|
|
1406
|
+
return fromArgs || fromNamed
|
|
655
1407
|
}
|
|
656
|
-
return Array.isArray(record.args) ? record.args : []
|
|
657
|
-
}
|
|
658
1408
|
|
|
659
|
-
const
|
|
1409
|
+
const resolvedConfig = pickRecord(options.config)
|
|
1410
|
+
const giteaHost = toTrimmedString(pickFirst(resolvedConfig.giteaHost, process.env.GITEA_HOST)) || ''
|
|
1411
|
+
const giteaToken = toTrimmedString(pickFirst(resolvedConfig.giteaToken, process.env.GITEA_TOKEN)) || ''
|
|
1412
|
+
const giteaApiBase = giteaHost ? normalizeGiteaApiBase(giteaHost) : ''
|
|
1413
|
+
|
|
1414
|
+
const checkPrBlockingIssues = async (
|
|
1415
|
+
owner: string | undefined,
|
|
1416
|
+
repo: string | undefined,
|
|
1417
|
+
prNumber: string,
|
|
1418
|
+
): Promise<{
|
|
1419
|
+
attempted: boolean
|
|
1420
|
+
ok: boolean
|
|
1421
|
+
status?: number
|
|
1422
|
+
blockingIssueNumbers: number[]
|
|
1423
|
+
details?: unknown
|
|
1424
|
+
reason?: string
|
|
1425
|
+
}> => {
|
|
1426
|
+
const issueNumber = toPositiveInteger(prNumber)
|
|
1427
|
+
if (!owner || !repo || !issueNumber) {
|
|
1428
|
+
return {
|
|
1429
|
+
attempted: false,
|
|
1430
|
+
ok: false,
|
|
1431
|
+
blockingIssueNumbers: [],
|
|
1432
|
+
reason: 'owner/repo/number is required',
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
660
1435
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1436
|
+
try {
|
|
1437
|
+
const result = await callIssueDependenciesApi(
|
|
1438
|
+
'GET',
|
|
1439
|
+
owner,
|
|
1440
|
+
repo,
|
|
1441
|
+
issueNumber,
|
|
1442
|
+
giteaHost || undefined,
|
|
1443
|
+
giteaToken || undefined,
|
|
1444
|
+
)
|
|
665
1445
|
|
|
666
|
-
|
|
667
|
-
|
|
1446
|
+
return {
|
|
1447
|
+
attempted: true,
|
|
1448
|
+
ok: result.ok,
|
|
1449
|
+
status: result.status,
|
|
1450
|
+
blockingIssueNumbers: result.ok ? extractDependencyIssueNumbers(result.body) : [],
|
|
1451
|
+
details: result.body,
|
|
1452
|
+
}
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
return {
|
|
1455
|
+
attempted: true,
|
|
1456
|
+
ok: false,
|
|
1457
|
+
blockingIssueNumbers: [],
|
|
1458
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
668
1461
|
}
|
|
669
1462
|
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1463
|
+
const checkPrHeadContainedInBase = async (
|
|
1464
|
+
owner: string | undefined,
|
|
1465
|
+
repo: string | undefined,
|
|
1466
|
+
baseRef: string,
|
|
1467
|
+
headSha: string,
|
|
1468
|
+
): Promise<{
|
|
1469
|
+
attempted: boolean
|
|
1470
|
+
ok: boolean
|
|
1471
|
+
status?: number
|
|
1472
|
+
contained: boolean | null
|
|
1473
|
+
details?: unknown
|
|
1474
|
+
reason?: string
|
|
1475
|
+
}> => {
|
|
1476
|
+
if (!owner || !repo || !baseRef || !headSha) {
|
|
1477
|
+
return {
|
|
1478
|
+
attempted: false,
|
|
1479
|
+
ok: false,
|
|
1480
|
+
contained: null,
|
|
1481
|
+
reason: 'owner/repo/baseRef/headSha is required',
|
|
1482
|
+
}
|
|
679
1483
|
}
|
|
680
1484
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1485
|
+
if (!giteaApiBase) {
|
|
1486
|
+
return {
|
|
1487
|
+
attempted: false,
|
|
1488
|
+
ok: false,
|
|
1489
|
+
contained: null,
|
|
1490
|
+
reason: 'Gitea API base is not configured',
|
|
1491
|
+
}
|
|
684
1492
|
}
|
|
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
1493
|
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
}
|
|
1494
|
+
const basehead = `${baseRef}...${headSha}`
|
|
1495
|
+
const requestUrl = `${giteaApiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/compare/${encodeURIComponent(basehead)}`
|
|
1496
|
+
const headers: Record<string, string> = {
|
|
1497
|
+
Accept: 'application/json',
|
|
1498
|
+
}
|
|
1499
|
+
if (giteaToken) {
|
|
1500
|
+
headers.Authorization = `token ${giteaToken}`
|
|
1501
|
+
}
|
|
789
1502
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1503
|
+
try {
|
|
1504
|
+
const response = await fetch(requestUrl, { method: 'GET', headers })
|
|
1505
|
+
const responseText = await response.text()
|
|
1506
|
+
let parsedBody: unknown = responseText
|
|
1507
|
+
try {
|
|
1508
|
+
parsedBody = JSON.parse(responseText)
|
|
1509
|
+
} catch {
|
|
1510
|
+
parsedBody = responseText
|
|
1511
|
+
}
|
|
795
1512
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1513
|
+
if (!response.ok) {
|
|
1514
|
+
return {
|
|
1515
|
+
attempted: true,
|
|
1516
|
+
ok: false,
|
|
1517
|
+
status: response.status,
|
|
1518
|
+
contained: null,
|
|
1519
|
+
details: parsedBody,
|
|
1520
|
+
reason: buildErrorMessage(response.status, parsedBody),
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
801
1523
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1524
|
+
return {
|
|
1525
|
+
attempted: true,
|
|
1526
|
+
ok: true,
|
|
1527
|
+
status: response.status,
|
|
1528
|
+
contained: inferHeadContainedFromCompareBody(parsedBody),
|
|
1529
|
+
details: parsedBody,
|
|
1530
|
+
}
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
return {
|
|
1533
|
+
attempted: true,
|
|
1534
|
+
ok: false,
|
|
1535
|
+
contained: null,
|
|
1536
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
805
1539
|
}
|
|
806
1540
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
{
|
|
818
|
-
|
|
819
|
-
|
|
1541
|
+
const normalizePayloadWithContext = (
|
|
1542
|
+
tool: ToolDefinition,
|
|
1543
|
+
payload: unknown,
|
|
1544
|
+
): { args: string[]; options: Record<string, unknown> } => {
|
|
1545
|
+
const normalized = normalizePayload(payload)
|
|
1546
|
+
const optionsWithDefaults = { ...normalized.options }
|
|
1547
|
+
const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
|
|
1548
|
+
if (owner && optionsWithDefaults.owner === undefined) optionsWithDefaults.owner = owner
|
|
1549
|
+
if (repo && optionsWithDefaults.repo === undefined) optionsWithDefaults.repo = repo
|
|
1550
|
+
|
|
1551
|
+
// LLM-friendly: allow { number: 123 } instead of args:[\"123\"] for PR tools.
|
|
1552
|
+
const args = [...normalized.args]
|
|
1553
|
+
if (args.length === 0 && tool.name.includes('.pr.') && tool.name.startsWith('repo.')) {
|
|
1554
|
+
const number = resolvePrNumber(args, optionsWithDefaults)
|
|
1555
|
+
if (number) {
|
|
1556
|
+
args.push(number)
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
return {
|
|
1561
|
+
args,
|
|
1562
|
+
options: optionsWithDefaults,
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const customTools: McpToolListEntry[] = [
|
|
1567
|
+
{
|
|
1568
|
+
name: contextSetToolName,
|
|
1569
|
+
description: 'Set default {owner, repo} for subsequent git tool calls in this session.',
|
|
1570
|
+
inputSchema: {
|
|
1571
|
+
type: 'object',
|
|
1572
|
+
additionalProperties: true,
|
|
1573
|
+
properties: {
|
|
1574
|
+
owner: { type: 'string', description: 'Default repository owner/org' },
|
|
1575
|
+
repo: { type: 'string', description: 'Default repository name' },
|
|
1576
|
+
args: {
|
|
1577
|
+
type: 'array',
|
|
1578
|
+
items: { type: 'string' },
|
|
1579
|
+
description: 'Legacy positional form: [owner, repo]',
|
|
1580
|
+
},
|
|
1581
|
+
format: {
|
|
1582
|
+
type: 'string',
|
|
1583
|
+
enum: ['terse', 'debug'],
|
|
1584
|
+
description: 'Output format. Default: "terse".',
|
|
1585
|
+
},
|
|
1586
|
+
full: {
|
|
1587
|
+
type: 'boolean',
|
|
1588
|
+
description: 'If true, return unfiltered helper payloads.',
|
|
1589
|
+
},
|
|
1590
|
+
},
|
|
1591
|
+
},
|
|
820
1592
|
},
|
|
821
1593
|
{
|
|
822
|
-
|
|
823
|
-
|
|
1594
|
+
name: contextGetToolName,
|
|
1595
|
+
description: 'Get the current default {owner, repo} for this session.',
|
|
1596
|
+
inputSchema: {
|
|
1597
|
+
type: 'object',
|
|
1598
|
+
additionalProperties: true,
|
|
1599
|
+
properties: {
|
|
1600
|
+
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1601
|
+
full: { type: 'boolean', description: 'If true, return unfiltered helper payloads.' },
|
|
1602
|
+
},
|
|
824
1603
|
},
|
|
825
1604
|
},
|
|
826
|
-
|
|
1605
|
+
{
|
|
1606
|
+
name: searchToolsToolName,
|
|
1607
|
+
description: 'Search available git MCP tools by substring (returns names + descriptions).',
|
|
1608
|
+
inputSchema: {
|
|
1609
|
+
type: 'object',
|
|
1610
|
+
additionalProperties: true,
|
|
1611
|
+
properties: {
|
|
1612
|
+
query: { type: 'string', description: 'Search query (substring match on tool name/path)' },
|
|
1613
|
+
limit: { type: 'integer', minimum: 1, description: 'Max matches to return (default: 20)' },
|
|
1614
|
+
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1615
|
+
full: { type: 'boolean', description: 'If true, return unfiltered helper payloads.' },
|
|
1616
|
+
},
|
|
1617
|
+
required: ['query'],
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
name: prPreflightToolName,
|
|
1622
|
+
description: 'Fetch PR metadata + checks + review artifacts in one call.',
|
|
1623
|
+
inputSchema: {
|
|
1624
|
+
type: 'object',
|
|
1625
|
+
additionalProperties: true,
|
|
1626
|
+
properties: {
|
|
1627
|
+
owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
|
|
1628
|
+
repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
|
|
1629
|
+
number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
|
|
1630
|
+
includeIssues: { type: 'boolean', description: 'If true, fetch referenced issues mentioned as "Fixes #123".' },
|
|
1631
|
+
checkBlockingIssues: {
|
|
1632
|
+
type: 'boolean',
|
|
1633
|
+
description: 'If true (default), detect issue dependencies that block this PR from being merged.',
|
|
1634
|
+
},
|
|
1635
|
+
validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
|
|
1636
|
+
full: { type: 'boolean', description: 'If true, return unfiltered PR/check/review payloads.' },
|
|
1637
|
+
fields: {
|
|
1638
|
+
description: 'Optional field selection applied to pr/checks/review bodies.',
|
|
1639
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
1640
|
+
},
|
|
1641
|
+
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1642
|
+
},
|
|
1643
|
+
anyOf: [{ required: ['number'] }, { required: ['args'] }],
|
|
1644
|
+
},
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
name: prMergeAndVerifyToolName,
|
|
1648
|
+
description: 'Merge a PR via hosting API and verify PR state transitions to merged/closed.',
|
|
1649
|
+
inputSchema: {
|
|
1650
|
+
type: 'object',
|
|
1651
|
+
additionalProperties: true,
|
|
1652
|
+
properties: {
|
|
1653
|
+
owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
|
|
1654
|
+
repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
|
|
1655
|
+
number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
|
|
1656
|
+
mergeMethod: { type: 'string', description: 'Merge method. Maps to Gitea merge Do field (default: "merge").' },
|
|
1657
|
+
maxAttempts: { type: 'integer', minimum: 1, description: 'Max poll attempts (default: 6)' },
|
|
1658
|
+
delayMs: { type: 'integer', minimum: 0, description: 'Delay between polls in ms (default: 1000)' },
|
|
1659
|
+
allowManualFinalize: {
|
|
1660
|
+
type: 'boolean',
|
|
1661
|
+
description: 'If true, attempt a manual metadata finalization when normal merge fails.',
|
|
1662
|
+
},
|
|
1663
|
+
manualMergeCommitSha: {
|
|
1664
|
+
type: 'string',
|
|
1665
|
+
description: 'Optional merge commit SHA used when finalizing with Do="manually-merged".',
|
|
1666
|
+
},
|
|
1667
|
+
validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
|
|
1668
|
+
full: { type: 'boolean', description: 'If true, return unfiltered merge/PR payloads.' },
|
|
1669
|
+
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1670
|
+
},
|
|
1671
|
+
anyOf: [{ required: ['number'] }, { required: ['args'] }],
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1674
|
+
]
|
|
1675
|
+
|
|
1676
|
+
const customToolMetaByName = new Map<string, McpToolListEntry>(customTools.map((tool) => [tool.name, tool]))
|
|
1677
|
+
|
|
1678
|
+
const sleep = async (ms: number): Promise<void> => {
|
|
1679
|
+
if (ms <= 0) return
|
|
1680
|
+
await new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const toOk = (
|
|
1684
|
+
data: unknown,
|
|
1685
|
+
format: McpToolOutputFormat,
|
|
1686
|
+
debug?: unknown,
|
|
1687
|
+
status: number = 0,
|
|
1688
|
+
full: boolean = true,
|
|
1689
|
+
): McpTerseOk => {
|
|
1690
|
+
const outputData = full ? data : compactResponseDataForDefaultMode(data)
|
|
1691
|
+
const envelope: McpTerseOk = {
|
|
1692
|
+
ok: true,
|
|
1693
|
+
data: outputData,
|
|
1694
|
+
meta: {
|
|
1695
|
+
status,
|
|
1696
|
+
},
|
|
1697
|
+
...(format === 'debug' && debug !== undefined ? { debug } : {}),
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
return redactSecretsForMcpOutput(envelope) as McpTerseOk
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const toErr = (error: McpTerseErr, format: McpToolOutputFormat, debug?: unknown): McpTerseErr => {
|
|
1704
|
+
const enriched: McpTerseErr = {
|
|
1705
|
+
...error,
|
|
1706
|
+
...(format === 'debug' && debug !== undefined ? { debug } : {}),
|
|
1707
|
+
}
|
|
1708
|
+
return redactSecretsForMcpOutput(enriched) as McpTerseErr
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const invokeCustomTool = async (
|
|
1712
|
+
toolName: string,
|
|
1713
|
+
payload: unknown,
|
|
1714
|
+
controls: McpCallControls,
|
|
1715
|
+
): Promise<{ isError: boolean; envelope: McpTerseOk | McpTerseErr }> => {
|
|
1716
|
+
const format = controls.format ?? 'terse'
|
|
1717
|
+
|
|
1718
|
+
if (toolName === contextGetToolName) {
|
|
1719
|
+
return {
|
|
1720
|
+
isError: false,
|
|
1721
|
+
envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format, undefined, 0, true),
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if (toolName === contextSetToolName) {
|
|
1726
|
+
const normalized = normalizePayload(payload)
|
|
1727
|
+
const ownerFromArgs = normalized.args.length >= 1 ? toTrimmedString(normalized.args[0]) : ''
|
|
1728
|
+
const repoFromArgs = normalized.args.length >= 2 ? toTrimmedString(normalized.args[1]) : ''
|
|
1729
|
+
|
|
1730
|
+
const owner = toTrimmedString(pickFirst(normalized.options.owner, ownerFromArgs)) || ''
|
|
1731
|
+
const repo = toTrimmedString(pickFirst(normalized.options.repo, repoFromArgs)) || ''
|
|
1732
|
+
|
|
1733
|
+
context.owner = owner || undefined
|
|
1734
|
+
context.repo = repo || undefined
|
|
1735
|
+
|
|
1736
|
+
return {
|
|
1737
|
+
isError: false,
|
|
1738
|
+
envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format, undefined, 0, true),
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (toolName === searchToolsToolName) {
|
|
1743
|
+
const normalized = normalizePayload(payload)
|
|
1744
|
+
const query = toTrimmedString(pickFirst(normalized.options.query, normalized.args[0])) || ''
|
|
1745
|
+
if (!query) {
|
|
1746
|
+
return {
|
|
1747
|
+
isError: true,
|
|
1748
|
+
envelope: toErr(
|
|
1749
|
+
{
|
|
1750
|
+
ok: false,
|
|
1751
|
+
error: {
|
|
1752
|
+
code: 'INVALID_INPUT',
|
|
1753
|
+
message: 'query is required. Example: { query: \"pr.merge\" }',
|
|
1754
|
+
retryable: false,
|
|
1755
|
+
},
|
|
1756
|
+
},
|
|
1757
|
+
format,
|
|
1758
|
+
),
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
const limitRaw = pickFirst(normalized.options.limit, normalized.args[1])
|
|
1763
|
+
const limit = toPositiveInteger(limitRaw) ?? 20
|
|
1764
|
+
|
|
1765
|
+
const haystack = [
|
|
1766
|
+
...toolByName.keys(),
|
|
1767
|
+
...customTools.map((tool) => tool.name),
|
|
1768
|
+
batchToolName,
|
|
1769
|
+
]
|
|
1770
|
+
const q = query.toLowerCase()
|
|
1771
|
+
const matches = haystack
|
|
1772
|
+
.filter((name) => name.toLowerCase().includes(q))
|
|
1773
|
+
.slice(0, limit)
|
|
1774
|
+
.map((name) => {
|
|
1775
|
+
const meta =
|
|
1776
|
+
toolByName.has(name)
|
|
1777
|
+
? { name, description: `Call git API method ${toolByName.get(name)?.path.join('.') ?? name}` }
|
|
1778
|
+
: customToolMetaByName.get(name) ?? (name === batchToolName ? { name, description: 'Batch tool' } : { name, description: '' })
|
|
1779
|
+
return meta
|
|
1780
|
+
})
|
|
1781
|
+
|
|
1782
|
+
return { isError: false, envelope: toOk({ matches }, format, undefined, 0, true) }
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
if (toolName === prPreflightToolName) {
|
|
1786
|
+
const normalized = normalizePayload(payload)
|
|
1787
|
+
const optionsWithDefaults = { ...normalized.options }
|
|
1788
|
+
const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
|
|
1789
|
+
if (owner) optionsWithDefaults.owner = owner
|
|
1790
|
+
if (repo) optionsWithDefaults.repo = repo
|
|
1791
|
+
|
|
1792
|
+
const number = resolvePrNumber(normalized.args, optionsWithDefaults)
|
|
1793
|
+
if (!number) {
|
|
1794
|
+
return {
|
|
1795
|
+
isError: true,
|
|
1796
|
+
envelope: toErr(
|
|
1797
|
+
{
|
|
1798
|
+
ok: false,
|
|
1799
|
+
error: {
|
|
1800
|
+
code: 'INVALID_INPUT',
|
|
1801
|
+
message: 'number is required. Example: { owner, repo, number: 766 } (or { args:[\"766\"], owner, repo }).',
|
|
1802
|
+
retryable: false,
|
|
1803
|
+
},
|
|
1804
|
+
},
|
|
1805
|
+
format,
|
|
1806
|
+
),
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const includeIssues = Boolean((optionsWithDefaults as Record<string, unknown>).includeIssues)
|
|
1811
|
+
const checkBlockingIssues =
|
|
1812
|
+
parseBoolean((optionsWithDefaults as Record<string, unknown>).checkBlockingIssues) ?? true
|
|
1813
|
+
|
|
1814
|
+
if (controls.validateOnly) {
|
|
1815
|
+
const toolPrefix = prefix ? `${prefix}.` : ''
|
|
1816
|
+
return {
|
|
1817
|
+
isError: false,
|
|
1818
|
+
envelope: toOk(
|
|
1819
|
+
{
|
|
1820
|
+
valid: true,
|
|
1821
|
+
owner: owner ?? null,
|
|
1822
|
+
repo: repo ?? null,
|
|
1823
|
+
number,
|
|
1824
|
+
calls: [
|
|
1825
|
+
{ tool: `${toolPrefix}repo.pr.view`, args: [number], options: { owner, repo } },
|
|
1826
|
+
{ tool: `${toolPrefix}repo.pr.checks`, args: [number], options: { owner, repo } },
|
|
1827
|
+
{ tool: `${toolPrefix}repo.pr.review`, args: [number], options: { owner, repo, method: 'GET' } },
|
|
1828
|
+
],
|
|
1829
|
+
includeIssues,
|
|
1830
|
+
checkBlockingIssues,
|
|
1831
|
+
},
|
|
1832
|
+
format,
|
|
1833
|
+
undefined,
|
|
1834
|
+
0,
|
|
1835
|
+
true,
|
|
1836
|
+
),
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
const callOptionsBase = stripMcpOnlyOptions(optionsWithDefaults)
|
|
1841
|
+
const callOptions: Record<string, unknown> = { ...callOptionsBase }
|
|
1842
|
+
delete (callOptions as any).includeIssues
|
|
1843
|
+
delete (callOptions as any).checkBlockingIssues
|
|
1844
|
+
delete (callOptions as any).number
|
|
1845
|
+
delete (callOptions as any).prNumber
|
|
1846
|
+
delete (callOptions as any).index
|
|
1847
|
+
|
|
1848
|
+
const pr = await (api as any).repo.pr.view(number, callOptions)
|
|
1849
|
+
const checks = await (api as any).repo.pr.checks(number, callOptions)
|
|
1850
|
+
const review = await (api as any).repo.pr.review(number, { ...callOptions, method: 'GET' })
|
|
1851
|
+
|
|
1852
|
+
const referencedIssueNumbers: number[] = []
|
|
1853
|
+
if (isRecord(pr.body) && typeof (pr.body as any).body === 'string') {
|
|
1854
|
+
const body = String((pr.body as any).body)
|
|
1855
|
+
const matches = body.matchAll(/\b(?:fixes|closes|resolves)\s+#(\d+)\b/gi)
|
|
1856
|
+
for (const match of matches) {
|
|
1857
|
+
const n = Number(match[1])
|
|
1858
|
+
if (Number.isFinite(n) && n > 0) referencedIssueNumbers.push(n)
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
const uniqueReferenced = Array.from(new Set(referencedIssueNumbers))
|
|
1863
|
+
const issues: unknown[] = []
|
|
1864
|
+
if (includeIssues && uniqueReferenced.length > 0) {
|
|
1865
|
+
for (const n of uniqueReferenced) {
|
|
1866
|
+
try {
|
|
1867
|
+
const issue = await (api as any).repo.issue.view(String(n), callOptions)
|
|
1868
|
+
issues.push(issue.body)
|
|
1869
|
+
} catch {
|
|
1870
|
+
// best effort
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
827
1874
|
|
|
828
|
-
|
|
1875
|
+
const blockerCheck = checkBlockingIssues ? await checkPrBlockingIssues(owner, repo, number) : null
|
|
1876
|
+
const blockingIssueNumbers = blockerCheck ? blockerCheck.blockingIssueNumbers : []
|
|
829
1877
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1878
|
+
if (blockingIssueNumbers.length > 0) {
|
|
1879
|
+
return {
|
|
1880
|
+
isError: true,
|
|
1881
|
+
envelope: toErr(
|
|
1882
|
+
{
|
|
1883
|
+
ok: false,
|
|
1884
|
+
error: {
|
|
1885
|
+
code: 'PR_BLOCKED_BY_ISSUES',
|
|
1886
|
+
message: 'Pull request is blocked by issue dependencies. Close all blocking issues before merging.',
|
|
1887
|
+
details: {
|
|
1888
|
+
blockingIssueNumbers,
|
|
1889
|
+
blockerCheck: {
|
|
1890
|
+
attempted: blockerCheck?.attempted ?? false,
|
|
1891
|
+
ok: blockerCheck?.ok ?? false,
|
|
1892
|
+
status: blockerCheck?.status,
|
|
1893
|
+
},
|
|
1894
|
+
},
|
|
1895
|
+
retryable: false,
|
|
1896
|
+
},
|
|
1897
|
+
},
|
|
1898
|
+
format,
|
|
1899
|
+
{ pr, checks, review, blockerCheck },
|
|
1900
|
+
),
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
833
1903
|
|
|
834
|
-
|
|
835
|
-
|
|
1904
|
+
const data = {
|
|
1905
|
+
pr: applyFieldSelection(pr.body, controls.fields),
|
|
1906
|
+
checks: applyFieldSelection(checks.body, controls.fields),
|
|
1907
|
+
review: applyFieldSelection(review.body, controls.fields),
|
|
1908
|
+
referencedIssueNumbers: uniqueReferenced,
|
|
1909
|
+
...(checkBlockingIssues ? { blockingIssueNumbers } : {}),
|
|
1910
|
+
...(includeIssues ? { issues } : {}),
|
|
1911
|
+
}
|
|
1912
|
+
const full = controls.full || Boolean(controls.fields)
|
|
1913
|
+
|
|
1914
|
+
const allOk = pr.ok && checks.ok && review.ok
|
|
1915
|
+
if (!allOk) {
|
|
1916
|
+
return {
|
|
1917
|
+
isError: true,
|
|
1918
|
+
envelope: toErr(
|
|
1919
|
+
{
|
|
1920
|
+
ok: false,
|
|
1921
|
+
error: {
|
|
1922
|
+
code: 'PREFLIGHT_FAILED',
|
|
1923
|
+
message: 'One or more preflight calls failed. See details.',
|
|
1924
|
+
details: {
|
|
1925
|
+
pr: { ok: pr.ok, status: pr.status },
|
|
1926
|
+
checks: { ok: checks.ok, status: checks.status },
|
|
1927
|
+
review: { ok: review.ok, status: review.status },
|
|
1928
|
+
...(checkBlockingIssues
|
|
1929
|
+
? {
|
|
1930
|
+
blockerCheck: {
|
|
1931
|
+
attempted: blockerCheck?.attempted ?? false,
|
|
1932
|
+
ok: blockerCheck?.ok ?? false,
|
|
1933
|
+
status: blockerCheck?.status,
|
|
1934
|
+
},
|
|
1935
|
+
}
|
|
1936
|
+
: {}),
|
|
1937
|
+
},
|
|
1938
|
+
retryable: false,
|
|
1939
|
+
},
|
|
1940
|
+
},
|
|
1941
|
+
format,
|
|
1942
|
+
{ pr, checks, review, blockerCheck },
|
|
1943
|
+
),
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
836
1946
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1947
|
+
return { isError: false, envelope: toOk(data, format, { pr, checks, review, blockerCheck }, 200, full) }
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (toolName === prMergeAndVerifyToolName) {
|
|
1951
|
+
const normalized = normalizePayload(payload)
|
|
1952
|
+
const optionsWithDefaults = { ...normalized.options }
|
|
1953
|
+
const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
|
|
1954
|
+
if (owner) optionsWithDefaults.owner = owner
|
|
1955
|
+
if (repo) optionsWithDefaults.repo = repo
|
|
1956
|
+
|
|
1957
|
+
const number = resolvePrNumber(normalized.args, optionsWithDefaults)
|
|
1958
|
+
if (!number) {
|
|
1959
|
+
return {
|
|
1960
|
+
isError: true,
|
|
1961
|
+
envelope: toErr(
|
|
1962
|
+
{
|
|
1963
|
+
ok: false,
|
|
1964
|
+
error: {
|
|
1965
|
+
code: 'INVALID_INPUT',
|
|
1966
|
+
message: 'number is required. Example: { owner, repo, number: 766 }',
|
|
1967
|
+
retryable: false,
|
|
1968
|
+
},
|
|
1969
|
+
},
|
|
1970
|
+
format,
|
|
1971
|
+
),
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
const maxAttempts = toPositiveInteger((optionsWithDefaults as Record<string, unknown>).maxAttempts) ?? 6
|
|
1976
|
+
const delayMs = toNonNegativeInteger((optionsWithDefaults as Record<string, unknown>).delayMs) ?? 1000
|
|
1977
|
+
const mergeMethod = toTrimmedString((optionsWithDefaults as Record<string, unknown>).mergeMethod) || 'merge'
|
|
1978
|
+
const allowManualFinalize = Boolean((optionsWithDefaults as Record<string, unknown>).allowManualFinalize)
|
|
1979
|
+
const manualMergeCommitSha =
|
|
1980
|
+
toTrimmedString(
|
|
1981
|
+
pickFirst(
|
|
1982
|
+
(optionsWithDefaults as Record<string, unknown>).manualMergeCommitSha,
|
|
1983
|
+
(optionsWithDefaults as Record<string, unknown>).mergeCommitSha,
|
|
1984
|
+
),
|
|
1985
|
+
) || ''
|
|
1986
|
+
|
|
1987
|
+
if (controls.validateOnly) {
|
|
1988
|
+
return {
|
|
1989
|
+
isError: false,
|
|
1990
|
+
envelope: toOk(
|
|
1991
|
+
{
|
|
1992
|
+
valid: true,
|
|
1993
|
+
owner: owner ?? null,
|
|
1994
|
+
repo: repo ?? null,
|
|
1995
|
+
number,
|
|
1996
|
+
mergeMethod,
|
|
1997
|
+
maxAttempts,
|
|
1998
|
+
delayMs,
|
|
1999
|
+
allowManualFinalize,
|
|
2000
|
+
manualMergeCommitSha: manualMergeCommitSha || null,
|
|
2001
|
+
},
|
|
2002
|
+
format,
|
|
2003
|
+
undefined,
|
|
2004
|
+
0,
|
|
2005
|
+
true,
|
|
2006
|
+
),
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
const callOptionsBase = stripMcpOnlyOptions(optionsWithDefaults)
|
|
2011
|
+
const callOptions: Record<string, unknown> = { ...callOptionsBase }
|
|
2012
|
+
delete (callOptions as any).maxAttempts
|
|
2013
|
+
delete (callOptions as any).delayMs
|
|
2014
|
+
delete (callOptions as any).number
|
|
2015
|
+
delete (callOptions as any).prNumber
|
|
2016
|
+
delete (callOptions as any).index
|
|
2017
|
+
delete (callOptions as any).mergeMethod
|
|
2018
|
+
delete (callOptions as any).allowManualFinalize
|
|
2019
|
+
delete (callOptions as any).manualMergeCommitSha
|
|
2020
|
+
delete (callOptions as any).mergeCommitSha
|
|
2021
|
+
|
|
2022
|
+
let mergeResult: GitServiceApiExecutionResult | null = null
|
|
2023
|
+
let lastMergeError: unknown = null
|
|
2024
|
+
let lastFailure: MergeFailureClassification | null = null
|
|
2025
|
+
let manualFinalize: Record<string, unknown> | null = null
|
|
2026
|
+
|
|
2027
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2028
|
+
mergeResult = await (api as any).repo.pr.merge(number, { ...callOptions, mergeMethod })
|
|
2029
|
+
if (mergeResult.ok && mergeResult.status < 400) {
|
|
2030
|
+
break
|
|
2031
|
+
}
|
|
861
2032
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
2033
|
+
lastMergeError = mergeResult.body
|
|
2034
|
+
lastFailure = classifyMergeFailure(mergeResult.status, mergeResult.body)
|
|
2035
|
+
if (!lastFailure.retryable) {
|
|
2036
|
+
break
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
await sleep(delayMs)
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
|
|
2043
|
+
const failure = classifyMergeFailure(mergeResult?.status, mergeResult?.body ?? lastMergeError)
|
|
2044
|
+
|
|
2045
|
+
if (failure.blockedByIssues) {
|
|
2046
|
+
return {
|
|
2047
|
+
isError: true,
|
|
2048
|
+
envelope: toErr(
|
|
2049
|
+
{
|
|
2050
|
+
ok: false,
|
|
2051
|
+
error: {
|
|
2052
|
+
code: 'PR_BLOCKED_BY_ISSUES',
|
|
2053
|
+
status: mergeResult?.status,
|
|
2054
|
+
message: failure.message,
|
|
2055
|
+
details: mergeResult?.body ?? lastMergeError,
|
|
2056
|
+
retryable: false,
|
|
2057
|
+
},
|
|
2058
|
+
meta: mergeResult?.status ? { status: mergeResult.status } : undefined,
|
|
2059
|
+
},
|
|
2060
|
+
format,
|
|
2061
|
+
{ mergeResult, lastFailure },
|
|
2062
|
+
),
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
866
2065
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
2066
|
+
if (allowManualFinalize) {
|
|
2067
|
+
const prBeforeFinalize = await (api as any).repo.pr.view(number, callOptions)
|
|
2068
|
+
const prBody = isRecord(prBeforeFinalize.body) ? (prBeforeFinalize.body as Record<string, unknown>) : null
|
|
2069
|
+
const baseRef = prBody && isRecord(prBody.base) ? toTrimmedString((prBody.base as Record<string, unknown>).ref) : ''
|
|
2070
|
+
const headSha = prBody && isRecord(prBody.head) ? toTrimmedString((prBody.head as Record<string, unknown>).sha) : ''
|
|
2071
|
+
|
|
2072
|
+
const containmentCheck = manualMergeCommitSha
|
|
2073
|
+
? {
|
|
2074
|
+
attempted: false,
|
|
2075
|
+
ok: true,
|
|
2076
|
+
contained: true,
|
|
2077
|
+
reason: 'manualMergeCommitSha provided by caller',
|
|
2078
|
+
}
|
|
2079
|
+
: await checkPrHeadContainedInBase(owner, repo, baseRef, headSha)
|
|
2080
|
+
|
|
2081
|
+
const canFinalize = manualMergeCommitSha.length > 0 || containmentCheck.contained === true
|
|
2082
|
+
manualFinalize = {
|
|
2083
|
+
attempted: true,
|
|
2084
|
+
canFinalize,
|
|
2085
|
+
manualMergeCommitSha: manualMergeCommitSha || null,
|
|
2086
|
+
baseRef: baseRef || null,
|
|
2087
|
+
headSha: headSha || null,
|
|
2088
|
+
containmentCheck,
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
if (canFinalize) {
|
|
2092
|
+
const finalizeBody: Record<string, unknown> = {
|
|
2093
|
+
Do: 'manually-merged',
|
|
2094
|
+
}
|
|
2095
|
+
if (manualMergeCommitSha) {
|
|
2096
|
+
finalizeBody.MergeCommitID = manualMergeCommitSha
|
|
2097
|
+
}
|
|
872
2098
|
|
|
873
|
-
|
|
2099
|
+
const manualMergeResult = await (api as any).repo.pr.merge(number, {
|
|
2100
|
+
...callOptions,
|
|
2101
|
+
mergeMethod: 'manually-merged',
|
|
2102
|
+
requestBody: finalizeBody,
|
|
2103
|
+
})
|
|
2104
|
+
|
|
2105
|
+
if (manualMergeResult.ok && manualMergeResult.status < 400) {
|
|
2106
|
+
mergeResult = manualMergeResult
|
|
2107
|
+
manualFinalize = {
|
|
2108
|
+
...manualFinalize,
|
|
2109
|
+
mode: 'manually-merged',
|
|
2110
|
+
status: manualMergeResult.status,
|
|
2111
|
+
ok: true,
|
|
2112
|
+
}
|
|
2113
|
+
} else {
|
|
2114
|
+
const finalizeFailure = classifyMergeFailure(manualMergeResult.status, manualMergeResult.body)
|
|
874
2115
|
return {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
2116
|
+
isError: true,
|
|
2117
|
+
envelope: toErr(
|
|
2118
|
+
{
|
|
2119
|
+
ok: false,
|
|
2120
|
+
error: {
|
|
2121
|
+
code: finalizeFailure.blockedByIssues ? 'PR_BLOCKED_BY_ISSUES' : 'MERGE_FINALIZE_FAILED',
|
|
2122
|
+
status: manualMergeResult.status,
|
|
2123
|
+
message: finalizeFailure.message,
|
|
2124
|
+
details: {
|
|
2125
|
+
merge: manualMergeResult.body,
|
|
2126
|
+
manualFinalize,
|
|
2127
|
+
},
|
|
2128
|
+
retryable: finalizeFailure.retryable,
|
|
2129
|
+
},
|
|
2130
|
+
meta: manualMergeResult.status ? { status: manualMergeResult.status } : undefined,
|
|
2131
|
+
},
|
|
2132
|
+
format,
|
|
2133
|
+
{ mergeResult, manualMergeResult, manualFinalize, prBeforeFinalize },
|
|
2134
|
+
),
|
|
888
2135
|
}
|
|
889
|
-
throw error
|
|
890
2136
|
}
|
|
891
|
-
}
|
|
892
|
-
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
893
2139
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
2140
|
+
if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
|
|
2141
|
+
return {
|
|
2142
|
+
isError: true,
|
|
2143
|
+
envelope: toErr(
|
|
2144
|
+
{
|
|
2145
|
+
ok: false,
|
|
2146
|
+
error: {
|
|
2147
|
+
code: failure.code,
|
|
2148
|
+
status: mergeResult?.status,
|
|
2149
|
+
message: failure.message,
|
|
2150
|
+
details: {
|
|
2151
|
+
merge: mergeResult?.body ?? lastMergeError,
|
|
2152
|
+
manualFinalize,
|
|
2153
|
+
},
|
|
2154
|
+
hint:
|
|
2155
|
+
allowManualFinalize && !manualMergeCommitSha
|
|
2156
|
+
? 'Set allowManualFinalize=true and provide manualMergeCommitSha when the PR is already merged outside the API.'
|
|
2157
|
+
: undefined,
|
|
2158
|
+
retryable: failure.retryable,
|
|
2159
|
+
},
|
|
2160
|
+
meta: mergeResult?.status ? { status: mergeResult.status } : undefined,
|
|
2161
|
+
},
|
|
2162
|
+
format,
|
|
2163
|
+
{ mergeResult, lastFailure, manualFinalize },
|
|
2164
|
+
),
|
|
2165
|
+
}
|
|
902
2166
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
2167
|
+
|
|
2168
|
+
lastFailure = null
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const views: GitServiceApiExecutionResult[] = []
|
|
2172
|
+
let prAfter: GitServiceApiExecutionResult | null = null
|
|
2173
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2174
|
+
prAfter = await (api as any).repo.pr.view(number, callOptions)
|
|
2175
|
+
views.push(prAfter)
|
|
2176
|
+
|
|
2177
|
+
const merged = isRecord(prAfter.body) ? Boolean((prAfter.body as any).merged) : false
|
|
2178
|
+
const state = isRecord(prAfter.body) && typeof (prAfter.body as any).state === 'string' ? String((prAfter.body as any).state) : ''
|
|
2179
|
+
if (merged || state.toLowerCase() === 'closed') {
|
|
2180
|
+
break
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
await sleep(delayMs)
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
const data = {
|
|
2187
|
+
merge: mergeResult.body,
|
|
2188
|
+
pr: prAfter ? prAfter.body : null,
|
|
2189
|
+
polled: views.length,
|
|
2190
|
+
...(manualFinalize ? { finalization: manualFinalize } : {}),
|
|
2191
|
+
}
|
|
2192
|
+
const full = controls.full
|
|
2193
|
+
|
|
2194
|
+
const merged = prAfter && isRecord(prAfter.body) ? Boolean((prAfter.body as any).merged) : false
|
|
2195
|
+
const state = prAfter && isRecord(prAfter.body) && typeof (prAfter.body as any).state === 'string' ? String((prAfter.body as any).state) : ''
|
|
2196
|
+
|
|
2197
|
+
if (!merged && state.toLowerCase() !== 'closed') {
|
|
2198
|
+
return {
|
|
2199
|
+
isError: true,
|
|
2200
|
+
envelope: toErr(
|
|
907
2201
|
{
|
|
908
|
-
|
|
909
|
-
|
|
2202
|
+
ok: false,
|
|
2203
|
+
error: {
|
|
2204
|
+
code: 'MERGE_VERIFY_FAILED',
|
|
2205
|
+
message: 'Merge request succeeded, but PR state did not transition to merged/closed within polling window.',
|
|
2206
|
+
details: { merged, state },
|
|
2207
|
+
retryable: true,
|
|
2208
|
+
},
|
|
910
2209
|
},
|
|
911
|
-
|
|
2210
|
+
format,
|
|
2211
|
+
{ mergeResult, prAfter, views, manualFinalize },
|
|
2212
|
+
),
|
|
912
2213
|
}
|
|
913
2214
|
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const tool = toolByName.get(requestedName)
|
|
917
2215
|
|
|
918
|
-
|
|
919
|
-
throw new Error(`Unknown tool: ${requestedName}`)
|
|
2216
|
+
return { isError: false, envelope: toOk(data, format, { mergeResult, prAfter, views, manualFinalize }, 200, full) }
|
|
920
2217
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
2218
|
+
|
|
2219
|
+
return {
|
|
2220
|
+
isError: true,
|
|
2221
|
+
envelope: toErr(
|
|
2222
|
+
{
|
|
2223
|
+
ok: false,
|
|
2224
|
+
error: { code: 'UNKNOWN_TOOL', message: `Unknown tool: ${toolName}`, retryable: false },
|
|
2225
|
+
},
|
|
2226
|
+
format,
|
|
2227
|
+
),
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const server = new Server(
|
|
2232
|
+
{
|
|
2233
|
+
name: options.serverName ?? 'git',
|
|
2234
|
+
version: options.serverVersion ?? '1.0.0',
|
|
2235
|
+
},
|
|
2236
|
+
{
|
|
2237
|
+
capabilities: {
|
|
2238
|
+
tools: {},
|
|
2239
|
+
},
|
|
2240
|
+
},
|
|
2241
|
+
)
|
|
2242
|
+
|
|
2243
|
+
const toolByName = new Map<string, ToolDefinition>(tools.map((tool) => [buildToolName(tool, prefix), tool]))
|
|
2244
|
+
|
|
2245
|
+
const listTools = (): McpToolListEntry[] => buildToolList(tools, batchToolName, prefix, customTools)
|
|
2246
|
+
|
|
2247
|
+
const callTool = async (requestedName: string, payload: unknown): Promise<{ isError: boolean; text: string }> => {
|
|
2248
|
+
if (customToolMetaByName.has(requestedName)) {
|
|
2249
|
+
const controls = extractMcpControls(payload)
|
|
2250
|
+
const { isError, envelope } = await invokeCustomTool(requestedName, payload, controls)
|
|
2251
|
+
return { isError, text: JSON.stringify(envelope) }
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
if (requestedName === batchToolName) {
|
|
2255
|
+
const { calls, continueOnError } = normalizeBatchPayload(payload)
|
|
2256
|
+
const batchControls = extractMcpControls(payload)
|
|
2257
|
+
|
|
2258
|
+
const executions = calls.map(({ tool, args = [], options = {} }, index) => ({ tool, args, options, index }))
|
|
2259
|
+
const results = await Promise.all(
|
|
2260
|
+
executions.map(async ({ tool, args, options, index }) => {
|
|
2261
|
+
if (customToolMetaByName.has(tool)) {
|
|
2262
|
+
const mergedPayload = { args, options }
|
|
2263
|
+
const callControls = extractMcpControls(mergedPayload)
|
|
2264
|
+
const effectiveControls = {
|
|
2265
|
+
...callControls,
|
|
2266
|
+
format: callControls.format ?? batchControls.format ?? null,
|
|
2267
|
+
fields: callControls.fields ?? batchControls.fields ?? null,
|
|
2268
|
+
validateOnly: callControls.validateOnly || batchControls.validateOnly,
|
|
2269
|
+
full: callControls.full || batchControls.full,
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
try {
|
|
2273
|
+
const { isError, envelope } = await invokeCustomTool(tool, mergedPayload, effectiveControls)
|
|
2274
|
+
return {
|
|
2275
|
+
index,
|
|
2276
|
+
tool,
|
|
2277
|
+
isError,
|
|
2278
|
+
...(envelope as McpTerseOk | McpTerseErr),
|
|
2279
|
+
} as BatchResult
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
if (continueOnError) {
|
|
2282
|
+
return {
|
|
2283
|
+
index,
|
|
2284
|
+
tool,
|
|
2285
|
+
isError: true,
|
|
2286
|
+
...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
|
|
2287
|
+
} as BatchResult
|
|
2288
|
+
}
|
|
2289
|
+
throw error
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
const toolDefinition = toolByName.get(tool)
|
|
2294
|
+
if (!toolDefinition) {
|
|
2295
|
+
return {
|
|
2296
|
+
index,
|
|
2297
|
+
tool,
|
|
2298
|
+
isError: true,
|
|
2299
|
+
...({
|
|
2300
|
+
ok: false,
|
|
2301
|
+
error: {
|
|
2302
|
+
code: 'UNKNOWN_TOOL',
|
|
2303
|
+
message: `Unknown tool: ${tool}`,
|
|
2304
|
+
retryable: false,
|
|
2305
|
+
},
|
|
2306
|
+
} satisfies McpTerseErr),
|
|
2307
|
+
} as BatchResult
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
try {
|
|
2311
|
+
const mergedPayload = { args, options }
|
|
2312
|
+
const callControls = extractMcpControls(mergedPayload)
|
|
2313
|
+
const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
|
|
2314
|
+
const effectiveFields = callControls.fields ?? batchControls.fields ?? null
|
|
2315
|
+
const validateOnly = callControls.validateOnly || batchControls.validateOnly
|
|
2316
|
+
const full = callControls.full || batchControls.full
|
|
2317
|
+
|
|
2318
|
+
if (validateOnly) {
|
|
2319
|
+
const normalized = normalizePayloadWithContext(toolDefinition, mergedPayload)
|
|
2320
|
+
const envelope: McpTerseOk = {
|
|
2321
|
+
ok: true,
|
|
2322
|
+
data: {
|
|
2323
|
+
valid: true,
|
|
2324
|
+
tool,
|
|
2325
|
+
args: normalized.args,
|
|
2326
|
+
options: stripMcpOnlyOptions(normalized.options),
|
|
2327
|
+
},
|
|
2328
|
+
meta: {
|
|
2329
|
+
status: 0,
|
|
2330
|
+
},
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
return {
|
|
2334
|
+
index,
|
|
2335
|
+
tool,
|
|
2336
|
+
isError: false,
|
|
2337
|
+
...(redactSecretsForMcpOutput(envelope) as McpTerseOk),
|
|
2338
|
+
} as BatchResult
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
const normalized = normalizePayloadWithContext(toolDefinition, mergedPayload)
|
|
2342
|
+
const normalizedPayload = { args: normalized.args, options: normalized.options }
|
|
2343
|
+
|
|
2344
|
+
const data = await (isLogsForRunTailTool(toolDefinition.name)
|
|
2345
|
+
? invokeLogsForRunTailTool(toolDefinition, normalizedPayload)
|
|
2346
|
+
: isArtifactsByRunTool(toolDefinition.name)
|
|
2347
|
+
? invokeArtifactsByRunTool(toolDefinition, normalizedPayload)
|
|
2348
|
+
: invokeTool(toolDefinition, normalizedPayload))
|
|
2349
|
+
|
|
2350
|
+
const selected = effectiveFields ? { ...data, body: applyFieldSelection(data.body, effectiveFields) } : data
|
|
2351
|
+
const shouldReturnFull = full || Boolean(effectiveFields)
|
|
2352
|
+
const { isError, envelope } = toMcpEnvelope(selected, effectiveFormat, shouldReturnFull)
|
|
2353
|
+
return {
|
|
2354
|
+
index,
|
|
2355
|
+
tool,
|
|
2356
|
+
isError,
|
|
2357
|
+
...(envelope as McpTerseOk | McpTerseErr),
|
|
2358
|
+
} as BatchResult
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
if (continueOnError) {
|
|
2361
|
+
return {
|
|
2362
|
+
index,
|
|
2363
|
+
tool,
|
|
2364
|
+
isError: true,
|
|
2365
|
+
...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
|
|
2366
|
+
} as BatchResult
|
|
2367
|
+
}
|
|
2368
|
+
throw error
|
|
2369
|
+
}
|
|
2370
|
+
}),
|
|
2371
|
+
)
|
|
2372
|
+
|
|
2373
|
+
return {
|
|
2374
|
+
isError: results.some((result) => result.isError),
|
|
2375
|
+
text: JSON.stringify(redactSecretsForMcpOutput(results)),
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const tool = toolByName.get(requestedName)
|
|
2380
|
+
if (!tool) {
|
|
2381
|
+
throw new Error(`Unknown tool: ${requestedName}`)
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const controls = extractMcpControls(payload)
|
|
2385
|
+
|
|
2386
|
+
if (controls.validateOnly) {
|
|
2387
|
+
const normalized = normalizePayloadWithContext(tool, payload)
|
|
2388
|
+
const envelope: McpTerseOk = {
|
|
2389
|
+
ok: true,
|
|
2390
|
+
data: {
|
|
2391
|
+
valid: true,
|
|
2392
|
+
tool: requestedName,
|
|
2393
|
+
args: normalized.args,
|
|
2394
|
+
options: stripMcpOnlyOptions(normalized.options),
|
|
2395
|
+
},
|
|
2396
|
+
meta: {
|
|
2397
|
+
status: 0,
|
|
2398
|
+
},
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
return { isError: false, text: JSON.stringify(redactSecretsForMcpOutput(envelope)) }
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
const normalized = normalizePayloadWithContext(tool, payload)
|
|
2405
|
+
const normalizedPayload = { args: normalized.args, options: normalized.options }
|
|
2406
|
+
|
|
2407
|
+
const result = await (isLogsForRunTailTool(tool.name)
|
|
2408
|
+
? invokeLogsForRunTailTool(tool, normalizedPayload)
|
|
2409
|
+
: isArtifactsByRunTool(tool.name)
|
|
2410
|
+
? invokeArtifactsByRunTool(tool, normalizedPayload)
|
|
2411
|
+
: invokeTool(tool, normalizedPayload))
|
|
2412
|
+
|
|
2413
|
+
const selected = controls.fields ? { ...result, body: applyFieldSelection(result.body, controls.fields) } : result
|
|
2414
|
+
const shouldReturnFull = controls.full || Boolean(controls.fields)
|
|
2415
|
+
const { isError, envelope } = toMcpEnvelope(selected, controls.format ?? 'terse', shouldReturnFull)
|
|
2416
|
+
return { isError, text: JSON.stringify(envelope) }
|
|
956
2417
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
2418
|
+
|
|
2419
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2420
|
+
tools: listTools(),
|
|
2421
|
+
}))
|
|
2422
|
+
|
|
2423
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2424
|
+
try {
|
|
2425
|
+
const result = await callTool(request.params.name, request.params.arguments)
|
|
2426
|
+
return {
|
|
2427
|
+
...(result.isError ? { isError: true } : {}),
|
|
2428
|
+
content: [
|
|
2429
|
+
{
|
|
2430
|
+
type: 'text',
|
|
2431
|
+
text: result.text,
|
|
2432
|
+
},
|
|
2433
|
+
],
|
|
2434
|
+
}
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
if (error instanceof Error && error.message.startsWith('Unknown tool:')) {
|
|
2437
|
+
throw error
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
return {
|
|
2441
|
+
isError: true,
|
|
2442
|
+
content: [
|
|
2443
|
+
{
|
|
2444
|
+
type: 'text',
|
|
2445
|
+
text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
|
|
2446
|
+
},
|
|
2447
|
+
],
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
})
|
|
2451
|
+
|
|
2452
|
+
const run = async (): Promise<Server> => {
|
|
2453
|
+
await server.connect(new StdioServerTransport())
|
|
2454
|
+
return server
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
return { api, tools, server, listTools, callTool, run }
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
export const runGitMcpServer = async (options: GitMcpServerOptions = {}): Promise<Server> => {
|
|
2461
|
+
const instance = createGitMcpServer(options)
|
|
2462
|
+
return instance.run()
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
export const normalizeToolCallNameForServer = (
|
|
2466
|
+
prefix: string | undefined,
|
|
2467
|
+
toolName: string,
|
|
2468
|
+
): string => normalizeToolCallName(prefix, toolName)
|