@foundation0/git 1.2.5 → 1.3.1

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,749 +1,1809 @@
1
- import { createGitServiceApi } from '@foundation0/git'
2
- import type {
3
- GitServiceApi,
4
- GitServiceApiExecutionResult,
5
- GitServiceApiFactoryOptions,
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
-
19
- type McpToolOutputFormat = 'terse' | 'debug'
20
-
21
- type BatchToolCall = {
22
- tool: string
23
- args?: unknown[]
24
- options?: Record<string, unknown>
25
- [key: string]: unknown
26
- }
27
-
28
- type BatchToolCallPayload = {
29
- calls: BatchToolCall[]
30
- continueOnError: boolean
31
- }
32
-
33
- type BatchResult = {
34
- index: number
35
- tool: string
36
- isError: boolean
37
- } & (McpTerseOk | McpTerseErr)
38
-
39
- type ToolDefinition = {
40
- name: string
41
- path: string[]
42
- method: GitServiceApiMethod
43
- }
44
-
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
-
52
- try {
53
- return JSON.parse(trimmed) as unknown
54
- } catch (error) {
55
- const message = error instanceof Error ? error.message : String(error)
56
- throw new Error(`Invalid args JSON: ${message}`)
57
- }
58
- }
59
-
60
- const normalizeArgumentPayload = (payload: unknown): unknown => {
61
- if (typeof payload === 'string' || payload instanceof String) {
62
- const parsed = tryParseJsonObject(String(payload))
63
- if (!isRecord(parsed)) {
64
- const kind = Array.isArray(parsed) ? 'array' : typeof parsed
65
- throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
66
- }
67
- return parsed
68
- }
69
-
70
- return payload
71
- }
72
-
73
- const toTrimmedString = (value: unknown): string => (value === null || value === undefined ? '' : String(value)).trim()
74
-
75
- const toPositiveInteger = (value: unknown): number | null => {
76
- if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
77
- return Math.floor(value)
78
- }
79
-
80
- if (typeof value !== 'string') return null
81
-
82
- const trimmed = value.trim()
83
- if (!trimmed) return null
84
- const parsed = Number(trimmed)
85
- if (!Number.isFinite(parsed) || parsed <= 0) return null
86
- return Math.floor(parsed)
87
- }
88
-
89
- const pickFirst = <T>(...candidates: Array<T | null | undefined>): T | null => {
90
- for (const candidate of candidates) {
91
- if (candidate !== null && candidate !== undefined) return candidate
92
- }
93
- return null
94
- }
95
-
96
- const pickRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
97
-
98
- const parseOutputFormat = (value: unknown): McpToolOutputFormat | null => {
99
- if (value === null || value === undefined) return null
100
- const raw = toTrimmedString(value).toLowerCase()
101
- if (!raw) return null
102
- if (raw === 'debug') return 'debug'
103
- if (raw === 'terse') return 'terse'
104
- return null
105
- }
106
-
107
- const extractOutputControls = (payload: unknown): { format: McpToolOutputFormat | null } => {
108
- const normalized = normalizeArgumentPayload(payload)
109
- if (!isRecord(normalized)) {
110
- return { format: null }
111
- }
112
-
113
- const topLevelFormat = parseOutputFormat(normalized.format)
114
- const optionsFormat = parseOutputFormat(pickRecord(normalized.options).format)
115
-
116
- // Support either { format:"debug" } or { options:{ format:"debug" } }.
117
- return { format: pickFirst(topLevelFormat, optionsFormat) }
118
- }
119
-
120
- type McpTerseOk = {
121
- ok: true
122
- data: unknown
123
- meta: {
124
- status: number
125
- }
126
- debug?: unknown
127
- }
128
-
129
- type McpTerseErr = {
130
- ok: false
131
- error: {
132
- code: string
133
- status?: number
134
- message: string
135
- details?: unknown
136
- hint?: string
137
- retryable: boolean
138
- }
139
- meta?: {
140
- status?: number
141
- }
142
- debug?: unknown
143
- }
144
-
1
+ import { createGitServiceApi } from '@foundation0/git'
2
+ import type {
3
+ GitServiceApi,
4
+ GitServiceApiExecutionResult,
5
+ GitServiceApiFactoryOptions,
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
+
19
+ type McpToolOutputFormat = 'terse' | 'debug'
20
+
21
+ type McpFieldSelection = string[]
22
+
23
+ type BatchToolCall = {
24
+ tool: string
25
+ args?: unknown[]
26
+ options?: Record<string, unknown>
27
+ [key: string]: unknown
28
+ }
29
+
30
+ type BatchToolCallPayload = {
31
+ calls: BatchToolCall[]
32
+ continueOnError: boolean
33
+ }
34
+
35
+ type BatchResult = {
36
+ index: number
37
+ tool: string
38
+ isError: boolean
39
+ } & (McpTerseOk | McpTerseErr)
40
+
41
+ type ToolDefinition = {
42
+ name: string
43
+ path: string[]
44
+ method: GitServiceApiMethod
45
+ }
46
+
47
+ type McpToolListEntry = {
48
+ name: string
49
+ description: string
50
+ inputSchema: Record<string, unknown>
51
+ }
52
+
53
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
54
+ typeof value === 'object' && value !== null && !Array.isArray(value)
55
+
56
+ const tryParseJsonObject = (value: string): unknown => {
57
+ const trimmed = value.trim()
58
+ if (!trimmed) return {}
59
+
60
+ try {
61
+ return JSON.parse(trimmed) as unknown
62
+ } catch (error) {
63
+ const message = error instanceof Error ? error.message : String(error)
64
+ throw new Error(`Invalid args JSON: ${message}`)
65
+ }
66
+ }
67
+
68
+ const normalizeArgumentPayload = (payload: unknown): unknown => {
69
+ if (typeof payload === 'string' || payload instanceof String) {
70
+ const parsed = tryParseJsonObject(String(payload))
71
+ if (!isRecord(parsed)) {
72
+ const kind = Array.isArray(parsed) ? 'array' : typeof parsed
73
+ throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
74
+ }
75
+ return parsed
76
+ }
77
+
78
+ return payload
79
+ }
80
+
81
+ const toTrimmedString = (value: unknown): string => (value === null || value === undefined ? '' : String(value)).trim()
82
+
83
+ const toPositiveInteger = (value: unknown): number | null => {
84
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
85
+ return Math.floor(value)
86
+ }
87
+
88
+ if (typeof value !== 'string') return null
89
+
90
+ const trimmed = value.trim()
91
+ if (!trimmed) return null
92
+ const parsed = Number(trimmed)
93
+ if (!Number.isFinite(parsed) || parsed <= 0) return null
94
+ return Math.floor(parsed)
95
+ }
96
+
97
+ const toNonNegativeInteger = (value: unknown): number | null => {
98
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
99
+ return Math.floor(value)
100
+ }
101
+
102
+ if (typeof value !== 'string') return null
103
+
104
+ const trimmed = value.trim()
105
+ if (!trimmed) return null
106
+ const parsed = Number(trimmed)
107
+ if (!Number.isFinite(parsed) || parsed < 0) return null
108
+ return Math.floor(parsed)
109
+ }
110
+
111
+ const pickFirst = <T>(...candidates: Array<T | null | undefined>): T | null => {
112
+ for (const candidate of candidates) {
113
+ if (candidate !== null && candidate !== undefined) return candidate
114
+ }
115
+ return null
116
+ }
117
+
118
+ const pickRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
119
+
120
+ const parseOutputFormat = (value: unknown): McpToolOutputFormat | null => {
121
+ if (value === null || value === undefined) return null
122
+ const raw = toTrimmedString(value).toLowerCase()
123
+ if (!raw) return null
124
+ if (raw === 'debug') return 'debug'
125
+ if (raw === 'terse') return 'terse'
126
+ return null
127
+ }
128
+
129
+ const parseBoolean = (value: unknown): boolean | null => {
130
+ if (value === null || value === undefined) return null
131
+ if (typeof value === 'boolean') return value
132
+
133
+ if (typeof value === 'string') {
134
+ const normalized = value.trim().toLowerCase()
135
+ if (!normalized) return null
136
+ if (normalized === 'true') return true
137
+ if (normalized === 'false') return false
138
+ }
139
+
140
+ return null
141
+ }
142
+
143
+ const parseFieldSelection = (value: unknown): McpFieldSelection | null => {
144
+ if (value === null || value === undefined) return null
145
+
146
+ if (Array.isArray(value)) {
147
+ const fields = value.map((entry) => toTrimmedString(entry)).filter((entry) => entry.length > 0)
148
+ return fields.length > 0 ? fields : null
149
+ }
150
+
151
+ if (typeof value === 'string') {
152
+ const trimmed = value.trim()
153
+ if (!trimmed) return null
154
+ const fields = trimmed
155
+ .split(',')
156
+ .map((entry) => entry.trim())
157
+ .filter((entry) => entry.length > 0)
158
+ return fields.length > 0 ? fields : null
159
+ }
160
+
161
+ return null
162
+ }
163
+
164
+ const extractMcpControls = (
165
+ payload: unknown,
166
+ ): { format: McpToolOutputFormat | null; validateOnly: boolean; fields: McpFieldSelection | null } => {
167
+ const normalized = normalizeArgumentPayload(payload)
168
+ if (!isRecord(normalized)) {
169
+ return { format: null, validateOnly: false, fields: null }
170
+ }
171
+
172
+ const topLevelFormat = parseOutputFormat(normalized.format)
173
+ const topLevelValidateOnly = parseBoolean(normalized.validateOnly)
174
+ const topLevelFields = parseFieldSelection(normalized.fields)
175
+ const optionsFormat = parseOutputFormat(pickRecord(normalized.options).format)
176
+ const optionsValidateOnly = parseBoolean(pickRecord(normalized.options).validateOnly)
177
+ const optionsFields = parseFieldSelection(pickRecord(normalized.options).fields)
178
+
179
+ // Support either { format:"debug" } or { options:{ format:"debug" } }.
180
+ return {
181
+ format: pickFirst(topLevelFormat, optionsFormat),
182
+ validateOnly: Boolean(pickFirst(topLevelValidateOnly, optionsValidateOnly)),
183
+ fields: pickFirst(topLevelFields, optionsFields),
184
+ }
185
+ }
186
+
187
+ type McpTerseOk = {
188
+ ok: true
189
+ data: unknown
190
+ meta: {
191
+ status: number
192
+ }
193
+ debug?: unknown
194
+ }
195
+
196
+ type McpTerseErr = {
197
+ ok: false
198
+ error: {
199
+ code: string
200
+ status?: number
201
+ message: string
202
+ details?: unknown
203
+ hint?: string
204
+ retryable: boolean
205
+ }
206
+ meta?: {
207
+ status?: number
208
+ }
209
+ debug?: unknown
210
+ }
211
+
145
212
  const httpErrorCodeForStatus = (status: number): string => {
146
213
  if (status === 400) return 'HTTP_BAD_REQUEST'
147
214
  if (status === 401) return 'HTTP_UNAUTHORIZED'
148
215
  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'
216
+ if (status === 404) return 'HTTP_NOT_FOUND'
217
+ if (status === 409) return 'HTTP_CONFLICT'
218
+ if (status === 422) return 'HTTP_UNPROCESSABLE_ENTITY'
219
+ if (status === 429) return 'HTTP_RATE_LIMITED'
220
+ if (status >= 500) return 'HTTP_SERVER_ERROR'
154
221
  if (status >= 400) return 'HTTP_ERROR'
155
222
  return 'UNKNOWN'
156
223
  }
