@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/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 BatchToolCall = {
22
- tool: string
23
- args?: unknown[]
24
- options?: Record<string, unknown>
25
- [key: string]: unknown
26
- }
21
+ type McpFieldSelection = string[]
27
22
 
28
- type BatchToolCallPayload = {
29
- calls: BatchToolCall[]
30
- continueOnError: boolean
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
- type BatchResult = {
34
- index: number
35
- tool: string
36
- isError: boolean
37
- } & (McpTerseOk | McpTerseErr)
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
- type ToolDefinition = {
40
- name: string
41
- path: string[]
42
- method: GitServiceApiMethod
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 isRecord = (value: unknown): value is Record<string, unknown> =>
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(trimmed) as unknown
54
- } catch (error) {
55
- const message = error instanceof Error ? error.message : String(error)
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 normalizeArgumentPayload = (payload: unknown): unknown => {
61
- if (typeof payload === 'string' || payload instanceof String) {
62
- const parsed = tryParseJsonObject(String(payload))
63
- if (!isRecord(parsed)) {
64
- const kind = Array.isArray(parsed) ? 'array' : typeof parsed
65
- throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
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
- return payload
71
- }
249
+ if (typeof body.error === 'string' && body.error.trim()) {
250
+ return body.error.trim()
251
+ }
72
252
 
73
- const toTrimmedString = (value: unknown): string => (value === null || value === undefined ? '' : String(value)).trim()
253
+ if (isRecord(body.error)) {
254
+ const nested = extractBodyMessage(body.error)
255
+ if (nested) return nested
256
+ }
257
+ }
74
258
 
75
- const toPositiveInteger = (value: unknown): number | null => {
76
- if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
77
- return Math.floor(value)
259
+ if (typeof body !== 'string') {
260
+ return null
78
261
  }
79
262
 
80
- if (typeof value !== 'string') return null
263
+ const trimmed = body.trim()
264
+ if (!trimmed) {
265
+ return null
266
+ }
81
267
 
82
- const trimmed = value.trim()
83
- if (!trimmed) return null
84
- const parsed = Number(trimmed)
85
- if (!Number.isFinite(parsed) || parsed <= 0) return null
86
- return Math.floor(parsed)
87
- }
268
+ const parsed =
269
+ trimmed.startsWith('{') || trimmed.startsWith('[')
270
+ ? tryParseJsonLikeString(trimmed)
271
+ : null
88
272
 
89
- const pickFirst = <T>(...candidates: Array<T | null | undefined>): T | null => {
90
- for (const candidate of candidates) {
91
- if (candidate !== null && candidate !== undefined) return candidate
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 pickRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
278
+ const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ''
279
+ if (firstLine === '{' || firstLine === '[') {
280
+ return null
281
+ }
97
282
 
98
- const parseOutputFormat = (value: unknown): McpToolOutputFormat | null => {
99
- if (value === null || value === undefined) return null
100
- const raw = toTrimmedString(value).toLowerCase()
101
- if (!raw) return null
102
- if (raw === 'debug') return 'debug'
103
- if (raw === 'terse') return 'terse'
104
- return null
283
+ return firstLine.length > 0 ? firstLine : null
105
284
  }
106
285
 
107
- const extractOutputControls = (payload: unknown): { format: McpToolOutputFormat | null } => {
108
- const normalized = normalizeArgumentPayload(payload)
109
- if (!isRecord(normalized)) {
110
- return { format: null }
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
- const topLevelFormat = parseOutputFormat(normalized.format)
114
- const optionsFormat = parseOutputFormat(pickRecord(normalized.options).format)
292
+ if (typeof status === 'number') {
293
+ return `HTTP ${status}`
294
+ }
115
295
 
116
- // Support either { format:"debug" } or { options:{ format:"debug" } }.
117
- return { format: pickFirst(topLevelFormat, optionsFormat) }
296
+ return 'Request failed'
118
297
  }
119
298
 
120
- type McpTerseOk = {
121
- ok: true
122
- data: unknown
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
- type McpTerseErr = {
130
- ok: false
131
- error: {
132
- code: string
133
- status?: number
134
- message: string
135
- details?: unknown
136
- hint?: string
137
- retryable: boolean
304
+ const safeStringify = (value: unknown): string => {
305
+ try {
306
+ return JSON.stringify(value)
307
+ } catch {
308
+ return ''
138
309
  }
139
- meta?: {
140
- status?: number
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
- debug?: unknown
319
+
320
+ const bodyText = typeof body === 'string' ? body : safeStringify(body)
321
+ return blockingIssueMessagePattern.test(bodyText)
143
322
  }
144
323
 
145
- const httpErrorCodeForStatus = (status: number): string => {
146
- if (status === 400) return 'HTTP_BAD_REQUEST'
147
- if (status === 401) return 'HTTP_UNAUTHORIZED'
148
- if (status === 403) return 'HTTP_FORBIDDEN'
149
- if (status === 404) return 'HTTP_NOT_FOUND'
150
- if (status === 409) return 'HTTP_CONFLICT'
151
- if (status === 422) return 'HTTP_UNPROCESSABLE_ENTITY'
152
- if (status === 429) return 'HTTP_RATE_LIMITED'
153
- if (status >= 500) return 'HTTP_SERVER_ERROR'
154
- if (status >= 400) return 'HTTP_ERROR'
155
- return 'UNKNOWN'
324
+ type MergeFailureClassification = {
325
+ code: string
326
+ message: string
327
+ retryable: boolean
328
+ blockedByIssues: boolean
156
329
  }
157
330
 
158
- const buildErrorMessage = (status: number | undefined, body: unknown): string => {
159
- if (typeof body === 'string') {
160
- const trimmed = body.trim()
161
- if (trimmed) {
162
- const firstLine = trimmed.split(/\r?\n/, 1)[0]
163
- return firstLine.length > 0 ? firstLine : trimmed
164
- }
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
- if (isRecord(body) && typeof body.message === 'string' && body.message.trim()) {
168
- return body.message.trim()
346
+ const inferHeadContainedFromCompareBody = (body: unknown): boolean | null => {
347
+ if (!isRecord(body)) {
348
+ return null
169
349
  }
170
350
 
171
- if (typeof status === 'number') {
172
- return `HTTP ${status}`
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
- return 'Request failed'
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 toMcpEnvelope = (
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
- if (result.ok && result.status < 400) {
185
- const envelope: McpTerseOk = {
186
- ok: true,
187
- data: result.body,
188
- meta: {
189
- status: result.status,
190
- },
191
- ...(format === 'debug' ? { debug: sanitized } : {}),
192
- }
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
- return { isError: false, envelope: redactSecretsForMcpOutput(envelope) as McpTerseOk }
195
- }
376
+ const hasKey = (value: Record<string, unknown>, key: string): boolean =>
377
+ Object.prototype.hasOwnProperty.call(value, key)
196
378
 
197
- const status = result.status
198
- const envelope: McpTerseErr = {
199
- ok: false,
200
- error: {
201
- code: httpErrorCodeForStatus(status),
202
- status,
203
- message: buildErrorMessage(status, result.body),
204
- details: result.body,
205
- retryable: status >= 500 || status === 429,
206
- },
207
- meta: {
208
- status,
209
- },
210
- ...(format === 'debug' ? { debug: sanitized } : {}),
211
- }
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
- return { isError: true, envelope: redactSecretsForMcpOutput(envelope) as McpTerseErr }
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 toMcpThrownErrorEnvelope = (error: unknown): McpTerseErr => ({
217
- ok: false,
218
- error: {
219
- code: 'TOOL_ERROR',
220
- message: redactSecretsInText(error instanceof Error ? error.message : String(error)),
221
- retryable: false,
222
- },
223
- })
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 normalizePayload = (payload: unknown): { args: string[]; options: Record<string, unknown> } => {
226
- const normalized = normalizeArgumentPayload(payload)
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
- if (normalized === null || normalized === undefined) {
229
- return {
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
- if (!isRecord(normalized)) {
236
- const kind = Array.isArray(normalized) ? 'array' : typeof normalized
237
- throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
238
- }
396
+ const isLikelyPullRequestRecord = (value: Record<string, unknown>): boolean =>
397
+ hasKey(value, 'number') && (hasKey(value, 'head') || hasKey(value, 'base') || hasKey(value, 'merged'))
239
398
 
240
- const explicitArgs = Array.isArray(normalized.args) ? normalized.args : undefined
241
- const explicitOptions = isRecord(normalized.options) ? normalized.options : undefined
399
+ const isLikelyIssueRecord = (value: Record<string, unknown>): boolean =>
400
+ hasKey(value, 'number') && hasKey(value, 'title') && hasKey(value, 'state') && !isLikelyPullRequestRecord(value)
242
401
 
243
- const args = explicitArgs ? explicitArgs.map((entry) => String(entry)) : []
244
- const options: Record<string, unknown> = explicitOptions ? { ...explicitOptions } : {}
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
- for (const [key, value] of Object.entries(normalized)) {
247
- if (key === 'args' || key === 'options') {
248
- continue
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
- if (value !== undefined) {
252
- options[key] = value
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 OMITTED_OPTION_KEYS = new Set(['format'])
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
- for (const [key, value] of Object.entries(options)) {
267
- if (OMITTED_OPTION_KEYS.has(key)) continue
268
- next[key] = value
269
- }
270
- return next
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 normalizeBatchToolCall = (
274
- call: unknown,
275
- index: number,
276
- ): { tool: string; payload: ToolInvocationPayload } => {
277
- if (!isRecord(call)) {
278
- throw new Error(`Invalid batch call at index ${index}: expected object`)
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
- const tool = typeof call.tool === 'string' ? call.tool.trim() : ''
282
- if (!tool) {
283
- throw new Error(`Invalid batch call at index ${index}: missing "tool"`)
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
- const args = Array.isArray(call.args) ? call.args : []
287
- const { options, ...extras } = call
288
- const normalized: ToolInvocationPayload = {
289
- args,
290
- options: isRecord(options) ? options : {},
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
- for (const [key, value] of Object.entries(extras)) {
294
- if (value !== undefined) {
295
- normalized.options[key] = value
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
- tool,
301
- payload: normalized,
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
- if (!Array.isArray(normalized.calls)) {
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
- const calls = (normalized.calls as unknown[]).map((call, index) => normalizeBatchToolCall(call, index))
316
-
317
- return {
318
- calls: calls.map(({ tool, payload }) => ({
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
- for (const [segment, value] of Object.entries(api)) {
330
- const currentPath = [...parentPath, segment]
556
+ const next: Record<string, unknown> = {}
557
+ const hasHtmlUrl = isNonEmptyString(value.html_url)
331
558
 
332
- if (typeof value === 'function') {
333
- tools.push({
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
- if (isRecord(value)) {
342
- tools.push(...collectGitTools(value as GitServiceApi, currentPath))
343
- }
564
+ const compacted = compact(entryValue, key)
565
+ if (isEffectivelyEmpty(compacted)) continue
566
+ next[key] = compacted
344
567
  }
345
568
 
346
- return tools
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 buildToolName = (tool: ToolDefinition, prefix?: string): string =>
363
- prefix ? `${prefix}.${tool.name}` : tool.name
364
-
365
- const isLogsForRunTailTool = (toolName: string): boolean => toolName.endsWith('jobs.logsForRunTail')
366
- const isArtifactsByRunTool = (toolName: string): boolean => toolName.endsWith('runs.artifacts')
367
- const isDiagnoseLatestFailureTool = (toolName: string): boolean =>
368
- toolName.endsWith('diagnoseLatestFailure') && toolName.includes('.actions.')
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
- const buildGenericToolSchema = (): Record<string, unknown> => ({
371
- type: 'object',
372
- additionalProperties: true,
373
- properties: {
374
- args: {
375
- type: 'array',
376
- items: { type: 'string' },
377
- description: 'Positional arguments for the git API method (strings are safest).',
378
- },
379
- options: {
380
- type: 'object',
381
- additionalProperties: true,
382
- description: 'Method options (query/headers/body payload). Extra top-level keys are merged into options.',
383
- },
384
- format: {
385
- type: 'string',
386
- enum: ['terse', 'debug'],
387
- description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
388
- },
389
- },
390
- })
391
-
392
- const buildArtifactsByRunSchema = (): Record<string, unknown> => ({
393
- type: 'object',
394
- additionalProperties: true,
395
- properties: {
396
- owner: {
397
- type: 'string',
398
- description: 'Repository owner/org. Optional if the server was started with a default owner.',
399
- },
400
- repo: {
401
- type: 'string',
402
- description: 'Repository name. Optional if the server was started with a default repo.',
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
- runId: {
405
- description: 'Workflow run id (alias: run_id).',
406
- anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
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
- run_id: {
409
- description: 'Alias for runId.',
410
- anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
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
- // Legacy positional forms:
418
- // - Preferred by humans/LLMs: [owner, repo, runId]
419
- // - Back-compat with the underlying helper signature: [runId, owner, repo]
420
- args: {
421
- type: 'array',
422
- items: {},
423
- description:
424
- 'Legacy positional form. Prefer named params. If used, pass [owner, repo, runId] (recommended) or the legacy [runId, owner, repo]. You can also pass only [runId] if defaults are configured.',
425
- },
426
- options: {
427
- type: 'object',
428
- additionalProperties: true,
429
- description: 'Options object (query/headers/body payload). Extra top-level keys are merged into options.',
430
- },
431
- },
432
- anyOf: [{ required: ['runId'] }, { required: ['run_id'] }, { required: ['args'] }],
433
- })
434
-
435
- const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
436
- type: 'object',
437
- additionalProperties: true,
438
- properties: {
439
- // Preferred named form (no positional confusion).
440
- owner: {
441
- type: 'string',
442
- description: 'Repository owner/org. Optional if the server was started with a default owner.',
443
- },
444
- repo: {
445
- type: 'string',
446
- description: 'Repository name. Optional if the server was started with a default repo.',
447
- },
448
- headSha: {
449
- type: 'string',
450
- description: 'Commit SHA for the run (alias: head_sha).',
451
- },
452
- head_sha: {
453
- type: 'string',
454
- description: 'Alias for headSha.',
455
- },
456
- runNumber: {
457
- type: 'integer',
458
- minimum: 1,
459
- description: 'Workflow run_number (alias: run_number).',
460
- },
461
- run_number: {
462
- type: 'integer',
463
- minimum: 1,
464
- description: 'Alias for runNumber.',
465
- },
466
- maxLines: {
467
- type: 'integer',
468
- minimum: 1,
469
- description: 'Max lines to return from the end of the logs.',
470
- },
471
- maxBytes: {
472
- type: 'integer',
473
- minimum: 1,
474
- description: 'Max bytes to return from the end of the logs.',
475
- },
476
- contains: {
477
- type: 'string',
478
- description: 'If set, only return log lines containing this substring.',
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
- // Legacy / compatibility: allow calling with positional args.
487
- args: {
488
- type: 'array',
489
- items: {},
490
- description:
491
- 'Legacy positional form. Preferred is named params. If used, pass [headSha, runNumber] (or the legacy-mistake [owner, repo] with options.headSha/options.runNumber).',
492
- },
493
- options: {
494
- type: 'object',
495
- additionalProperties: true,
496
- description:
497
- 'Options object. Supports owner/repo, maxLines/maxBytes/contains, and format. Extra top-level keys are merged into options.',
498
- },
499
- },
500
- anyOf: [
501
- { required: ['headSha', 'runNumber'] },
502
- { required: ['head_sha', 'run_number'] },
503
- { required: ['headSha', 'run_number'] },
504
- { required: ['head_sha', 'runNumber'] },
505
- ],
506
- })
507
-
508
- const buildDiagnoseLatestFailureSchema = (): Record<string, unknown> => ({
509
- type: 'object',
510
- additionalProperties: true,
511
- properties: {
512
- owner: {
513
- type: 'string',
514
- description: 'Repository owner/org. Optional if the server was started with a default owner.',
515
- },
516
- repo: {
517
- type: 'string',
518
- description: 'Repository name. Optional if the server was started with a default repo.',
519
- },
520
- workflowName: {
521
- type: 'string',
522
- description: 'Optional filter: match failing runs by name/display_title (case-insensitive substring).',
523
- },
524
- limit: {
525
- type: 'integer',
526
- minimum: 1,
527
- description: 'How many tasks/runs to fetch before filtering (default: 50).',
528
- },
529
- maxLines: {
530
- type: 'integer',
531
- minimum: 1,
532
- description: 'Max lines to return from the end of the logs (default: 200).',
533
- },
534
- maxBytes: {
535
- type: 'integer',
536
- minimum: 1,
537
- description: 'Max bytes to return from the end of the logs.',
538
- },
539
- contains: {
540
- type: 'string',
541
- description: 'If set, only return log lines containing this substring.',
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
- args: {
549
- type: 'array',
550
- items: { type: 'string' },
551
- description: 'Legacy positional form: [owner, repo] plus options.workflowName/maxLines/etc.',
552
- },
553
- options: {
554
- type: 'object',
555
- additionalProperties: true,
556
- description: 'Options object. Extra top-level keys are merged into options.',
1084
+ full: {
1085
+ type: 'boolean',
1086
+ description: 'If true, return the unfiltered upstream response body.',
557
1087
  },
558
- },
559
- })
560
-
561
- const buildToolInputSchema = (tool: ToolDefinition): Record<string, unknown> =>
562
- isLogsForRunTailTool(tool.name)
563
- ? buildLogsForRunTailSchema()
564
- : isArtifactsByRunTool(tool.name)
565
- ? buildArtifactsByRunSchema()
566
- : isDiagnoseLatestFailureTool(tool.name)
567
- ? buildDiagnoseLatestFailureSchema()
568
- : buildGenericToolSchema()
569
-
570
- const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?: string) => {
571
- const toolNames = tools.map((tool) => ({
572
- name: buildToolName(tool, prefix),
573
- description: `Call git API method ${tool.path.join('.')}`,
574
- inputSchema: buildToolInputSchema(tool),
575
- }))
576
-
577
- const batchTool = {
578
- name: batchToolName,
579
- description:
580
- 'Preferred mode for client calls: execute multiple MCP tool calls in one request with parallel execution.',
581
- inputSchema: {
582
- type: 'object',
583
- additionalProperties: true,
584
- properties: {
585
- calls: {
586
- type: 'array',
587
- minItems: 1,
588
- items: {
589
- type: 'object',
590
- additionalProperties: true,
591
- properties: {
592
- tool: {
593
- type: 'string',
594
- description: 'Full MCP tool name to execute',
595
- },
596
- args: {
597
- type: 'array',
598
- items: { type: 'string' },
599
- description: 'Positional args for the tool',
600
- },
601
- options: {
602
- type: 'object',
603
- additionalProperties: true,
604
- description: 'Tool invocation options',
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
- format: {
607
- type: 'string',
608
- enum: ['terse', 'debug'],
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
- required: ['tool'],
613
- },
614
- description: 'List of tool calls to execute',
615
- },
616
- continueOnError: {
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: 'Whether to continue when a call in the batch fails',
619
- default: false,
1186
+ description: 'If true, validate and normalize calls without executing them.',
620
1187
  },
621
- format: {
622
- type: 'string',
623
- enum: ['terse', 'debug'],
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
- required: ['calls'],
628
- },
629
- }
630
-
631
- return [...toolNames, batchTool]
632
- }
633
-
634
- const normalizeToolCallName = (prefix: string | undefined, toolName: string): string =>
635
- prefix ? toolName.replace(new RegExp(`^${prefix}\\.`), '') : toolName
636
-
637
- const invokeTool = async (
638
- tool: ToolDefinition,
639
- payload: unknown,
640
- ): Promise<GitServiceApiExecutionResult> => {
641
- const { args, options } = normalizePayload(payload)
642
- const cleanedOptions = stripMcpOnlyOptions(options)
643
- const invocationArgs: unknown[] = args
644
-
645
- if (Object.keys(cleanedOptions).length > 0) {
646
- invocationArgs.push(cleanedOptions)
647
- }
648
-
649
- return tool.method(...invocationArgs)
650
- }
651
-
652
- const pickArgsFromNormalizedPayload = (normalized: unknown, record: Record<string, unknown>): unknown[] => {
653
- if (Array.isArray(normalized)) {
654
- return normalized
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 pickNestedRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
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
- const normalizeQueryRecord = (query: unknown): Record<string, unknown> => {
662
- if (isRecord(query)) {
663
- return query
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
- if (!Array.isArray(query)) {
667
- return {}
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 merged: Record<string, unknown> = {}
671
-
672
- for (const entry of query) {
673
- if (!isRecord(entry)) continue
674
-
675
- const name = typeof entry.name === 'string' ? entry.name.trim() : ''
676
- if (name) {
677
- merged[name] = entry.value
678
- continue
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
- // Support [{ headSha: "..." }, { runNumber: 11 }] style arrays.
682
- for (const [key, value] of Object.entries(entry)) {
683
- if (value !== undefined) merged[key] = value
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 invokeArtifactsByRunTool = async (
760
- tool: ToolDefinition,
761
- payload: unknown,
762
- ): Promise<GitServiceApiExecutionResult> => {
763
- const normalized = normalizeArgumentPayload(payload)
764
- const record = isRecord(normalized) ? normalized : {}
765
- const args = pickArgsFromNormalizedPayload(normalized, record)
766
- const options = pickRecord(record.options)
767
-
768
- const runIdNamed = toTrimmedString(pickFirst(record.runId, record.run_id, options.runId, options.run_id)) || ''
769
-
770
- const a0 = args.length >= 1 ? toTrimmedString(args[0]) : ''
771
- const a1 = args.length >= 2 ? toTrimmedString(args[1]) : ''
772
- const a2 = args.length >= 3 ? toTrimmedString(args[2]) : ''
773
-
774
- // Heuristic:
775
- // - If named runId is set, use it.
776
- // - Else if args look like [owner, repo, runId], use that.
777
- // - Else treat args as legacy [runId, owner, repo] (or [runId] with defaults).
778
- const shouldTreatAsOwnerRepoRunId = Boolean(a0) && Boolean(a1) && Boolean(a2) && !runIdNamed
779
-
780
- const owner =
781
- toTrimmedString(pickFirst(record.owner, options.owner, shouldTreatAsOwnerRepoRunId ? a0 : a1)) || undefined
782
- const repo =
783
- toTrimmedString(pickFirst(record.repo, options.repo, shouldTreatAsOwnerRepoRunId ? a1 : a2)) || undefined
784
- const runId = toTrimmedString(pickFirst(runIdNamed || null, shouldTreatAsOwnerRepoRunId ? a2 : a0)) || ''
785
-
786
- if (!runId) {
787
- throw new Error('runId is required. Preferred invocation: { owner, repo, runId }.')
788
- }
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
- const cleanedOptions = stripMcpOnlyOptions({
791
- ...options,
792
- ...(owner ? { owner } : {}),
793
- ...(repo ? { repo } : {}),
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
- // Underlying helper signature is (runId, owner?, repo?, options?).
797
- // We always pass runId first, and owner/repo if we have them.
798
- if (owner && repo) {
799
- return tool.method(runId, owner, repo, cleanedOptions)
800
- }
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
- if (owner && !repo) {
803
- // Unusual: allow passing only owner explicitly.
804
- return tool.method(runId, owner, cleanedOptions)
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
- return tool.method(runId, cleanedOptions)
808
- }
809
-
810
- export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpServerInstance => {
811
- const api = createGitServiceApi(options)
812
- const tools = collectGitTools(api)
813
- const prefix = options.toolsPrefix
814
- const batchToolName = prefix ? `${prefix}.batch` : 'batch'
815
-
816
- const server = new Server(
817
- {
818
- name: options.serverName ?? 'git',
819
- version: options.serverVersion ?? '1.0.0',
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
- capabilities: {
823
- tools: {},
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
- const toolByName = new Map<string, ToolDefinition>(tools.map((tool) => [buildToolName(tool, prefix), tool]))
1875
+ const blockerCheck = checkBlockingIssues ? await checkPrBlockingIssues(owner, repo, number) : null
1876
+ const blockingIssueNumbers = blockerCheck ? blockerCheck.blockingIssueNumbers : []
829
1877
 
830
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
831
- tools: buildToolList(tools, batchToolName, prefix),
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
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
835
- const requestedName = request.params.name
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
- if (requestedName === batchToolName) {
838
- try {
839
- const { calls, continueOnError } = normalizeBatchPayload(request.params.arguments)
840
- const batchControls = extractOutputControls(request.params.arguments)
841
-
842
- const executions = calls.map(({ tool, args = [], options = {} }, index) => ({ tool, args, options, index }))
843
- const results = await Promise.all(
844
- executions.map(async ({ tool, args, options, index }) => {
845
- const toolDefinition = toolByName.get(tool)
846
- if (!toolDefinition) {
847
- return {
848
- index,
849
- tool,
850
- isError: true,
851
- ...({
852
- ok: false,
853
- error: {
854
- code: 'UNKNOWN_TOOL',
855
- message: `Unknown tool: ${tool}`,
856
- retryable: false,
857
- },
858
- } satisfies McpTerseErr),
859
- } as BatchResult
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
- try {
863
- const mergedPayload = { args, options }
864
- const callControls = extractOutputControls(mergedPayload)
865
- const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
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
- const data = await (isLogsForRunTailTool(toolDefinition.name)
868
- ? invokeLogsForRunTailTool(toolDefinition, mergedPayload)
869
- : isArtifactsByRunTool(toolDefinition.name)
870
- ? invokeArtifactsByRunTool(toolDefinition, mergedPayload)
871
- : invokeTool(toolDefinition, mergedPayload))
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
- const { isError, envelope } = toMcpEnvelope(data, effectiveFormat)
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
- index,
876
- tool,
877
- isError,
878
- ...(envelope as McpTerseOk | McpTerseErr),
879
- } as BatchResult
880
- } catch (error) {
881
- if (continueOnError) {
882
- return {
883
- index,
884
- tool,
885
- isError: true,
886
- ...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
887
- } as BatchResult
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
- return {
895
- isError: results.some((result) => result.isError),
896
- content: [
897
- {
898
- type: 'text',
899
- text: JSON.stringify(redactSecretsForMcpOutput(results)),
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
- } catch (error) {
904
- return {
905
- isError: true,
906
- content: [
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
- type: 'text',
909
- text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
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
- if (!tool) {
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
- try {
923
- const controls = extractOutputControls(request.params.arguments)
924
- const result = await (isLogsForRunTailTool(tool.name)
925
- ? invokeLogsForRunTailTool(tool, request.params.arguments)
926
- : isArtifactsByRunTool(tool.name)
927
- ? invokeArtifactsByRunTool(tool, request.params.arguments)
928
- : invokeTool(tool, request.params.arguments))
929
-
930
- const { isError, envelope } = toMcpEnvelope(result, controls.format ?? 'terse')
931
- return {
932
- ...(isError ? { isError: true } : {}),
933
- content: [
934
- {
935
- type: 'text',
936
- text: JSON.stringify(envelope),
937
- },
938
- ],
939
- }
940
- } catch (error) {
941
- return {
942
- isError: true,
943
- content: [
944
- {
945
- type: 'text',
946
- text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
947
- },
948
- ],
949
- }
950
- }
951
- })
952
-
953
- const run = async (): Promise<Server> => {
954
- await server.connect(new StdioServerTransport())
955
- return server
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
- return { api, tools, server, run }
959
- }
960
-
961
- export const runGitMcpServer = async (options: GitMcpServerOptions = {}): Promise<Server> => {
962
- const instance = createGitMcpServer(options)
963
- return instance.run()
964
- }
965
-
966
- export const normalizeToolCallNameForServer = (
967
- prefix: string | undefined,
968
- toolName: string,
969
- ): string => normalizeToolCallName(prefix, toolName)
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)