@foundation0/git 1.2.4 → 1.3.0

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