157
224
 
158
- const buildErrorMessage = (status: number | undefined, body: unknown): string => {
159
- if (typeof body === 'string') {
160
- const trimmed = body.trim()
161
- if (trimmed) {
162
- const firstLine = trimmed.split(/\r?\n/, 1)[0]
163
- return firstLine.length > 0 ? firstLine : trimmed
164
- }
165
- }
166
-
167
- if (isRecord(body) && typeof body.message === 'string' && body.message.trim()) {
168
- return body.message.trim()
169
- }
170
-
171
- if (typeof status === 'number') {
172
- return `HTTP ${status}`
173
- }
174
-
175
- return 'Request failed'
176
- }
177
-
178
- const toMcpEnvelope = (
179
- result: GitServiceApiExecutionResult,
180
- format: McpToolOutputFormat,
181
- ): { isError: boolean; envelope: McpTerseOk | McpTerseErr } => {
182
- const sanitized = redactSecretsForMcpOutput(result)
183
-
184
- if (result.ok && result.status < 400) {
185
- const envelope: McpTerseOk = {
186
- ok: true,
187
- data: result.body,
188
- meta: {
189
- status: result.status,
190
- },
191
- ...(format === 'debug' ? { debug: sanitized } : {}),
192
- }
193
-
194
- return { isError: false, envelope: redactSecretsForMcpOutput(envelope) as McpTerseOk }
195
- }
196
-
197
- const status = result.status
198
- const envelope: McpTerseErr = {
199
- ok: false,
200
- error: {
201
- code: httpErrorCodeForStatus(status),
202
- status,
203
- message: buildErrorMessage(status, result.body),
204
- details: result.body,
205
- retryable: status >= 500 || status === 429,
206
- },
207
- meta: {
208
- status,
209
- },
210
- ...(format === 'debug' ? { debug: sanitized } : {}),
225
+ const tryParseJsonLikeString = (value: string): unknown | null => {
226
+ try {
227
+ return JSON.parse(value) as unknown
228
+ } catch {
229
+ return null
211
230
  }
212
-
213
- return { isError: true, envelope: redactSecretsForMcpOutput(envelope) as McpTerseErr }
214
231
  }
215
232
 
216
- const toMcpThrownErrorEnvelope = (error: unknown): McpTerseErr => ({
217
- ok: false,
218
- error: {
219
- code: 'TOOL_ERROR',
220
- message: redactSecretsInText(error instanceof Error ? error.message : String(error)),
221
- retryable: false,
222
- },
223
- })
224
-
225
- const normalizePayload = (payload: unknown): { args: string[]; options: Record<string, unknown> } => {
226
- const normalized = normalizeArgumentPayload(payload)
227
-
228
- if (normalized === null || normalized === undefined) {
229
- return {
230
- args: [],
231
- options: {},
233
+ const extractBodyMessage = (body: unknown): string | null => {
234
+ if (isRecord(body)) {
235
+ if (typeof body.message === 'string' && body.message.trim()) {
236
+ return body.message.trim()
232
237
  }
233
- }
234
238
 
235
- if (!isRecord(normalized)) {
236
- const kind = Array.isArray(normalized) ? 'array' : typeof normalized
237
- throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
238
- }
239
-
240
- const explicitArgs = Array.isArray(normalized.args) ? normalized.args : undefined
241
- const explicitOptions = isRecord(normalized.options) ? normalized.options : undefined
242
-
243
- const args = explicitArgs ? explicitArgs.map((entry) => String(entry)) : []
244
- const options: Record<string, unknown> = explicitOptions ? { ...explicitOptions } : {}
245
-
246
- for (const [key, value] of Object.entries(normalized)) {
247
- if (key === 'args' || key === 'options') {
248
- continue
239
+ if (typeof body.error === 'string' && body.error.trim()) {
240
+ return body.error.trim()
249
241
  }
250
242
 
251
- if (value !== undefined) {
252
- options[key] = value
243
+ if (isRecord(body.error)) {
244
+ const nested = extractBodyMessage(body.error)
245
+ if (nested) return nested
253
246
  }
254
247
  }
255
248
 
256
- return {
257
- args,
258
- options,
249
+ if (typeof body !== 'string') {
250
+ return null
259
251
  }
260
- }
261
-
262
- const OMITTED_OPTION_KEYS = new Set(['format'])
263
252
 
264
- const stripMcpOnlyOptions = (options: Record<string, unknown>): Record<string, unknown> => {
265
- const next: Record<string, unknown> = {}
266
- for (const [key, value] of Object.entries(options)) {
267
- if (OMITTED_OPTION_KEYS.has(key)) continue
268
- next[key] = value
253
+ const trimmed = body.trim()
254
+ if (!trimmed) {
255
+ return null
269
256
  }
270
- return next
271
- }
272
257
 
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
- }
258
+ const parsed =
259
+ trimmed.startsWith('{') || trimmed.startsWith('[')
260
+ ? tryParseJsonLikeString(trimmed)
261
+ : null
280
262
 
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"`)
263
+ if (parsed && isRecord(parsed)) {
264
+ const parsedMessage = extractBodyMessage(parsed)
265
+ if (parsedMessage) return parsedMessage
284
266
  }
285
267
 
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 : {},
268
+ const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ''
269
+ if (firstLine === '{' || firstLine === '[') {
270
+ return null
291
271
  }
292
272
 
293
- for (const [key, value] of Object.entries(extras)) {
294
- if (value !== undefined) {
295
- normalized.options[key] = value
296
- }
297
- }
298
-
299
- return {
300
- tool,
301
- payload: normalized,
302
- }
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')
309
- }
310
-
311
- if (!Array.isArray(normalized.calls)) {
312
- throw new Error('Batch tool call requires a "calls" array')
313
- }
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),
323
- }
273
+ return firstLine.length > 0 ? firstLine : null
324
274
  }
325
275
 
326
- const collectGitTools = (api: GitServiceApi, parentPath: string[] = []): ToolDefinition[] => {
327
- const tools: ToolDefinition[] = []
328
-
329
- for (const [segment, value] of Object.entries(api)) {
330
- const currentPath = [...parentPath, segment]
331
-
332
- if (typeof value === 'function') {
333
- tools.push({
334
- name: currentPath.join('.'),
335
- path: currentPath,
336
- method: value as GitServiceApiMethod,
337
- })
338
- continue
339
- }
340
-
341
- if (isRecord(value)) {
342
- tools.push(...collectGitTools(value as GitServiceApi, currentPath))
343
- }
344
- }
345
-
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>
360
- }
361
-
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
-
367
- const buildGenericToolSchema = (): Record<string, unknown> => ({
368
- type: 'object',
369
- additionalProperties: true,
370
- properties: {
371
- args: {
372
- type: 'array',
373
- items: { type: 'string' },
374
- description: 'Positional arguments for the git API method (strings are safest).',
375
- },
376
- options: {
377
- type: 'object',
378
- additionalProperties: true,
379
- description: 'Method options (query/headers/body payload). Extra top-level keys are merged into options.',
380
- },
381
- format: {
382
- type: 'string',
383
- enum: ['terse', 'debug'],
384
- description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
385
- },
386
- },
387
- })
388
-
389
- const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
390
- type: 'object',
391
- additionalProperties: true,
392
- properties: {
393
- // Preferred named form (no positional confusion).
394
- owner: {
395
- type: 'string',
396
- description: 'Repository owner/org. Optional if the server was started with a default owner.',
397
- },
398
- repo: {
399
- type: 'string',
400
- description: 'Repository name. Optional if the server was started with a default repo.',
401
- },
402
- headSha: {
403
- type: 'string',
404
- description: 'Commit SHA for the run (alias: head_sha).',
405
- },
406
- head_sha: {
407
- type: 'string',
408
- description: 'Alias for headSha.',
409
- },
410
- runNumber: {
411
- type: 'integer',
412
- minimum: 1,
413
- description: 'Workflow run_number (alias: run_number).',
414
- },
415
- run_number: {
416
- type: 'integer',
417
- minimum: 1,
418
- description: 'Alias for runNumber.',
419
- },
420
- maxLines: {
421
- type: 'integer',
422
- minimum: 1,
423
- description: 'Max lines to return from the end of the logs.',
424
- },
425
- maxBytes: {
426
- type: 'integer',
427
- minimum: 1,
428
- description: 'Max bytes to return from the end of the logs.',
429
- },
430
- contains: {
431
- type: 'string',
432
- description: 'If set, only return log lines containing this substring.',
433
- },
434
- format: {
435
- type: 'string',
436
- enum: ['terse', 'debug'],
437
- description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
438
- },
439
-
440
- // Legacy / compatibility: allow calling with positional args.
441
- args: {
442
- type: 'array',
443
- items: {},
444
- description:
445
- 'Legacy positional form. Preferred is named params. If used, pass [headSha, runNumber] (or the legacy-mistake [owner, repo] with options.headSha/options.runNumber).',
446
- },
447
- options: {
448
- type: 'object',
449
- additionalProperties: true,
450
- description:
451
- 'Options object. Supports owner/repo, maxLines/maxBytes/contains, and format. Extra top-level keys are merged into options.',
452
- },
453
- },
454
- anyOf: [
455
- { required: ['headSha', 'runNumber'] },
456
- { required: ['head_sha', 'run_number'] },
457
- { required: ['headSha', 'run_number'] },
458
- { required: ['head_sha', 'runNumber'] },
459
- ],
460
- })
461
-
462
- const buildToolInputSchema = (tool: ToolDefinition): Record<string, unknown> =>
463
- isLogsForRunTailTool(tool.name) ? buildLogsForRunTailSchema() : buildGenericToolSchema()
464
-
465
- const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?: string) => {
466
- const toolNames = tools.map((tool) => ({
467
- name: buildToolName(tool, prefix),
468
- description: `Call git API method ${tool.path.join('.')}`,
469
- inputSchema: buildToolInputSchema(tool),
470
- }))
471
-
472
- const batchTool = {
473
- name: batchToolName,
474
- description:
475
- 'Preferred mode for client calls: execute multiple MCP tool calls in one request with parallel execution.',
476
- inputSchema: {
477
- type: 'object',
478
- additionalProperties: true,
479
- properties: {
480
- calls: {
481
- type: 'array',
482
- minItems: 1,
483
- items: {
484
- type: 'object',
485
- additionalProperties: true,
486
- properties: {
487
- tool: {
488
- type: 'string',
489
- description: 'Full MCP tool name to execute',
490
- },
491
- args: {
492
- type: 'array',
493
- items: { type: 'string' },
494
- description: 'Positional args for the tool',
495
- },
496
- options: {
497
- type: 'object',
498
- additionalProperties: true,
499
- description: 'Tool invocation options',
500
- },
501
- format: {
502
- type: 'string',
503
- enum: ['terse', 'debug'],
504
- description: 'Per-call output format (default: "terse").',
505
- },
506
- },
507
- required: ['tool'],
508
- },
509
- description: 'List of tool calls to execute',
510
- },
511
- continueOnError: {
512
- type: 'boolean',
513
- description: 'Whether to continue when a call in the batch fails',
514
- default: false,
515
- },
516
- format: {
517
- type: 'string',
518
- enum: ['terse', 'debug'],
519
- description: 'Default output format for calls that do not specify one (default: "terse").',
520
- },
521
- },
522
- required: ['calls'],
523
- },
524
- }
525
-
526
- return [...toolNames, batchTool]
527
- }
528
-
529
- const normalizeToolCallName = (prefix: string | undefined, toolName: string): string =>
530
- prefix ? toolName.replace(new RegExp(`^${prefix}\\.`), '') : toolName
531
-
532
- const invokeTool = async (
533
- tool: ToolDefinition,
534
- payload: unknown,
535
- ): Promise<GitServiceApiExecutionResult> => {
536
- const { args, options } = normalizePayload(payload)
537
- const cleanedOptions = stripMcpOnlyOptions(options)
538
- const invocationArgs: unknown[] = args
539
-
540
- if (Object.keys(cleanedOptions).length > 0) {
541
- invocationArgs.push(cleanedOptions)
542
- }
543
-
544
- return tool.method(...invocationArgs)
545
- }
546
-
547
- const invokeLogsForRunTailTool = async (tool: ToolDefinition, payload: unknown): Promise<GitServiceApiExecutionResult> => {
548
- const normalized = normalizeArgumentPayload(payload)
549
- const record = isRecord(normalized) ? normalized : {}
550
- const args = Array.isArray(record.args) ? record.args : []
551
- const options = pickRecord(record.options)
552
-
553
- const headShaNamed = toTrimmedString(pickFirst(record.headSha, record.head_sha, options.headSha, options.head_sha))
554
- const runNumberNamed = pickFirst(
555
- toPositiveInteger(pickFirst(record.runNumber, record.run_number, options.runNumber, options.run_number)),
556
- null,
557
- )
558
-
559
- // Positional preferred legacy: [headSha, runNumber]
560
- const headShaPositional = args.length >= 1 ? toTrimmedString(args[0]) : ''
561
- const runNumberPositional = args.length >= 2 ? toPositiveInteger(args[1]) : null
562
-
563
- // Legacy-mistake support: args=[owner, repo], options has headSha/runNumber.
564
- const shouldTreatArgsAsOwnerRepo =
565
- args.length >= 2 &&
566
- (!headShaPositional || !runNumberPositional) &&
567
- Boolean(headShaNamed) &&
568
- Boolean(runNumberNamed)
569
-
570
- const ownerFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[0]) : ''
571
- const repoFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[1]) : ''
572
-
573
- const owner = toTrimmedString(pickFirst(record.owner, options.owner, ownerFromArgs)) || undefined
574
- const repo = toTrimmedString(pickFirst(record.repo, options.repo, repoFromArgs)) || undefined
575
-
576
- const sha = toTrimmedString(pickFirst(headShaNamed || null, headShaPositional || null)) || ''
577
- const run = pickFirst(runNumberNamed, runNumberPositional) ?? null
578
-
579
- if (!sha || !run) {
580
- throw new Error(
581
- 'headSha (string) and runNumber (positive integer) are required. Preferred invocation: { owner, repo, headSha, runNumber, maxLines }.',
582
- )
583
- }
584
-
585
- const cleanedOptions = stripMcpOnlyOptions({
586
- ...options,
587
- ...(owner ? { owner } : {}),
588
- ...(repo ? { repo } : {}),
589
- })
590
-
591
- return tool.method(sha, run, cleanedOptions)
592
- }
593
-
594
- export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpServerInstance => {
595
- const api = createGitServiceApi(options)
596
- const tools = collectGitTools(api)
597
- const prefix = options.toolsPrefix
598
- const batchToolName = prefix ? `${prefix}.batch` : 'batch'
599
-
600
- const server = new Server(
601
- {
602
- name: options.serverName ?? 'git',
603
- version: options.serverVersion ?? '1.0.0',
604
- },
605
- {
606
- capabilities: {
607
- tools: {},
608
- },
609
- },
610
- )
611
-
612
- const toolByName = new Map<string, ToolDefinition>(tools.map((tool) => [buildToolName(tool, prefix), tool]))
613
-
614
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
615
- tools: buildToolList(tools, batchToolName, prefix),
616
- }))
617
-
618
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
619
- const requestedName = request.params.name
620
-
621
- if (requestedName === batchToolName) {
622
- try {
623
- const { calls, continueOnError } = normalizeBatchPayload(request.params.arguments)
624
- const batchControls = extractOutputControls(request.params.arguments)
625
-
626
- const executions = calls.map(({ tool, args = [], options = {} }, index) => ({ tool, args, options, index }))
627
- const results = await Promise.all(
628
- executions.map(async ({ tool, args, options, index }) => {
629
- const toolDefinition = toolByName.get(tool)
630
- if (!toolDefinition) {
631
- return {
632
- index,
633
- tool,
634
- isError: true,
635
- ...({
636
- ok: false,
637
- error: {
638
- code: 'UNKNOWN_TOOL',
639
- message: `Unknown tool: ${tool}`,
640
- retryable: false,
641
- },
642
- } satisfies McpTerseErr),
643
- } as BatchResult
644
- }
645
-
646
- try {
647
- const mergedPayload = { args, options }
648
- const callControls = extractOutputControls(mergedPayload)
649
- const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
650
-
651
- const data = await (isLogsForRunTailTool(toolDefinition.name)
652
- ? invokeLogsForRunTailTool(toolDefinition, mergedPayload)
653
- : invokeTool(toolDefinition, mergedPayload))
654
-
655
- const { isError, envelope } = toMcpEnvelope(data, effectiveFormat)
656
- return {
657
- index,
658
- tool,
659
- isError,
660
- ...(envelope as McpTerseOk | McpTerseErr),
661
- } as BatchResult
662
- } catch (error) {
663
- if (continueOnError) {
664
- return {
665
- index,
666
- tool,
667
- isError: true,
668
- ...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
669
- } as BatchResult
670
- }
671
- throw error
672
- }
673
- }),
674
- )
675
-
676
- return {
677
- isError: results.some((result) => result.isError),
678
- content: [
679
- {
680
- type: 'text',
681
- text: JSON.stringify(redactSecretsForMcpOutput(results)),
682
- },
683
- ],
684
- }
685
- } catch (error) {
686
- return {
687
- isError: true,
688
- content: [
689
- {
690
- type: 'text',
691
- text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
692
- },
693
- ],
694
- }
695
- }
696
- }
697
-
698
- const tool = toolByName.get(requestedName)
699
-
700
- if (!tool) {
701
- throw new Error(`Unknown tool: ${requestedName}`)
702
- }
703
-
704
- try {
705
- const controls = extractOutputControls(request.params.arguments)
706
- const result = await (isLogsForRunTailTool(tool.name)
707
- ? invokeLogsForRunTailTool(tool, request.params.arguments)
708
- : invokeTool(tool, request.params.arguments))
709
-
710
- const { isError, envelope } = toMcpEnvelope(result, controls.format ?? 'terse')
711
- return {
712
- ...(isError ? { isError: true } : {}),
713
- content: [
714
- {
715
- type: 'text',
716
- text: JSON.stringify(envelope),
717
- },
718
- ],
719
- }
720
- } catch (error) {
721
- return {
722
- isError: true,
723
- content: [
724
- {
725
- type: 'text',
726
- text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
727
- },
728
- ],
729
- }
730
- }
731
- })
732
-
733
- const run = async (): Promise<Server> => {
734
- await server.connect(new StdioServerTransport())
735
- return server
276
+ const buildErrorMessage = (status: number | undefined, body: unknown): string => {
277
+ const extracted = extractBodyMessage(body)
278
+ if (extracted) {
279
+ return extracted
736
280
  }
737
281
 
738
- return { api, tools, server, run }
739
- }
740
-
741
- export const runGitMcpServer = async (options: GitMcpServerOptions = {}): Promise<Server> => {
742
- const instance = createGitMcpServer(options)
743
- return instance.run()
744
- }
745
-
746
- export const normalizeToolCallNameForServer = (
747
- prefix: string | undefined,
748
- toolName: string,
749
- ): string => normalizeToolCallName(prefix, toolName)
282
+ if (typeof status === 'number') {
283
+ return `HTTP ${status}`
284
+ }
285
+
286
+ return 'Request failed'
287
+ }
288
+
289
+ const toMcpEnvelope = (
290
+ result: GitServiceApiExecutionResult,
291
+ format: McpToolOutputFormat,
292
+ ): { isError: boolean; envelope: McpTerseOk | McpTerseErr } => {
293
+ const sanitized = redactSecretsForMcpOutput(result)
294
+
295
+ if (result.ok && result.status < 400) {
296
+ const envelope: McpTerseOk = {
297
+ ok: true,
298
+ data: result.body,
299
+ meta: {
300
+ status: result.status,
301
+ },
302
+ ...(format === 'debug' ? { debug: sanitized } : {}),
303
+ }
304
+
305
+ return { isError: false, envelope: redactSecretsForMcpOutput(envelope) as McpTerseOk }
306
+ }
307
+
308
+ const status = result.status
309
+ const message = buildErrorMessage(status, result.body)
310
+ const retryable = status >= 500 || status === 429 || /try again later/i.test(message)
311
+
312
+ const envelope: McpTerseErr = {
313
+ ok: false,
314
+ error: {
315
+ code: httpErrorCodeForStatus(status),
316
+ status,
317
+ message,
318
+ details: result.body,
319
+ retryable,
320
+ },
321
+ meta: {
322
+ status,
323
+ },
324
+ ...(format === 'debug' ? { debug: sanitized } : {}),
325
+ }
326
+
327
+ return { isError: true, envelope: redactSecretsForMcpOutput(envelope) as McpTerseErr }
328
+ }
329
+
330
+ const toMcpThrownErrorEnvelope = (error: unknown): McpTerseErr => {
331
+ const message = redactSecretsInText(error instanceof Error ? error.message : String(error))
332
+
333
+ const hint =
334
+ message.includes('Missing required path arguments') || message.includes('Unresolved parameters')
335
+ ? 'Likely missing required positional args. Prefer { args:[...], owner, repo } or set server defaults, and avoid putting path params inside options.data/options.query.'
336
+ : message.startsWith('Invalid args JSON:')
337
+ ? 'If you are calling through a router/proxy, ensure tool arguments are a JSON object (not an array/string).'
338
+ : message.startsWith('Invalid args: expected a JSON object')
339
+ ? 'Pass an object payload like { args:[...], options:{...} }. Some proxies require arguments to be a JSON string containing an object.'
340
+ : undefined
341
+
342
+ return {
343
+ ok: false,
344
+ error: {
345
+ code: 'TOOL_ERROR',
346
+ message,
347
+ ...(hint ? { hint } : {}),
348
+ retryable: false,
349
+ },
350
+ }
351
+ }
352
+
353
+ const normalizePayload = (payload: unknown): { args: string[]; options: Record<string, unknown> } => {
354
+ const normalized = normalizeArgumentPayload(payload)
355
+
356
+ if (normalized === null || normalized === undefined) {
357
+ return {
358
+ args: [],
359
+ options: {},
360
+ }
361
+ }
362
+
363
+ if (!isRecord(normalized)) {
364
+ const kind = Array.isArray(normalized) ? 'array' : typeof normalized
365
+ throw new Error(`Invalid args: expected a JSON object, got ${kind}`)
366
+ }
367
+
368
+ const explicitArgs = Array.isArray(normalized.args) ? normalized.args : undefined
369
+ const explicitOptions = isRecord(normalized.options) ? normalized.options : undefined
370
+
371
+ const args = explicitArgs ? explicitArgs.map((entry) => String(entry)) : []
372
+ const options: Record<string, unknown> = explicitOptions ? { ...explicitOptions } : {}
373
+
374
+ for (const [key, value] of Object.entries(normalized)) {
375
+ if (key === 'args' || key === 'options') {
376
+ continue
377
+ }
378
+
379
+ if (value !== undefined) {
380
+ options[key] = value
381
+ }
382
+ }
383
+
384
+ return {
385
+ args,
386
+ options: normalizeNestedOptions(options),
387
+ }
388
+ }
389
+
390
+ const OMITTED_OPTION_KEYS = new Set(['format', 'fields', 'validateOnly'])
391
+
392
+ const stripMcpOnlyOptions = (options: Record<string, unknown>): Record<string, unknown> => {
393
+ const next: Record<string, unknown> = {}
394
+ for (const [key, value] of Object.entries(options)) {
395
+ if (OMITTED_OPTION_KEYS.has(key)) continue
396
+ next[key] = value
397
+ }
398
+ return next
399
+ }
400
+
401
+ const tryParseJson = (value: string): unknown => {
402
+ const trimmed = value.trim()
403
+ if (!trimmed) return undefined
404
+ try {
405
+ return JSON.parse(trimmed) as unknown
406
+ } catch (error) {
407
+ const message = error instanceof Error ? error.message : String(error)
408
+ throw new Error(`Invalid nested JSON: ${message}`)
409
+ }
410
+ }
411
+
412
+ const normalizeNestedJsonOption = (value: unknown): unknown => {
413
+ if (typeof value !== 'string') return value
414
+
415
+ const trimmed = value.trim()
416
+ if (!trimmed) return value
417
+
418
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[') && trimmed !== 'null' && trimmed !== 'true' && trimmed !== 'false') {
419
+ return value
420
+ }
421
+
422
+ return tryParseJson(trimmed)
423
+ }
424
+
425
+ const normalizeNestedOptions = (options: Record<string, unknown>): Record<string, unknown> => {
426
+ const next = { ...options }
427
+
428
+ if ('data' in next) {
429
+ next.data = normalizeNestedJsonOption(next.data)
430
+ }
431
+
432
+ if ('json' in next) {
433
+ next.json = normalizeNestedJsonOption(next.json)
434
+ }
435
+
436
+ if ('payload' in next) {
437
+ next.payload = normalizeNestedJsonOption(next.payload)
438
+ }
439
+
440
+ if ('query' in next) {
441
+ next.query = normalizeNestedJsonOption(next.query)
442
+ }
443
+
444
+ return next
445
+ }
446
+
447
+ const applyFieldSelection = (data: unknown, fields: McpFieldSelection | null): unknown => {
448
+ if (!fields || fields.length === 0) return data
449
+
450
+ const pickFromRecord = (record: Record<string, unknown>): Record<string, unknown> => {
451
+ const out: Record<string, unknown> = {}
452
+
453
+ for (const field of fields) {
454
+ const parts = field.split('.').map((part) => part.trim()).filter(Boolean)
455
+ if (parts.length === 0) continue
456
+
457
+ let current: unknown = record
458
+ for (const part of parts) {
459
+ if (!isRecord(current)) {
460
+ current = undefined
461
+ break
462
+ }
463
+ current = (current as Record<string, unknown>)[part]
464
+ }
465
+
466
+ if (current === undefined) continue
467
+
468
+ let target: Record<string, unknown> = out
469
+ for (const part of parts.slice(0, -1)) {
470
+ if (!isRecord(target[part])) {
471
+ target[part] = {}
472
+ }
473
+ target = target[part] as Record<string, unknown>
474
+ }
475
+ target[parts[parts.length - 1]] = current
476
+ }
477
+
478
+ return out
479
+ }
480
+
481
+ if (Array.isArray(data)) {
482
+ return data.map((entry) => (isRecord(entry) ? pickFromRecord(entry as Record<string, unknown>) : entry))
483
+ }
484
+
485
+ if (isRecord(data)) {
486
+ return pickFromRecord(data)
487
+ }
488
+
489
+ return data
490
+ }
491
+
492
+ const normalizeBatchToolCall = (
493
+ call: unknown,
494
+ index: number,
495
+ ): { tool: string; payload: ToolInvocationPayload } => {
496
+ if (!isRecord(call)) {
497
+ throw new Error(`Invalid batch call at index ${index}: expected object`)
498
+ }
499
+
500
+ const tool = typeof call.tool === 'string' ? call.tool.trim() : ''
501
+ if (!tool) {
502
+ throw new Error(`Invalid batch call at index ${index}: missing "tool"`)
503
+ }
504
+
505
+ const args = Array.isArray(call.args) ? call.args : []
506
+ const { options, ...extras } = call
507
+ const normalized: ToolInvocationPayload = {
508
+ args,
509
+ options: isRecord(options) ? options : {},
510
+ }
511
+
512
+ for (const [key, value] of Object.entries(extras)) {
513
+ if (value !== undefined) {
514
+ normalized.options[key] = value
515
+ }
516
+ }
517
+
518
+ return {
519
+ tool,
520
+ payload: normalized,
521
+ }
522
+ }
523
+
524
+ const normalizeBatchPayload = (payload: unknown): BatchToolCallPayload => {
525
+ const normalized = normalizeArgumentPayload(payload)
526
+ if (!isRecord(normalized)) {
527
+ throw new Error('Batch tool call requires an object payload')
528
+ }
529
+
530
+ if (!Array.isArray(normalized.calls)) {
531
+ throw new Error('Batch tool call requires a "calls" array')
532
+ }
533
+
534
+ const calls = (normalized.calls as unknown[]).map((call, index) => normalizeBatchToolCall(call, index))
535
+
536
+ return {
537
+ calls: calls.map(({ tool, payload }) => ({
538
+ tool,
539
+ ...payload,
540
+ })),
541
+ continueOnError: Boolean((normalized as Record<string, unknown>).continueOnError),
542
+ }
543
+ }
544
+
545
+ const collectGitTools = (api: GitServiceApi, parentPath: string[] = []): ToolDefinition[] => {
546
+ const tools: ToolDefinition[] = []
547
+
548
+ for (const [segment, value] of Object.entries(api)) {
549
+ const currentPath = [...parentPath, segment]
550
+
551
+ if (typeof value === 'function') {
552
+ tools.push({
553
+ name: currentPath.join('.'),
554
+ path: currentPath,
555
+ method: value as GitServiceApiMethod,
556
+ })
557
+ continue
558
+ }
559
+
560
+ if (isRecord(value)) {
561
+ tools.push(...collectGitTools(value as GitServiceApi, currentPath))
562
+ }
563
+ }
564
+
565
+ return tools
566
+ }
567
+
568
+ export interface GitMcpServerOptions extends GitServiceApiFactoryOptions {
569
+ serverName?: string
570
+ serverVersion?: string
571
+ toolsPrefix?: string
572
+ }
573
+
574
+ export type GitMcpServerInstance = {
575
+ api: GitServiceApi
576
+ tools: ToolDefinition[]
577
+ server: Server
578
+ listTools: () => McpToolListEntry[]
579
+ callTool: (toolName: string, payload: unknown) => Promise<{ isError: boolean; text: string }>
580
+ run: () => Promise<Server>
581
+ }
582
+
583
+ const buildToolName = (tool: ToolDefinition, prefix?: string): string =>
584
+ prefix ? `${prefix}.${tool.name}` : tool.name
585
+
586
+ const isLogsForRunTailTool = (toolName: string): boolean => toolName.endsWith('jobs.logsForRunTail')
587
+ const isArtifactsByRunTool = (toolName: string): boolean => toolName.endsWith('runs.artifacts')
588
+ const isDiagnoseLatestFailureTool = (toolName: string): boolean =>
589
+ toolName.endsWith('diagnoseLatestFailure') && toolName.includes('.actions.')
590
+
591
+ const buildGenericToolSchema = (): Record<string, unknown> => ({
592
+ type: 'object',
593
+ additionalProperties: true,
594
+ properties: {
595
+ owner: {
596
+ type: 'string',
597
+ description: 'Repository owner/org. Optional if the server was started with defaults or context was set.',
598
+ },
599
+ repo: {
600
+ type: 'string',
601
+ description: 'Repository name. Optional if the server was started with defaults or context was set.',
602
+ },
603
+ args: {
604
+ type: 'array',
605
+ items: { type: 'string' },
606
+ description: 'Positional arguments for the git API method (strings are safest).',
607
+ },
608
+ options: {
609
+ type: 'object',
610
+ additionalProperties: true,
611
+ description: 'Method options (query/headers/body payload). Extra top-level keys are merged into options.',
612
+ },
613
+ validateOnly: {
614
+ type: 'boolean',
615
+ description:
616
+ 'If true, do not execute the underlying HTTP request. Returns the normalized call payload (args/options) that would be sent.',
617
+ },
618
+ fields: {
619
+ description:
620
+ 'Optional field selection for the response body to reduce token usage. Accepts a string[] or a comma-separated string of dot-paths.',
621
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
622
+ },
623
+ format: {
624
+ type: 'string',
625
+ enum: ['terse', 'debug'],
626
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
627
+ },
628
+ },
629
+ })
630
+
631
+ const buildArtifactsByRunSchema = (): Record<string, unknown> => ({
632
+ type: 'object',
633
+ additionalProperties: true,
634
+ properties: {
635
+ owner: {
636
+ type: 'string',
637
+ description: 'Repository owner/org. Optional if the server was started with a default owner.',
638
+ },
639
+ repo: {
640
+ type: 'string',
641
+ description: 'Repository name. Optional if the server was started with a default repo.',
642
+ },
643
+ runId: {
644
+ description: 'Workflow run id (alias: run_id).',
645
+ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
646
+ },
647
+ run_id: {
648
+ description: 'Alias for runId.',
649
+ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
650
+ },
651
+ format: {
652
+ type: 'string',
653
+ enum: ['terse', 'debug'],
654
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
655
+ },
656
+ // Legacy positional forms:
657
+ // - Preferred by humans/LLMs: [owner, repo, runId]
658
+ // - Back-compat with the underlying helper signature: [runId, owner, repo]
659
+ args: {
660
+ type: 'array',
661
+ items: {},
662
+ description:
663
+ '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.',
664
+ },
665
+ options: {
666
+ type: 'object',
667
+ additionalProperties: true,
668
+ description: 'Options object (query/headers/body payload). Extra top-level keys are merged into options.',
669
+ },
670
+ },
671
+ anyOf: [{ required: ['runId'] }, { required: ['run_id'] }, { required: ['args'] }],
672
+ })
673
+
674
+ const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
675
+ type: 'object',
676
+ additionalProperties: true,
677
+ properties: {
678
+ // Preferred named form (no positional confusion).
679
+ owner: {
680
+ type: 'string',
681
+ description: 'Repository owner/org. Optional if the server was started with a default owner.',
682
+ },
683
+ repo: {
684
+ type: 'string',
685
+ description: 'Repository name. Optional if the server was started with a default repo.',
686
+ },
687
+ headSha: {
688
+ type: 'string',
689
+ description: 'Commit SHA for the run (alias: head_sha).',
690
+ },
691
+ head_sha: {
692
+ type: 'string',
693
+ description: 'Alias for headSha.',
694
+ },
695
+ runNumber: {
696
+ type: 'integer',
697
+ minimum: 1,
698
+ description: 'Workflow run_number (alias: run_number).',
699
+ },
700
+ run_number: {
701
+ type: 'integer',
702
+ minimum: 1,
703
+ description: 'Alias for runNumber.',
704
+ },
705
+ maxLines: {
706
+ type: 'integer',
707
+ minimum: 1,
708
+ description: 'Max lines to return from the end of the logs.',
709
+ },
710
+ maxBytes: {
711
+ type: 'integer',
712
+ minimum: 1,
713
+ description: 'Max bytes to return from the end of the logs.',
714
+ },
715
+ contains: {
716
+ type: 'string',
717
+ description: 'If set, only return log lines containing this substring.',
718
+ },
719
+ format: {
720
+ type: 'string',
721
+ enum: ['terse', 'debug'],
722
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
723
+ },
724
+
725
+ // Legacy / compatibility: allow calling with positional args.
726
+ args: {
727
+ type: 'array',
728
+ items: {},
729
+ description:
730
+ 'Legacy positional form. Preferred is named params. If used, pass [headSha, runNumber] (or the legacy-mistake [owner, repo] with options.headSha/options.runNumber).',
731
+ },
732
+ options: {
733
+ type: 'object',
734
+ additionalProperties: true,
735
+ description:
736
+ 'Options object. Supports owner/repo, maxLines/maxBytes/contains, and format. Extra top-level keys are merged into options.',
737
+ },
738
+ },
739
+ anyOf: [
740
+ { required: ['headSha', 'runNumber'] },
741
+ { required: ['head_sha', 'run_number'] },
742
+ { required: ['headSha', 'run_number'] },
743
+ { required: ['head_sha', 'runNumber'] },
744
+ ],
745
+ })
746
+
747
+ const buildDiagnoseLatestFailureSchema = (): Record<string, unknown> => ({
748
+ type: 'object',
749
+ additionalProperties: true,
750
+ properties: {
751
+ owner: {
752
+ type: 'string',
753
+ description: 'Repository owner/org. Optional if the server was started with a default owner.',
754
+ },
755
+ repo: {
756
+ type: 'string',
757
+ description: 'Repository name. Optional if the server was started with a default repo.',
758
+ },
759
+ workflowName: {
760
+ type: 'string',
761
+ description: 'Optional filter: match failing runs by name/display_title (case-insensitive substring).',
762
+ },
763
+ limit: {
764
+ type: 'integer',
765
+ minimum: 1,
766
+ description: 'How many tasks/runs to fetch before filtering (default: 50).',
767
+ },
768
+ maxLines: {
769
+ type: 'integer',
770
+ minimum: 1,
771
+ description: 'Max lines to return from the end of the logs (default: 200).',
772
+ },
773
+ maxBytes: {
774
+ type: 'integer',
775
+ minimum: 1,
776
+ description: 'Max bytes to return from the end of the logs.',
777
+ },
778
+ contains: {
779
+ type: 'string',
780
+ description: 'If set, only return log lines containing this substring.',
781
+ },
782
+ format: {
783
+ type: 'string',
784
+ enum: ['terse', 'debug'],
785
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
786
+ },
787
+ args: {
788
+ type: 'array',
789
+ items: { type: 'string' },
790
+ description: 'Legacy positional form: [owner, repo] plus options.workflowName/maxLines/etc.',
791
+ },
792
+ options: {
793
+ type: 'object',
794
+ additionalProperties: true,
795
+ description: 'Options object. Extra top-level keys are merged into options.',
796
+ },
797
+ },
798
+ })
799
+
800
+ const buildToolInputSchema = (tool: ToolDefinition): Record<string, unknown> =>
801
+ isLogsForRunTailTool(tool.name)
802
+ ? buildLogsForRunTailSchema()
803
+ : isArtifactsByRunTool(tool.name)
804
+ ? buildArtifactsByRunSchema()
805
+ : isDiagnoseLatestFailureTool(tool.name)
806
+ ? buildDiagnoseLatestFailureSchema()
807
+ : buildGenericToolSchema()
808
+
809
+ const buildToolList = (
810
+ tools: ToolDefinition[],
811
+ batchToolName: string,
812
+ prefix: string | undefined,
813
+ customTools: McpToolListEntry[],
814
+ ) => {
815
+ const toolNames: McpToolListEntry[] = tools.map((tool) => ({
816
+ name: buildToolName(tool, prefix),
817
+ description: `Call git API method ${tool.path.join('.')}`,
818
+ inputSchema: buildToolInputSchema(tool),
819
+ }))
820
+
821
+ const batchTool = {
822
+ name: batchToolName,
823
+ description:
824
+ 'Preferred mode for client calls: execute multiple MCP tool calls in one request with parallel execution.',
825
+ inputSchema: {
826
+ type: 'object',
827
+ additionalProperties: true,
828
+ properties: {
829
+ calls: {
830
+ type: 'array',
831
+ minItems: 1,
832
+ items: {
833
+ type: 'object',
834
+ additionalProperties: true,
835
+ properties: {
836
+ tool: {
837
+ type: 'string',
838
+ description: 'Full MCP tool name to execute',
839
+ },
840
+ args: {
841
+ type: 'array',
842
+ items: { type: 'string' },
843
+ description: 'Positional args for the tool',
844
+ },
845
+ options: {
846
+ type: 'object',
847
+ additionalProperties: true,
848
+ description: 'Tool invocation options',
849
+ },
850
+ validateOnly: {
851
+ type: 'boolean',
852
+ description: 'If true, validate and normalize without executing the underlying request.',
853
+ },
854
+ fields: {
855
+ description:
856
+ 'Optional field selection for the response body (reduces token usage). Accepts a string[] or comma-separated string.',
857
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
858
+ },
859
+ format: {
860
+ type: 'string',
861
+ enum: ['terse', 'debug'],
862
+ description: 'Per-call output format (default: "terse").',
863
+ },
864
+ },
865
+ required: ['tool'],
866
+ },
867
+ description: 'List of tool calls to execute',
868
+ },
869
+ continueOnError: {
870
+ type: 'boolean',
871
+ description: 'Whether to continue when a call in the batch fails',
872
+ default: false,
873
+ },
874
+ format: {
875
+ type: 'string',
876
+ enum: ['terse', 'debug'],
877
+ description: 'Default output format for calls that do not specify one (default: "terse").',
878
+ },
879
+ validateOnly: {
880
+ type: 'boolean',
881
+ description: 'If true, validate and normalize calls without executing them.',
882
+ },
883
+ fields: {
884
+ description:
885
+ 'Default field selection for calls that do not specify one. Accepts a string[] or comma-separated string.',
886
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
887
+ },
888
+ },
889
+ required: ['calls'],
890
+ },
891
+ }
892
+
893
+ return [...toolNames, ...customTools, batchTool]
894
+ }
895
+
896
+ const normalizeToolCallName = (prefix: string | undefined, toolName: string): string =>
897
+ prefix ? toolName.replace(new RegExp(`^${prefix}\\.`), '') : toolName
898
+
899
+ const invokeTool = async (
900
+ tool: ToolDefinition,
901
+ payload: unknown,
902
+ ): Promise<GitServiceApiExecutionResult> => {
903
+ const { args, options } = normalizePayload(payload)
904
+ const cleanedOptions = stripMcpOnlyOptions(options)
905
+ const invocationArgs: unknown[] = args
906
+
907
+ if (Object.keys(cleanedOptions).length > 0) {
908
+ invocationArgs.push(cleanedOptions)
909
+ }
910
+
911
+ return tool.method(...invocationArgs)
912
+ }
913
+
914
+ const pickArgsFromNormalizedPayload = (normalized: unknown, record: Record<string, unknown>): unknown[] => {
915
+ if (Array.isArray(normalized)) {
916
+ return normalized
917
+ }
918
+ return Array.isArray(record.args) ? record.args : []
919
+ }
920
+
921
+ const pickNestedRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
922
+
923
+ const normalizeQueryRecord = (query: unknown): Record<string, unknown> => {
924
+ if (isRecord(query)) {
925
+ return query
926
+ }
927
+
928
+ if (!Array.isArray(query)) {
929
+ return {}
930
+ }
931
+
932
+ const merged: Record<string, unknown> = {}
933
+
934
+ for (const entry of query) {
935
+ if (!isRecord(entry)) continue
936
+
937
+ const name = typeof entry.name === 'string' ? entry.name.trim() : ''
938
+ if (name) {
939
+ merged[name] = entry.value
940
+ continue
941
+ }
942
+
943
+ // Support [{ headSha: "..." }, { runNumber: 11 }] style arrays.
944
+ for (const [key, value] of Object.entries(entry)) {
945
+ if (value !== undefined) merged[key] = value
946
+ }
947
+ }
948
+
949
+ return merged
950
+ }
951
+
952
+ const invokeLogsForRunTailTool = async (tool: ToolDefinition, payload: unknown): Promise<GitServiceApiExecutionResult> => {
953
+ const normalized = normalizeArgumentPayload(payload)
954
+ const record = isRecord(normalized) ? normalized : {}
955
+ const args = pickArgsFromNormalizedPayload(normalized, record)
956
+ const options = normalizeNestedOptions(pickRecord(record.options))
957
+ const query = normalizeQueryRecord(options.query)
958
+ const data = pickNestedRecord(options.data)
959
+
960
+ const headShaNamed = toTrimmedString(
961
+ pickFirst(
962
+ record.headSha,
963
+ record.head_sha,
964
+ options.headSha,
965
+ options.head_sha,
966
+ query.headSha,
967
+ query.head_sha,
968
+ data.headSha,
969
+ data.head_sha,
970
+ ),
971
+ )
972
+ const runNumberNamed = pickFirst(
973
+ toPositiveInteger(pickFirst(record.runNumber, record.run_number, options.runNumber, options.run_number)),
974
+ toPositiveInteger(pickFirst(query.runNumber, query.run_number, data.runNumber, data.run_number)),
975
+ null,
976
+ )
977
+
978
+ // Positional preferred legacy: [headSha, runNumber]
979
+ const headShaPositional = args.length >= 1 ? toTrimmedString(args[0]) : ''
980
+ const runNumberPositional = args.length >= 2 ? toPositiveInteger(args[1]) : null
981
+
982
+ // Legacy-mistake support: args=[owner, repo], options has headSha/runNumber.
983
+ const shouldTreatArgsAsOwnerRepo =
984
+ args.length >= 2 &&
985
+ (!headShaPositional || !runNumberPositional) &&
986
+ Boolean(headShaNamed) &&
987
+ Boolean(runNumberNamed)
988
+
989
+ const ownerFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[0]) : ''
990
+ const repoFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[1]) : ''
991
+
992
+ const owner =
993
+ toTrimmedString(pickFirst(record.owner, options.owner, query.owner, data.owner, ownerFromArgs)) || undefined
994
+ const repo = toTrimmedString(pickFirst(record.repo, options.repo, query.repo, data.repo, repoFromArgs)) || undefined
995
+
996
+ const sha = toTrimmedString(pickFirst(headShaNamed || null, headShaPositional || null)) || ''
997
+ const run = pickFirst(runNumberNamed, runNumberPositional) ?? null
998
+
999
+ if (!sha || !run) {
1000
+ throw new Error(
1001
+ 'headSha (string) and runNumber (positive integer) are required. Preferred invocation: { owner, repo, headSha, runNumber, maxLines }. (Compatibility: options.query.{headSha,runNumber} is also accepted.)',
1002
+ )
1003
+ }
1004
+
1005
+ const containsFromQuery = typeof query.contains === 'string' ? query.contains : undefined
1006
+ const maxLinesFromQuery = toPositiveInteger(query.maxLines)
1007
+ const maxBytesFromQuery = toPositiveInteger(query.maxBytes)
1008
+
1009
+ const cleanedOptions = stripMcpOnlyOptions({
1010
+ ...options,
1011
+ ...(containsFromQuery ? { contains: containsFromQuery } : {}),
1012
+ ...(maxLinesFromQuery ? { maxLines: maxLinesFromQuery } : {}),
1013
+ ...(maxBytesFromQuery ? { maxBytes: maxBytesFromQuery } : {}),
1014
+ ...(owner ? { owner } : {}),
1015
+ ...(repo ? { repo } : {}),
1016
+ })
1017
+
1018
+ return tool.method(sha, run, cleanedOptions)
1019
+ }
1020
+
1021
+ const invokeArtifactsByRunTool = async (
1022
+ tool: ToolDefinition,
1023
+ payload: unknown,
1024
+ ): Promise<GitServiceApiExecutionResult> => {
1025
+ const normalized = normalizeArgumentPayload(payload)
1026
+ const record = isRecord(normalized) ? normalized : {}
1027
+ const args = pickArgsFromNormalizedPayload(normalized, record)
1028
+ const options = normalizeNestedOptions(pickRecord(record.options))
1029
+
1030
+ const runIdNamed = toTrimmedString(pickFirst(record.runId, record.run_id, options.runId, options.run_id)) || ''
1031
+
1032
+ const a0 = args.length >= 1 ? toTrimmedString(args[0]) : ''
1033
+ const a1 = args.length >= 2 ? toTrimmedString(args[1]) : ''
1034
+ const a2 = args.length >= 3 ? toTrimmedString(args[2]) : ''
1035
+
1036
+ // Heuristic:
1037
+ // - If named runId is set, use it.
1038
+ // - Else if args look like [owner, repo, runId], use that.
1039
+ // - Else treat args as legacy [runId, owner, repo] (or [runId] with defaults).
1040
+ const shouldTreatAsOwnerRepoRunId = Boolean(a0) && Boolean(a1) && Boolean(a2) && !runIdNamed
1041
+
1042
+ const owner =
1043
+ toTrimmedString(pickFirst(record.owner, options.owner, shouldTreatAsOwnerRepoRunId ? a0 : a1)) || undefined
1044
+ const repo =
1045
+ toTrimmedString(pickFirst(record.repo, options.repo, shouldTreatAsOwnerRepoRunId ? a1 : a2)) || undefined
1046
+ const runId = toTrimmedString(pickFirst(runIdNamed || null, shouldTreatAsOwnerRepoRunId ? a2 : a0)) || ''
1047
+
1048
+ if (!runId) {
1049
+ throw new Error('runId is required. Preferred invocation: { owner, repo, runId }.')
1050
+ }
1051
+
1052
+ const cleanedOptions = stripMcpOnlyOptions({
1053
+ ...options,
1054
+ ...(owner ? { owner } : {}),
1055
+ ...(repo ? { repo } : {}),
1056
+ })
1057
+
1058
+ // Underlying helper signature is (runId, owner?, repo?, options?).
1059
+ // We always pass runId first, and owner/repo if we have them.
1060
+ if (owner && repo) {
1061
+ return tool.method(runId, owner, repo, cleanedOptions)
1062
+ }
1063
+
1064
+ if (owner && !repo) {
1065
+ // Unusual: allow passing only owner explicitly.
1066
+ return tool.method(runId, owner, cleanedOptions)
1067
+ }
1068
+
1069
+ return tool.method(runId, cleanedOptions)
1070
+ }
1071
+
1072
+ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpServerInstance => {
1073
+ const api = createGitServiceApi(options)
1074
+ const tools = collectGitTools(api)
1075
+ const prefix = options.toolsPrefix
1076
+ const batchToolName = prefix ? `${prefix}.batch` : 'batch'
1077
+ const contextSetToolName = prefix ? `${prefix}.context.set` : 'context.set'
1078
+ const contextGetToolName = prefix ? `${prefix}.context.get` : 'context.get'
1079
+ const searchToolsToolName = prefix ? `${prefix}.searchTools` : 'searchTools'
1080
+ const prPreflightToolName = prefix ? `${prefix}.pr.preflight` : 'pr.preflight'
1081
+ const prMergeAndVerifyToolName = prefix ? `${prefix}.pr.mergeAndVerify` : 'pr.mergeAndVerify'
1082
+
1083
+ const context: { owner?: string; repo?: string } = {
1084
+ owner: toTrimmedString(options.defaultOwner) || undefined,
1085
+ repo: toTrimmedString(options.defaultRepo) || undefined,
1086
+ }
1087
+
1088
+ const resolveOwnerRepo = (options: Record<string, unknown>): { owner?: string; repo?: string } => {
1089
+ const owner = toTrimmedString(pickFirst(options.owner, context.owner)) || undefined
1090
+ const repo = toTrimmedString(pickFirst(options.repo, context.repo)) || undefined
1091
+ return { owner, repo }
1092
+ }
1093
+
1094
+ const resolvePrNumber = (args: string[], options: Record<string, unknown>): string => {
1095
+ const fromArgs = args.length >= 1 ? toTrimmedString(args[0]) : ''
1096
+ const fromNamed = toTrimmedString(pickFirst(options.number, (options as Record<string, unknown>).prNumber, options.index))
1097
+ return fromArgs || fromNamed
1098
+ }
1099
+
1100
+ const normalizePayloadWithContext = (
1101
+ tool: ToolDefinition,
1102
+ payload: unknown,
1103
+ ): { args: string[]; options: Record<string, unknown> } => {
1104
+ const normalized = normalizePayload(payload)
1105
+ const optionsWithDefaults = { ...normalized.options }
1106
+ const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
1107
+ if (owner && optionsWithDefaults.owner === undefined) optionsWithDefaults.owner = owner
1108
+ if (repo && optionsWithDefaults.repo === undefined) optionsWithDefaults.repo = repo
1109
+
1110
+ // LLM-friendly: allow { number: 123 } instead of args:[\"123\"] for PR tools.
1111
+ const args = [...normalized.args]
1112
+ if (args.length === 0 && tool.name.includes('.pr.') && tool.name.startsWith('repo.')) {
1113
+ const number = resolvePrNumber(args, optionsWithDefaults)
1114
+ if (number) {
1115
+ args.push(number)
1116
+ }
1117
+ }
1118
+
1119
+ return {
1120
+ args,
1121
+ options: optionsWithDefaults,
1122
+ }
1123
+ }
1124
+
1125
+ const customTools: McpToolListEntry[] = [
1126
+ {
1127
+ name: contextSetToolName,
1128
+ description: 'Set default {owner, repo} for subsequent git tool calls in this session.',
1129
+ inputSchema: {
1130
+ type: 'object',
1131
+ additionalProperties: true,
1132
+ properties: {
1133
+ owner: { type: 'string', description: 'Default repository owner/org' },
1134
+ repo: { type: 'string', description: 'Default repository name' },
1135
+ args: {
1136
+ type: 'array',
1137
+ items: { type: 'string' },
1138
+ description: 'Legacy positional form: [owner, repo]',
1139
+ },
1140
+ format: {
1141
+ type: 'string',
1142
+ enum: ['terse', 'debug'],
1143
+ description: 'Output format. Default: "terse".',
1144
+ },
1145
+ },
1146
+ },
1147
+ },
1148
+ {
1149
+ name: contextGetToolName,
1150
+ description: 'Get the current default {owner, repo} for this session.',
1151
+ inputSchema: { type: 'object', additionalProperties: true, properties: {} },
1152
+ },
1153
+ {
1154
+ name: searchToolsToolName,
1155
+ description: 'Search available git MCP tools by substring (returns names + descriptions).',
1156
+ inputSchema: {
1157
+ type: 'object',
1158
+ additionalProperties: true,
1159
+ properties: {
1160
+ query: { type: 'string', description: 'Search query (substring match on tool name/path)' },
1161
+ limit: { type: 'integer', minimum: 1, description: 'Max matches to return (default: 20)' },
1162
+ format: { type: 'string', enum: ['terse', 'debug'] },
1163
+ },
1164
+ required: ['query'],
1165
+ },
1166
+ },
1167
+ {
1168
+ name: prPreflightToolName,
1169
+ description: 'Fetch PR metadata + checks + review artifacts in one call.',
1170
+ inputSchema: {
1171
+ type: 'object',
1172
+ additionalProperties: true,
1173
+ properties: {
1174
+ owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
1175
+ repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
1176
+ number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
1177
+ includeIssues: { type: 'boolean', description: 'If true, fetch referenced issues mentioned as "Fixes #123".' },
1178
+ validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
1179
+ fields: {
1180
+ description: 'Optional field selection applied to pr/checks/review bodies.',
1181
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
1182
+ },
1183
+ format: { type: 'string', enum: ['terse', 'debug'] },
1184
+ },
1185
+ anyOf: [{ required: ['number'] }, { required: ['args'] }],
1186
+ },
1187
+ },
1188
+ {
1189
+ name: prMergeAndVerifyToolName,
1190
+ description: 'Merge a PR via hosting API and verify PR state transitions to merged/closed.',
1191
+ inputSchema: {
1192
+ type: 'object',
1193
+ additionalProperties: true,
1194
+ properties: {
1195
+ owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
1196
+ repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
1197
+ number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
1198
+ mergeMethod: { type: 'string', description: 'Merge method. Maps to Gitea merge Do field (default: "merge").' },
1199
+ maxAttempts: { type: 'integer', minimum: 1, description: 'Max poll attempts (default: 6)' },
1200
+ delayMs: { type: 'integer', minimum: 0, description: 'Delay between polls in ms (default: 1000)' },
1201
+ validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
1202
+ format: { type: 'string', enum: ['terse', 'debug'] },
1203
+ },
1204
+ anyOf: [{ required: ['number'] }, { required: ['args'] }],
1205
+ },
1206
+ },
1207
+ ]
1208
+
1209
+ const customToolMetaByName = new Map<string, McpToolListEntry>(customTools.map((tool) => [tool.name, tool]))
1210
+
1211
+ const sleep = async (ms: number): Promise<void> => {
1212
+ if (ms <= 0) return
1213
+ await new Promise<void>((resolve) => setTimeout(resolve, ms))
1214
+ }
1215
+
1216
+ const toOk = (data: unknown, format: McpToolOutputFormat, debug?: unknown, status: number = 0): McpTerseOk => {
1217
+ const envelope: McpTerseOk = {
1218
+ ok: true,
1219
+ data,
1220
+ meta: {
1221
+ status,
1222
+ },
1223
+ ...(format === 'debug' && debug !== undefined ? { debug } : {}),
1224
+ }
1225
+
1226
+ return redactSecretsForMcpOutput(envelope) as McpTerseOk
1227
+ }
1228
+
1229
+ const toErr = (error: McpTerseErr, format: McpToolOutputFormat, debug?: unknown): McpTerseErr => {
1230
+ const enriched: McpTerseErr = {
1231
+ ...error,
1232
+ ...(format === 'debug' && debug !== undefined ? { debug } : {}),
1233
+ }
1234
+ return redactSecretsForMcpOutput(enriched) as McpTerseErr
1235
+ }
1236
+
1237
+ const invokeCustomTool = async (
1238
+ toolName: string,
1239
+ payload: unknown,
1240
+ controls: { format: McpToolOutputFormat | null; validateOnly: boolean; fields: McpFieldSelection | null },
1241
+ ): Promise<{ isError: boolean; envelope: McpTerseOk | McpTerseErr }> => {
1242
+ const format = controls.format ?? 'terse'
1243
+
1244
+ if (toolName === contextGetToolName) {
1245
+ return { isError: false, envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format) }
1246
+ }
1247
+
1248
+ if (toolName === contextSetToolName) {
1249
+ const normalized = normalizePayload(payload)
1250
+ const ownerFromArgs = normalized.args.length >= 1 ? toTrimmedString(normalized.args[0]) : ''
1251
+ const repoFromArgs = normalized.args.length >= 2 ? toTrimmedString(normalized.args[1]) : ''
1252
+
1253
+ const owner = toTrimmedString(pickFirst(normalized.options.owner, ownerFromArgs)) || ''
1254
+ const repo = toTrimmedString(pickFirst(normalized.options.repo, repoFromArgs)) || ''
1255
+
1256
+ context.owner = owner || undefined
1257
+ context.repo = repo || undefined
1258
+
1259
+ return { isError: false, envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format) }
1260
+ }
1261
+
1262
+ if (toolName === searchToolsToolName) {
1263
+ const normalized = normalizePayload(payload)
1264
+ const query = toTrimmedString(pickFirst(normalized.options.query, normalized.args[0])) || ''
1265
+ if (!query) {
1266
+ return {
1267
+ isError: true,
1268
+ envelope: toErr(
1269
+ {
1270
+ ok: false,
1271
+ error: {
1272
+ code: 'INVALID_INPUT',
1273
+ message: 'query is required. Example: { query: \"pr.merge\" }',
1274
+ retryable: false,
1275
+ },
1276
+ },
1277
+ format,
1278
+ ),
1279
+ }
1280
+ }
1281
+
1282
+ const limitRaw = pickFirst(normalized.options.limit, normalized.args[1])
1283
+ const limit = toPositiveInteger(limitRaw) ?? 20
1284
+
1285
+ const haystack = [
1286
+ ...toolByName.keys(),
1287
+ ...customTools.map((tool) => tool.name),
1288
+ batchToolName,
1289
+ ]
1290
+ const q = query.toLowerCase()
1291
+ const matches = haystack
1292
+ .filter((name) => name.toLowerCase().includes(q))
1293
+ .slice(0, limit)
1294
+ .map((name) => {
1295
+ const meta =
1296
+ toolByName.has(name)
1297
+ ? { name, description: `Call git API method ${toolByName.get(name)?.path.join('.') ?? name}` }
1298
+ : customToolMetaByName.get(name) ?? (name === batchToolName ? { name, description: 'Batch tool' } : { name, description: '' })
1299
+ return meta
1300
+ })
1301
+
1302
+ return { isError: false, envelope: toOk({ matches }, format) }
1303
+ }
1304
+
1305
+ if (toolName === prPreflightToolName) {
1306
+ const normalized = normalizePayload(payload)
1307
+ const optionsWithDefaults = { ...normalized.options }
1308
+ const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
1309
+ if (owner) optionsWithDefaults.owner = owner
1310
+ if (repo) optionsWithDefaults.repo = repo
1311
+
1312
+ const number = resolvePrNumber(normalized.args, optionsWithDefaults)
1313
+ if (!number) {
1314
+ return {
1315
+ isError: true,
1316
+ envelope: toErr(
1317
+ {
1318
+ ok: false,
1319
+ error: {
1320
+ code: 'INVALID_INPUT',
1321
+ message: 'number is required. Example: { owner, repo, number: 766 } (or { args:[\"766\"], owner, repo }).',
1322
+ retryable: false,
1323
+ },
1324
+ },
1325
+ format,
1326
+ ),
1327
+ }
1328
+ }
1329
+
1330
+ const includeIssues = Boolean((optionsWithDefaults as Record<string, unknown>).includeIssues)
1331
+
1332
+ if (controls.validateOnly) {
1333
+ const toolPrefix = prefix ? `${prefix}.` : ''
1334
+ return {
1335
+ isError: false,
1336
+ envelope: toOk(
1337
+ {
1338
+ valid: true,
1339
+ owner: owner ?? null,
1340
+ repo: repo ?? null,
1341
+ number,
1342
+ calls: [
1343
+ { tool: `${toolPrefix}repo.pr.view`, args: [number], options: { owner, repo } },
1344
+ { tool: `${toolPrefix}repo.pr.checks`, args: [number], options: { owner, repo } },
1345
+ { tool: `${toolPrefix}repo.pr.review`, args: [number], options: { owner, repo, method: 'GET' } },
1346
+ ],
1347
+ includeIssues,
1348
+ },
1349
+ format,
1350
+ ),
1351
+ }
1352
+ }
1353
+
1354
+ const callOptionsBase = stripMcpOnlyOptions(optionsWithDefaults)
1355
+ const callOptions: Record<string, unknown> = { ...callOptionsBase }
1356
+ delete (callOptions as any).includeIssues
1357
+ delete (callOptions as any).number
1358
+ delete (callOptions as any).prNumber
1359
+ delete (callOptions as any).index
1360
+
1361
+ const pr = await (api as any).repo.pr.view(number, callOptions)
1362
+ const checks = await (api as any).repo.pr.checks(number, callOptions)
1363
+ const review = await (api as any).repo.pr.review(number, { ...callOptions, method: 'GET' })
1364
+
1365
+ const referencedIssueNumbers: number[] = []
1366
+ if (isRecord(pr.body) && typeof (pr.body as any).body === 'string') {
1367
+ const body = String((pr.body as any).body)
1368
+ const matches = body.matchAll(/\b(?:fixes|closes|resolves)\s+#(\d+)\b/gi)
1369
+ for (const match of matches) {
1370
+ const n = Number(match[1])
1371
+ if (Number.isFinite(n) && n > 0) referencedIssueNumbers.push(n)
1372
+ }
1373
+ }
1374
+
1375
+ const uniqueReferenced = Array.from(new Set(referencedIssueNumbers))
1376
+ const issues: unknown[] = []
1377
+ if (includeIssues && uniqueReferenced.length > 0) {
1378
+ for (const n of uniqueReferenced) {
1379
+ try {
1380
+ const issue = await (api as any).repo.issue.view(String(n), callOptions)
1381
+ issues.push(issue.body)
1382
+ } catch {
1383
+ // best effort
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ const data = {
1389
+ pr: applyFieldSelection(pr.body, controls.fields),
1390
+ checks: applyFieldSelection(checks.body, controls.fields),
1391
+ review: applyFieldSelection(review.body, controls.fields),
1392
+ referencedIssueNumbers: uniqueReferenced,
1393
+ ...(includeIssues ? { issues } : {}),
1394
+ }
1395
+
1396
+ const allOk = pr.ok && checks.ok && review.ok
1397
+ if (!allOk) {
1398
+ return {
1399
+ isError: true,
1400
+ envelope: toErr(
1401
+ {
1402
+ ok: false,
1403
+ error: {
1404
+ code: 'PREFLIGHT_FAILED',
1405
+ message: 'One or more preflight calls failed. See details.',
1406
+ details: {
1407
+ pr: { ok: pr.ok, status: pr.status },
1408
+ checks: { ok: checks.ok, status: checks.status },
1409
+ review: { ok: review.ok, status: review.status },
1410
+ },
1411
+ retryable: false,
1412
+ },
1413
+ },
1414
+ format,
1415
+ { pr, checks, review },
1416
+ ),
1417
+ }
1418
+ }
1419
+
1420
+ return { isError: false, envelope: toOk(data, format, { pr, checks, review }, 200) }
1421
+ }
1422
+
1423
+ if (toolName === prMergeAndVerifyToolName) {
1424
+ const normalized = normalizePayload(payload)
1425
+ const optionsWithDefaults = { ...normalized.options }
1426
+ const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
1427
+ if (owner) optionsWithDefaults.owner = owner
1428
+ if (repo) optionsWithDefaults.repo = repo
1429
+
1430
+ const number = resolvePrNumber(normalized.args, optionsWithDefaults)
1431
+ if (!number) {
1432
+ return {
1433
+ isError: true,
1434
+ envelope: toErr(
1435
+ {
1436
+ ok: false,
1437
+ error: {
1438
+ code: 'INVALID_INPUT',
1439
+ message: 'number is required. Example: { owner, repo, number: 766 }',
1440
+ retryable: false,
1441
+ },
1442
+ },
1443
+ format,
1444
+ ),
1445
+ }
1446
+ }
1447
+
1448
+ const maxAttempts = toPositiveInteger((optionsWithDefaults as Record<string, unknown>).maxAttempts) ?? 6
1449
+ const delayMs = toNonNegativeInteger((optionsWithDefaults as Record<string, unknown>).delayMs) ?? 1000
1450
+ const mergeMethod = toTrimmedString((optionsWithDefaults as Record<string, unknown>).mergeMethod) || 'merge'
1451
+
1452
+ if (controls.validateOnly) {
1453
+ return {
1454
+ isError: false,
1455
+ envelope: toOk(
1456
+ {
1457
+ valid: true,
1458
+ owner: owner ?? null,
1459
+ repo: repo ?? null,
1460
+ number,
1461
+ mergeMethod,
1462
+ maxAttempts,
1463
+ delayMs,
1464
+ },
1465
+ format,
1466
+ ),
1467
+ }
1468
+ }
1469
+
1470
+ const callOptionsBase = stripMcpOnlyOptions(optionsWithDefaults)
1471
+ const callOptions: Record<string, unknown> = { ...callOptionsBase }
1472
+ delete (callOptions as any).maxAttempts
1473
+ delete (callOptions as any).delayMs
1474
+ delete (callOptions as any).number
1475
+ delete (callOptions as any).prNumber
1476
+ delete (callOptions as any).index
1477
+ delete (callOptions as any).mergeMethod
1478
+
1479
+ let mergeResult: GitServiceApiExecutionResult | null = null
1480
+ let lastMergeError: unknown = null
1481
+
1482
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1483
+ mergeResult = await (api as any).repo.pr.merge(number, { ...callOptions, mergeMethod })
1484
+ if (mergeResult.ok && mergeResult.status < 400) {
1485
+ break
1486
+ }
1487
+
1488
+ lastMergeError = mergeResult.body
1489
+ const message = buildErrorMessage(mergeResult.status, mergeResult.body)
1490
+ if (!/try again later/i.test(message) && mergeResult.status !== 429 && mergeResult.status < 500) {
1491
+ break
1492
+ }
1493
+
1494
+ await sleep(delayMs)
1495
+ }
1496
+
1497
+ if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
1498
+ return {
1499
+ isError: true,
1500
+ envelope: toErr(
1501
+ {
1502
+ ok: false,
1503
+ error: {
1504
+ code: 'MERGE_FAILED',
1505
+ status: mergeResult?.status,
1506
+ message: buildErrorMessage(mergeResult?.status, mergeResult?.body ?? lastMergeError),
1507
+ details: mergeResult?.body ?? lastMergeError,
1508
+ retryable: true,
1509
+ },
1510
+ meta: mergeResult?.status ? { status: mergeResult.status } : undefined,
1511
+ },
1512
+ format,
1513
+ { mergeResult },
1514
+ ),
1515
+ }
1516
+ }
1517
+
1518
+ const views: GitServiceApiExecutionResult[] = []
1519
+ let prAfter: GitServiceApiExecutionResult | null = null
1520
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1521
+ prAfter = await (api as any).repo.pr.view(number, callOptions)
1522
+ views.push(prAfter)
1523
+
1524
+ const merged = isRecord(prAfter.body) ? Boolean((prAfter.body as any).merged) : false
1525
+ const state = isRecord(prAfter.body) && typeof (prAfter.body as any).state === 'string' ? String((prAfter.body as any).state) : ''
1526
+ if (merged || state.toLowerCase() === 'closed') {
1527
+ break
1528
+ }
1529
+
1530
+ await sleep(delayMs)
1531
+ }
1532
+
1533
+ const data = {
1534
+ merge: mergeResult.body,
1535
+ pr: prAfter ? prAfter.body : null,
1536
+ polled: views.length,
1537
+ }
1538
+
1539
+ const merged = prAfter && isRecord(prAfter.body) ? Boolean((prAfter.body as any).merged) : false
1540
+ const state = prAfter && isRecord(prAfter.body) && typeof (prAfter.body as any).state === 'string' ? String((prAfter.body as any).state) : ''
1541
+
1542
+ if (!merged && state.toLowerCase() !== 'closed') {
1543
+ return {
1544
+ isError: true,
1545
+ envelope: toErr(
1546
+ {
1547
+ ok: false,
1548
+ error: {
1549
+ code: 'MERGE_VERIFY_FAILED',
1550
+ message: 'Merge request succeeded, but PR state did not transition to merged/closed within polling window.',
1551
+ details: { merged, state },
1552
+ retryable: true,
1553
+ },
1554
+ },
1555
+ format,
1556
+ { mergeResult, prAfter, views },
1557
+ ),
1558
+ }
1559
+ }
1560
+
1561
+ return { isError: false, envelope: toOk(data, format, { mergeResult, prAfter, views }, 200) }
1562
+ }
1563
+
1564
+ return {
1565
+ isError: true,
1566
+ envelope: toErr(
1567
+ {
1568
+ ok: false,
1569
+ error: { code: 'UNKNOWN_TOOL', message: `Unknown tool: ${toolName}`, retryable: false },
1570
+ },
1571
+ format,
1572
+ ),
1573
+ }
1574
+ }
1575
+
1576
+ const server = new Server(
1577
+ {
1578
+ name: options.serverName ?? 'git',
1579
+ version: options.serverVersion ?? '1.0.0',
1580
+ },
1581
+ {
1582
+ capabilities: {
1583
+ tools: {},
1584
+ },
1585
+ },
1586
+ )
1587
+
1588
+ const toolByName = new Map<string, ToolDefinition>(tools.map((tool) => [buildToolName(tool, prefix), tool]))
1589
+
1590
+ const listTools = (): McpToolListEntry[] => buildToolList(tools, batchToolName, prefix, customTools)
1591
+
1592
+ const callTool = async (requestedName: string, payload: unknown): Promise<{ isError: boolean; text: string }> => {
1593
+ if (customToolMetaByName.has(requestedName)) {
1594
+ const controls = extractMcpControls(payload)
1595
+ const { isError, envelope } = await invokeCustomTool(requestedName, payload, controls)
1596
+ return { isError, text: JSON.stringify(envelope) }
1597
+ }
1598
+
1599
+ if (requestedName === batchToolName) {
1600
+ const { calls, continueOnError } = normalizeBatchPayload(payload)
1601
+ const batchControls = extractMcpControls(payload)
1602
+
1603
+ const executions = calls.map(({ tool, args = [], options = {} }, index) => ({ tool, args, options, index }))
1604
+ const results = await Promise.all(
1605
+ executions.map(async ({ tool, args, options, index }) => {
1606
+ if (customToolMetaByName.has(tool)) {
1607
+ const mergedPayload = { args, options }
1608
+ const callControls = extractMcpControls(mergedPayload)
1609
+ const effectiveControls = {
1610
+ ...callControls,
1611
+ format: callControls.format ?? batchControls.format ?? null,
1612
+ fields: callControls.fields ?? batchControls.fields ?? null,
1613
+ validateOnly: callControls.validateOnly || batchControls.validateOnly,
1614
+ }
1615
+
1616
+ try {
1617
+ const { isError, envelope } = await invokeCustomTool(tool, mergedPayload, effectiveControls)
1618
+ return {
1619
+ index,
1620
+ tool,
1621
+ isError,
1622
+ ...(envelope as McpTerseOk | McpTerseErr),
1623
+ } as BatchResult
1624
+ } catch (error) {
1625
+ if (continueOnError) {
1626
+ return {
1627
+ index,
1628
+ tool,
1629
+ isError: true,
1630
+ ...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
1631
+ } as BatchResult
1632
+ }
1633
+ throw error
1634
+ }
1635
+ }
1636
+
1637
+ const toolDefinition = toolByName.get(tool)
1638
+ if (!toolDefinition) {
1639
+ return {
1640
+ index,
1641
+ tool,
1642
+ isError: true,
1643
+ ...({
1644
+ ok: false,
1645
+ error: {
1646
+ code: 'UNKNOWN_TOOL',
1647
+ message: `Unknown tool: ${tool}`,
1648
+ retryable: false,
1649
+ },
1650
+ } satisfies McpTerseErr),
1651
+ } as BatchResult
1652
+ }
1653
+
1654
+ try {
1655
+ const mergedPayload = { args, options }
1656
+ const callControls = extractMcpControls(mergedPayload)
1657
+ const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
1658
+ const effectiveFields = callControls.fields ?? batchControls.fields ?? null
1659
+ const validateOnly = callControls.validateOnly || batchControls.validateOnly
1660
+
1661
+ if (validateOnly) {
1662
+ const normalized = normalizePayloadWithContext(toolDefinition, mergedPayload)
1663
+ const envelope: McpTerseOk = {
1664
+ ok: true,
1665
+ data: {
1666
+ valid: true,
1667
+ tool,
1668
+ args: normalized.args,
1669
+ options: stripMcpOnlyOptions(normalized.options),
1670
+ },
1671
+ meta: {
1672
+ status: 0,
1673
+ },
1674
+ }
1675
+
1676
+ return {
1677
+ index,
1678
+ tool,
1679
+ isError: false,
1680
+ ...(redactSecretsForMcpOutput(envelope) as McpTerseOk),
1681
+ } as BatchResult
1682
+ }
1683
+
1684
+ const normalized = normalizePayloadWithContext(toolDefinition, mergedPayload)
1685
+ const normalizedPayload = { args: normalized.args, options: normalized.options }
1686
+
1687
+ const data = await (isLogsForRunTailTool(toolDefinition.name)
1688
+ ? invokeLogsForRunTailTool(toolDefinition, normalizedPayload)
1689
+ : isArtifactsByRunTool(toolDefinition.name)
1690
+ ? invokeArtifactsByRunTool(toolDefinition, normalizedPayload)
1691
+ : invokeTool(toolDefinition, normalizedPayload))
1692
+
1693
+ const selected = effectiveFields ? { ...data, body: applyFieldSelection(data.body, effectiveFields) } : data
1694
+ const { isError, envelope } = toMcpEnvelope(selected, effectiveFormat)
1695
+ return {
1696
+ index,
1697
+ tool,
1698
+ isError,
1699
+ ...(envelope as McpTerseOk | McpTerseErr),
1700
+ } as BatchResult
1701
+ } catch (error) {
1702
+ if (continueOnError) {
1703
+ return {
1704
+ index,
1705
+ tool,
1706
+ isError: true,
1707
+ ...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
1708
+ } as BatchResult
1709
+ }
1710
+ throw error
1711
+ }
1712
+ }),
1713
+ )
1714
+
1715
+ return {
1716
+ isError: results.some((result) => result.isError),
1717
+ text: JSON.stringify(redactSecretsForMcpOutput(results)),
1718
+ }
1719
+ }
1720
+
1721
+ const tool = toolByName.get(requestedName)
1722
+ if (!tool) {
1723
+ throw new Error(`Unknown tool: ${requestedName}`)
1724
+ }
1725
+
1726
+ const controls = extractMcpControls(payload)
1727
+
1728
+ if (controls.validateOnly) {
1729
+ const normalized = normalizePayloadWithContext(tool, payload)
1730
+ const envelope: McpTerseOk = {
1731
+ ok: true,
1732
+ data: {
1733
+ valid: true,
1734
+ tool: requestedName,
1735
+ args: normalized.args,
1736
+ options: stripMcpOnlyOptions(normalized.options),
1737
+ },
1738
+ meta: {
1739
+ status: 0,
1740
+ },
1741
+ }
1742
+
1743
+ return { isError: false, text: JSON.stringify(redactSecretsForMcpOutput(envelope)) }
1744
+ }
1745
+
1746
+ const normalized = normalizePayloadWithContext(tool, payload)
1747
+ const normalizedPayload = { args: normalized.args, options: normalized.options }
1748
+
1749
+ const result = await (isLogsForRunTailTool(tool.name)
1750
+ ? invokeLogsForRunTailTool(tool, normalizedPayload)
1751
+ : isArtifactsByRunTool(tool.name)
1752
+ ? invokeArtifactsByRunTool(tool, normalizedPayload)
1753
+ : invokeTool(tool, normalizedPayload))
1754
+
1755
+ const selected = controls.fields ? { ...result, body: applyFieldSelection(result.body, controls.fields) } : result
1756
+ const { isError, envelope } = toMcpEnvelope(selected, controls.format ?? 'terse')
1757
+ return { isError, text: JSON.stringify(envelope) }
1758
+ }
1759
+
1760
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1761
+ tools: listTools(),
1762
+ }))
1763
+
1764
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1765
+ try {
1766
+ const result = await callTool(request.params.name, request.params.arguments)
1767
+ return {
1768
+ ...(result.isError ? { isError: true } : {}),
1769
+ content: [
1770
+ {
1771
+ type: 'text',
1772
+ text: result.text,
1773
+ },
1774
+ ],
1775
+ }
1776
+ } catch (error) {
1777
+ if (error instanceof Error && error.message.startsWith('Unknown tool:')) {
1778
+ throw error
1779
+ }
1780
+
1781
+ return {
1782
+ isError: true,
1783
+ content: [
1784
+ {
1785
+ type: 'text',
1786
+ text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
1787
+ },
1788
+ ],
1789
+ }
1790
+ }
1791
+ })
1792
+
1793
+ const run = async (): Promise<Server> => {
1794
+ await server.connect(new StdioServerTransport())
1795
+ return server
1796
+ }
1797
+
1798
+ return { api, tools, server, listTools, callTool, run }
1799
+ }
1800
+
1801
+ export const runGitMcpServer = async (options: GitMcpServerOptions = {}): Promise<Server> => {
1802
+ const instance = createGitMcpServer(options)
1803
+ return instance.run()
1804
+ }
1805
+
1806
+ export const normalizeToolCallNameForServer = (
1807
+ prefix: string | undefined,
1808
+ toolName: string,
1809
+ ): string => normalizeToolCallName(prefix, toolName)