@foundation0/git 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/src/server.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { createGitServiceApi } from '@foundation0/git'
2
- import type {
3
- GitServiceApi,
4
- GitServiceApiExecutionResult,
5
- GitServiceApiFactoryOptions,
6
- GitServiceApiMethod,
1
+ import { callIssueDependenciesApi, createGitServiceApi, extractDependencyIssueNumbers } from '@foundation0/git'
2
+ import type {
3
+ GitServiceApi,
4
+ GitServiceApiExecutionResult,
5
+ GitServiceApiFactoryOptions,
6
+ GitServiceApiMethod,
7
7
  } from '@foundation0/git'
8
8
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -16,9 +16,16 @@ type ToolInvocationPayload = {
16
16
  [key: string]: unknown
17
17
  }
18
18
 
19
- type McpToolOutputFormat = 'terse' | 'debug'
20
-
21
- type McpFieldSelection = string[]
19
+ type McpToolOutputFormat = 'terse' | 'debug'
20
+
21
+ type McpFieldSelection = string[]
22
+
23
+ type McpCallControls = {
24
+ format: McpToolOutputFormat | null
25
+ validateOnly: boolean
26
+ fields: McpFieldSelection | null
27
+ full: boolean
28
+ }
22
29
 
23
30
  type BatchToolCall = {
24
31
  tool: string
@@ -161,28 +168,31 @@ const parseFieldSelection = (value: unknown): McpFieldSelection | null => {
161
168
  return null
162
169
  }
163
170
 
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
- }
171
+ const extractMcpControls = (
172
+ payload: unknown,
173
+ ): McpCallControls => {
174
+ const normalized = normalizeArgumentPayload(payload)
175
+ if (!isRecord(normalized)) {
176
+ return { format: null, validateOnly: false, fields: null, full: false }
177
+ }
178
+
179
+ const topLevelFormat = parseOutputFormat(normalized.format)
180
+ const topLevelValidateOnly = parseBoolean(normalized.validateOnly)
181
+ const topLevelFields = parseFieldSelection(normalized.fields)
182
+ const topLevelFull = parseBoolean(normalized.full)
183
+ const optionsFormat = parseOutputFormat(pickRecord(normalized.options).format)
184
+ const optionsValidateOnly = parseBoolean(pickRecord(normalized.options).validateOnly)
185
+ const optionsFields = parseFieldSelection(pickRecord(normalized.options).fields)
186
+ const optionsFull = parseBoolean(pickRecord(normalized.options).full)
187
+
188
+ // Support either { format:"debug" } or { options:{ format:"debug" } }.
189
+ return {
190
+ format: pickFirst(topLevelFormat, optionsFormat),
191
+ validateOnly: Boolean(pickFirst(topLevelValidateOnly, optionsValidateOnly)),
192
+ fields: pickFirst(topLevelFields, optionsFields),
193
+ full: Boolean(pickFirst(topLevelFull, optionsFull)),
194
+ }
195
+ }
186
196
 
187
197
  type McpTerseOk = {
188
198
  ok: true
@@ -282,23 +292,298 @@ const buildErrorMessage = (status: number | undefined, body: unknown): string =>
282
292
  if (typeof status === 'number') {
283
293
  return `HTTP ${status}`
284
294
  }
295
+
296
+ return 'Request failed'
297
+ }
298
+
299
+ const normalizeGiteaApiBase = (host: string): string => {
300
+ const trimmed = host.trim().replace(/\/$/, '')
301
+ return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
302
+ }
303
+
304
+ const safeStringify = (value: unknown): string => {
305
+ try {
306
+ return JSON.stringify(value)
307
+ } catch {
308
+ return ''
309
+ }
310
+ }
311
+
312
+ const blockingIssueMessagePattern = /close all issues blocking this pull request|issues blocking this pull request|blocking this pull request/i
313
+
314
+ const isBlockingIssueMessage = (status: number | undefined, body: unknown): boolean => {
315
+ const message = buildErrorMessage(status, body)
316
+ if (blockingIssueMessagePattern.test(message)) {
317
+ return true
318
+ }
319
+
320
+ const bodyText = typeof body === 'string' ? body : safeStringify(body)
321
+ return blockingIssueMessagePattern.test(bodyText)
322
+ }
323
+
324
+ type MergeFailureClassification = {
325
+ code: string
326
+ message: string
327
+ retryable: boolean
328
+ blockedByIssues: boolean
329
+ }
330
+
331
+ const classifyMergeFailure = (status: number | undefined, body: unknown): MergeFailureClassification => {
332
+ const message = buildErrorMessage(status, body)
333
+ const blockedByIssues = isBlockingIssueMessage(status, body)
334
+ const retryable =
335
+ !blockedByIssues &&
336
+ (status === 429 || (typeof status === 'number' && status >= 500) || /try again later/i.test(message))
337
+
338
+ return {
339
+ code: blockedByIssues ? 'PR_BLOCKED_BY_ISSUES' : 'MERGE_FAILED',
340
+ message,
341
+ retryable,
342
+ blockedByIssues,
343
+ }
344
+ }
345
+
346
+ const inferHeadContainedFromCompareBody = (body: unknown): boolean | null => {
347
+ if (!isRecord(body)) {
348
+ return null
349
+ }
350
+
351
+ const totalCommits = Number((body as Record<string, unknown>).total_commits)
352
+ if (Number.isFinite(totalCommits)) {
353
+ return totalCommits === 0
354
+ }
355
+
356
+ const commits = (body as Record<string, unknown>).commits
357
+ if (Array.isArray(commits)) {
358
+ return commits.length === 0
359
+ }
360
+
361
+ return null
362
+ }
285
363
 
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
- },
364
+ const isNonEmptyString = (value: unknown): boolean => typeof value === 'string' && value.trim().length > 0
365
+
366
+ const isEmptyObject = (value: unknown): boolean => isRecord(value) && Object.keys(value).length === 0
367
+
368
+ const isEffectivelyEmpty = (value: unknown): boolean => {
369
+ if (value === null || value === undefined) return true
370
+ if (typeof value === 'string') return value.trim().length === 0
371
+ if (Array.isArray(value)) return value.length === 0
372
+ if (isEmptyObject(value)) return true
373
+ return false
374
+ }
375
+
376
+ const hasKey = (value: Record<string, unknown>, key: string): boolean =>
377
+ Object.prototype.hasOwnProperty.call(value, key)
378
+
379
+ const isLikelyUserRecord = (value: Record<string, unknown>): boolean =>
380
+ (hasKey(value, 'login') || hasKey(value, 'username')) &&
381
+ (hasKey(value, 'avatar_url') || hasKey(value, 'html_url') || hasKey(value, 'is_admin'))
382
+
383
+ const isLikelyRepoRecord = (value: Record<string, unknown>): boolean =>
384
+ (hasKey(value, 'full_name') || hasKey(value, 'default_branch')) &&
385
+ (hasKey(value, 'private') || hasKey(value, 'clone_url') || hasKey(value, 'ssh_url'))
386
+
387
+ const isLikelyLabelRecord = (value: Record<string, unknown>): boolean =>
388
+ hasKey(value, 'name') && hasKey(value, 'color') && (hasKey(value, 'id') || hasKey(value, 'description'))
389
+
390
+ const isLikelyMilestoneRecord = (value: Record<string, unknown>): boolean =>
391
+ hasKey(value, 'title') && hasKey(value, 'state') && (hasKey(value, 'due_on') || hasKey(value, 'closed_at'))
392
+
393
+ const isLikelyBranchRefRecord = (value: Record<string, unknown>): boolean =>
394
+ hasKey(value, 'ref') && hasKey(value, 'sha') && hasKey(value, 'repo')
395
+
396
+ const isLikelyPullRequestRecord = (value: Record<string, unknown>): boolean =>
397
+ hasKey(value, 'number') && (hasKey(value, 'head') || hasKey(value, 'base') || hasKey(value, 'merged'))
398
+
399
+ const isLikelyIssueRecord = (value: Record<string, unknown>): boolean =>
400
+ hasKey(value, 'number') && hasKey(value, 'title') && hasKey(value, 'state') && !isLikelyPullRequestRecord(value)
401
+
402
+ const isLikelyReviewRecord = (value: Record<string, unknown>): boolean =>
403
+ (hasKey(value, 'commit_id') && hasKey(value, 'state') && hasKey(value, 'user')) || hasKey(value, 'submitted_at')
404
+
405
+ const compactRecordByKeys = (
406
+ value: Record<string, unknown>,
407
+ keys: string[],
408
+ compact: (entry: unknown, keyHint?: string) => unknown,
409
+ ): Record<string, unknown> => {
410
+ const next: Record<string, unknown> = {}
411
+
412
+ for (const key of keys) {
413
+ if (!hasKey(value, key)) continue
414
+ const compacted = compact(value[key], key)
415
+ if (isEffectivelyEmpty(compacted)) continue
416
+ next[key] = compacted
417
+ }
418
+
419
+ return next
420
+ }
421
+
422
+ const compactUserRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> => {
423
+ const next: Record<string, unknown> = {}
424
+ const id = value.id
425
+ const login = toTrimmedString(pickFirst(value.login, value.username))
426
+ const fullName = toTrimmedString(value.full_name)
427
+ const htmlUrl = toTrimmedString(value.html_url)
428
+
429
+ if (id !== undefined && id !== null) next.id = id
430
+ if (login) next.login = login
431
+ if (fullName) next.full_name = fullName
432
+ if (htmlUrl) next.html_url = htmlUrl
433
+
434
+ if (Object.keys(next).length > 0) return next
435
+ return compactRecordByKeys(value, ['login', 'username', 'id'], compact)
436
+ }
437
+
438
+ const compactRepoRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
439
+ compactRecordByKeys(value, ['id', 'name', 'full_name', 'private', 'default_branch', 'archived', 'html_url'], compact)
440
+
441
+ const compactLabelRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
442
+ compactRecordByKeys(value, ['id', 'name', 'color', 'description'], compact)
443
+
444
+ const compactMilestoneRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
445
+ compactRecordByKeys(value, ['id', 'title', 'state', 'due_on', 'closed_at'], compact)
446
+
447
+ const compactBranchRefRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
448
+ compactRecordByKeys(value, ['label', 'ref', 'sha', 'repo'], compact)
449
+
450
+ const compactPullRequestRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
451
+ compactRecordByKeys(
452
+ value,
453
+ [
454
+ 'id',
455
+ 'number',
456
+ 'title',
457
+ 'body',
458
+ 'state',
459
+ 'draft',
460
+ 'is_locked',
461
+ 'comments',
462
+ 'additions',
463
+ 'deletions',
464
+ 'changed_files',
465
+ 'mergeable',
466
+ 'merged',
467
+ 'merged_at',
468
+ 'merge_commit_sha',
469
+ 'allow_maintainer_edit',
470
+ 'html_url',
471
+ 'user',
472
+ 'labels',
473
+ 'milestone',
474
+ 'assignee',
475
+ 'assignees',
476
+ 'requested_reviewers',
477
+ 'base',
478
+ 'head',
479
+ 'merge_base',
480
+ 'created_at',
481
+ 'updated_at',
482
+ 'closed_at',
483
+ ],
484
+ compact,
485
+ )
486
+
487
+ const compactIssueRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
488
+ compactRecordByKeys(
489
+ value,
490
+ [
491
+ 'id',
492
+ 'number',
493
+ 'title',
494
+ 'body',
495
+ 'state',
496
+ 'user',
497
+ 'labels',
498
+ 'milestone',
499
+ 'assignee',
500
+ 'assignees',
501
+ 'comments',
502
+ 'html_url',
503
+ 'created_at',
504
+ 'updated_at',
505
+ 'closed_at',
506
+ ],
507
+ compact,
508
+ )
509
+
510
+ const compactReviewRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
511
+ compactRecordByKeys(value, ['id', 'state', 'body', 'user', 'commit_id', 'submitted_at', 'html_url'], compact)
512
+
513
+ const compactResponseDataForDefaultMode = (value: unknown, keyHint?: string): unknown => {
514
+ const compact = compactResponseDataForDefaultMode
515
+
516
+ if (Array.isArray(value)) {
517
+ const next = value
518
+ .map((entry) => compact(entry, keyHint))
519
+ .filter((entry) => !isEffectivelyEmpty(entry))
520
+ return next
521
+ }
522
+
523
+ if (!isRecord(value)) {
524
+ if (typeof value === 'string') {
525
+ const trimmed = value.trim()
526
+ return trimmed.length > 0 ? value : undefined
527
+ }
528
+ return value
529
+ }
530
+
531
+ if (isLikelyPullRequestRecord(value)) return compactPullRequestRecord(value, compact)
532
+ if (isLikelyIssueRecord(value)) return compactIssueRecord(value, compact)
533
+ if (isLikelyReviewRecord(value)) return compactReviewRecord(value, compact)
534
+ if (isLikelyBranchRefRecord(value)) return compactBranchRefRecord(value, compact)
535
+ if (isLikelyMilestoneRecord(value)) return compactMilestoneRecord(value, compact)
536
+ if (isLikelyLabelRecord(value)) return compactLabelRecord(value, compact)
537
+ if (isLikelyRepoRecord(value)) return compactRepoRecord(value, compact)
538
+ if (isLikelyUserRecord(value)) return compactUserRecord(value, compact)
539
+
540
+ if (keyHint === 'user' || keyHint === 'owner' || keyHint === 'assignee' || keyHint === 'merged_by') {
541
+ return compactUserRecord(value, compact)
542
+ }
543
+ if (keyHint === 'repo') {
544
+ return compactRepoRecord(value, compact)
545
+ }
546
+ if (keyHint === 'base' || keyHint === 'head') {
547
+ return compactBranchRefRecord(value, compact)
548
+ }
549
+ if (keyHint === 'milestone') {
550
+ return compactMilestoneRecord(value, compact)
551
+ }
552
+ if (keyHint === 'label') {
553
+ return compactLabelRecord(value, compact)
554
+ }
555
+
556
+ const next: Record<string, unknown> = {}
557
+ const hasHtmlUrl = isNonEmptyString(value.html_url)
558
+
559
+ for (const [key, entryValue] of Object.entries(value)) {
560
+ if (key === 'url' && hasHtmlUrl) {
561
+ continue
562
+ }
563
+
564
+ const compacted = compact(entryValue, key)
565
+ if (isEffectivelyEmpty(compacted)) continue
566
+ next[key] = compacted
567
+ }
568
+
569
+ return next
570
+ }
571
+
572
+ const toMcpEnvelope = (
573
+ result: GitServiceApiExecutionResult,
574
+ format: McpToolOutputFormat,
575
+ full: boolean,
576
+ ): { isError: boolean; envelope: McpTerseOk | McpTerseErr } => {
577
+ const sanitized = redactSecretsForMcpOutput(result)
578
+ const body = full ? result.body : compactResponseDataForDefaultMode(result.body)
579
+
580
+ if (result.ok && result.status < 400) {
581
+ const envelope: McpTerseOk = {
582
+ ok: true,
583
+ data: body,
584
+ meta: {
585
+ status: result.status,
586
+ },
302
587
  ...(format === 'debug' ? { debug: sanitized } : {}),
303
588
  }
304
589
 
@@ -309,16 +594,16 @@ const toMcpEnvelope = (
309
594
  const message = buildErrorMessage(status, result.body)
310
595
  const retryable = status >= 500 || status === 429 || /try again later/i.test(message)
311
596
 
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: {
597
+ const envelope: McpTerseErr = {
598
+ ok: false,
599
+ error: {
600
+ code: httpErrorCodeForStatus(status),
601
+ status,
602
+ message,
603
+ details: body,
604
+ retryable,
605
+ },
606
+ meta: {
322
607
  status,
323
608
  },
324
609
  ...(format === 'debug' ? { debug: sanitized } : {}),
@@ -387,7 +672,7 @@ const normalizePayload = (payload: unknown): { args: string[]; options: Record<s
387
672
  }
388
673
  }
389
674
 
390
- const OMITTED_OPTION_KEYS = new Set(['format', 'fields', 'validateOnly'])
675
+ const OMITTED_OPTION_KEYS = new Set(['format', 'fields', 'validateOnly', 'full'])
391
676
 
392
677
  const stripMcpOnlyOptions = (options: Record<string, unknown>): Record<string, unknown> => {
393
678
  const next: Record<string, unknown> = {}
@@ -610,15 +895,19 @@ const buildGenericToolSchema = (): Record<string, unknown> => ({
610
895
  additionalProperties: true,
611
896
  description: 'Method options (query/headers/body payload). Extra top-level keys are merged into options.',
612
897
  },
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' }],
898
+ validateOnly: {
899
+ type: 'boolean',
900
+ description:
901
+ 'If true, do not execute the underlying HTTP request. Returns the normalized call payload (args/options) that would be sent.',
902
+ },
903
+ full: {
904
+ type: 'boolean',
905
+ description: 'If true, return the unfiltered upstream response body.',
906
+ },
907
+ fields: {
908
+ description:
909
+ 'Optional field selection for the response body to reduce token usage. Accepts a string[] or a comma-separated string of dot-paths.',
910
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
622
911
  },
623
912
  format: {
624
913
  type: 'string',
@@ -648,12 +937,16 @@ const buildArtifactsByRunSchema = (): Record<string, unknown> => ({
648
937
  description: 'Alias for runId.',
649
938
  anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
650
939
  },
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:
940
+ format: {
941
+ type: 'string',
942
+ enum: ['terse', 'debug'],
943
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
944
+ },
945
+ full: {
946
+ type: 'boolean',
947
+ description: 'If true, return the unfiltered upstream response body.',
948
+ },
949
+ // Legacy positional forms:
657
950
  // - Preferred by humans/LLMs: [owner, repo, runId]
658
951
  // - Back-compat with the underlying helper signature: [runId, owner, repo]
659
952
  args: {
@@ -716,11 +1009,15 @@ const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
716
1009
  type: 'string',
717
1010
  description: 'If set, only return log lines containing this substring.',
718
1011
  },
719
- format: {
720
- type: 'string',
721
- enum: ['terse', 'debug'],
722
- description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
723
- },
1012
+ format: {
1013
+ type: 'string',
1014
+ enum: ['terse', 'debug'],
1015
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
1016
+ },
1017
+ full: {
1018
+ type: 'boolean',
1019
+ description: 'If true, return the unfiltered upstream response body.',
1020
+ },
724
1021
 
725
1022
  // Legacy / compatibility: allow calling with positional args.
726
1023
  args: {
@@ -779,12 +1076,16 @@ const buildDiagnoseLatestFailureSchema = (): Record<string, unknown> => ({
779
1076
  type: 'string',
780
1077
  description: 'If set, only return log lines containing this substring.',
781
1078
  },
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: {
1079
+ format: {
1080
+ type: 'string',
1081
+ enum: ['terse', 'debug'],
1082
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
1083
+ },
1084
+ full: {
1085
+ type: 'boolean',
1086
+ description: 'If true, return the unfiltered upstream response body.',
1087
+ },
1088
+ args: {
788
1089
  type: 'array',
789
1090
  items: { type: 'string' },
790
1091
  description: 'Legacy positional form: [owner, repo] plus options.workflowName/maxLines/etc.',
@@ -847,14 +1148,18 @@ const buildToolList = (
847
1148
  additionalProperties: true,
848
1149
  description: 'Tool invocation options',
849
1150
  },
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' }],
1151
+ validateOnly: {
1152
+ type: 'boolean',
1153
+ description: 'If true, validate and normalize without executing the underlying request.',
1154
+ },
1155
+ full: {
1156
+ type: 'boolean',
1157
+ description: 'If true, return the unfiltered upstream response body for this call.',
1158
+ },
1159
+ fields: {
1160
+ description:
1161
+ 'Optional field selection for the response body (reduces token usage). Accepts a string[] or comma-separated string.',
1162
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
858
1163
  },
859
1164
  format: {
860
1165
  type: 'string',
@@ -876,14 +1181,18 @@ const buildToolList = (
876
1181
  enum: ['terse', 'debug'],
877
1182
  description: 'Default output format for calls that do not specify one (default: "terse").',
878
1183
  },
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' }],
1184
+ validateOnly: {
1185
+ type: 'boolean',
1186
+ description: 'If true, validate and normalize calls without executing them.',
1187
+ },
1188
+ full: {
1189
+ type: 'boolean',
1190
+ description: 'Default response mode for calls that do not specify one. If true, return unfiltered bodies.',
1191
+ },
1192
+ fields: {
1193
+ description:
1194
+ 'Default field selection for calls that do not specify one. Accepts a string[] or comma-separated string.',
1195
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
887
1196
  },
888
1197
  },
889
1198
  required: ['calls'],
@@ -1091,16 +1400,148 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1091
1400
  return { owner, repo }
1092
1401
  }
1093
1402
 
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> } => {
1403
+ const resolvePrNumber = (args: string[], options: Record<string, unknown>): string => {
1404
+ const fromArgs = args.length >= 1 ? toTrimmedString(args[0]) : ''
1405
+ const fromNamed = toTrimmedString(pickFirst(options.number, (options as Record<string, unknown>).prNumber, options.index))
1406
+ return fromArgs || fromNamed
1407
+ }
1408
+
1409
+ const resolvedConfig = pickRecord(options.config)
1410
+ const giteaHost = toTrimmedString(pickFirst(resolvedConfig.giteaHost, process.env.GITEA_HOST)) || ''
1411
+ const giteaToken = toTrimmedString(pickFirst(resolvedConfig.giteaToken, process.env.GITEA_TOKEN)) || ''
1412
+ const giteaApiBase = giteaHost ? normalizeGiteaApiBase(giteaHost) : ''
1413
+
1414
+ const checkPrBlockingIssues = async (
1415
+ owner: string | undefined,
1416
+ repo: string | undefined,
1417
+ prNumber: string,
1418
+ ): Promise<{
1419
+ attempted: boolean
1420
+ ok: boolean
1421
+ status?: number
1422
+ blockingIssueNumbers: number[]
1423
+ details?: unknown
1424
+ reason?: string
1425
+ }> => {
1426
+ const issueNumber = toPositiveInteger(prNumber)
1427
+ if (!owner || !repo || !issueNumber) {
1428
+ return {
1429
+ attempted: false,
1430
+ ok: false,
1431
+ blockingIssueNumbers: [],
1432
+ reason: 'owner/repo/number is required',
1433
+ }
1434
+ }
1435
+
1436
+ try {
1437
+ const result = await callIssueDependenciesApi(
1438
+ 'GET',
1439
+ owner,
1440
+ repo,
1441
+ issueNumber,
1442
+ giteaHost || undefined,
1443
+ giteaToken || undefined,
1444
+ )
1445
+
1446
+ return {
1447
+ attempted: true,
1448
+ ok: result.ok,
1449
+ status: result.status,
1450
+ blockingIssueNumbers: result.ok ? extractDependencyIssueNumbers(result.body) : [],
1451
+ details: result.body,
1452
+ }
1453
+ } catch (error) {
1454
+ return {
1455
+ attempted: true,
1456
+ ok: false,
1457
+ blockingIssueNumbers: [],
1458
+ reason: error instanceof Error ? error.message : String(error),
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ const checkPrHeadContainedInBase = async (
1464
+ owner: string | undefined,
1465
+ repo: string | undefined,
1466
+ baseRef: string,
1467
+ headSha: string,
1468
+ ): Promise<{
1469
+ attempted: boolean
1470
+ ok: boolean
1471
+ status?: number
1472
+ contained: boolean | null
1473
+ details?: unknown
1474
+ reason?: string
1475
+ }> => {
1476
+ if (!owner || !repo || !baseRef || !headSha) {
1477
+ return {
1478
+ attempted: false,
1479
+ ok: false,
1480
+ contained: null,
1481
+ reason: 'owner/repo/baseRef/headSha is required',
1482
+ }
1483
+ }
1484
+
1485
+ if (!giteaApiBase) {
1486
+ return {
1487
+ attempted: false,
1488
+ ok: false,
1489
+ contained: null,
1490
+ reason: 'Gitea API base is not configured',
1491
+ }
1492
+ }
1493
+
1494
+ const basehead = `${baseRef}...${headSha}`
1495
+ const requestUrl = `${giteaApiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/compare/${encodeURIComponent(basehead)}`
1496
+ const headers: Record<string, string> = {
1497
+ Accept: 'application/json',
1498
+ }
1499
+ if (giteaToken) {
1500
+ headers.Authorization = `token ${giteaToken}`
1501
+ }
1502
+
1503
+ try {
1504
+ const response = await fetch(requestUrl, { method: 'GET', headers })
1505
+ const responseText = await response.text()
1506
+ let parsedBody: unknown = responseText
1507
+ try {
1508
+ parsedBody = JSON.parse(responseText)
1509
+ } catch {
1510
+ parsedBody = responseText
1511
+ }
1512
+
1513
+ if (!response.ok) {
1514
+ return {
1515
+ attempted: true,
1516
+ ok: false,
1517
+ status: response.status,
1518
+ contained: null,
1519
+ details: parsedBody,
1520
+ reason: buildErrorMessage(response.status, parsedBody),
1521
+ }
1522
+ }
1523
+
1524
+ return {
1525
+ attempted: true,
1526
+ ok: true,
1527
+ status: response.status,
1528
+ contained: inferHeadContainedFromCompareBody(parsedBody),
1529
+ details: parsedBody,
1530
+ }
1531
+ } catch (error) {
1532
+ return {
1533
+ attempted: true,
1534
+ ok: false,
1535
+ contained: null,
1536
+ reason: error instanceof Error ? error.message : String(error),
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ const normalizePayloadWithContext = (
1542
+ tool: ToolDefinition,
1543
+ payload: unknown,
1544
+ ): { args: string[]; options: Record<string, unknown> } => {
1104
1545
  const normalized = normalizePayload(payload)
1105
1546
  const optionsWithDefaults = { ...normalized.options }
1106
1547
  const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
@@ -1137,32 +1578,44 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1137
1578
  items: { type: 'string' },
1138
1579
  description: 'Legacy positional form: [owner, repo]',
1139
1580
  },
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
- },
1581
+ format: {
1582
+ type: 'string',
1583
+ enum: ['terse', 'debug'],
1584
+ description: 'Output format. Default: "terse".',
1585
+ },
1586
+ full: {
1587
+ type: 'boolean',
1588
+ description: 'If true, return unfiltered helper payloads.',
1589
+ },
1590
+ },
1591
+ },
1592
+ },
1593
+ {
1594
+ name: contextGetToolName,
1595
+ description: 'Get the current default {owner, repo} for this session.',
1596
+ inputSchema: {
1597
+ type: 'object',
1598
+ additionalProperties: true,
1599
+ properties: {
1600
+ format: { type: 'string', enum: ['terse', 'debug'] },
1601
+ full: { type: 'boolean', description: 'If true, return unfiltered helper payloads.' },
1602
+ },
1603
+ },
1604
+ },
1153
1605
  {
1154
1606
  name: searchToolsToolName,
1155
1607
  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
- },
1608
+ inputSchema: {
1609
+ type: 'object',
1610
+ additionalProperties: true,
1611
+ properties: {
1612
+ query: { type: 'string', description: 'Search query (substring match on tool name/path)' },
1613
+ limit: { type: 'integer', minimum: 1, description: 'Max matches to return (default: 20)' },
1614
+ format: { type: 'string', enum: ['terse', 'debug'] },
1615
+ full: { type: 'boolean', description: 'If true, return unfiltered helper payloads.' },
1616
+ },
1617
+ required: ['query'],
1618
+ },
1166
1619
  },
1167
1620
  {
1168
1621
  name: prPreflightToolName,
@@ -1171,15 +1624,20 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1171
1624
  type: 'object',
1172
1625
  additionalProperties: true,
1173
1626
  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
- },
1627
+ owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
1628
+ repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
1629
+ number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
1630
+ includeIssues: { type: 'boolean', description: 'If true, fetch referenced issues mentioned as "Fixes #123".' },
1631
+ checkBlockingIssues: {
1632
+ type: 'boolean',
1633
+ description: 'If true (default), detect issue dependencies that block this PR from being merged.',
1634
+ },
1635
+ validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
1636
+ full: { type: 'boolean', description: 'If true, return unfiltered PR/check/review payloads.' },
1637
+ fields: {
1638
+ description: 'Optional field selection applied to pr/checks/review bodies.',
1639
+ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
1640
+ },
1183
1641
  format: { type: 'string', enum: ['terse', 'debug'] },
1184
1642
  },
1185
1643
  anyOf: [{ required: ['number'] }, { required: ['args'] }],
@@ -1195,14 +1653,23 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1195
1653
  owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
1196
1654
  repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
1197
1655
  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
- },
1656
+ mergeMethod: { type: 'string', description: 'Merge method. Maps to Gitea merge Do field (default: "merge").' },
1657
+ maxAttempts: { type: 'integer', minimum: 1, description: 'Max poll attempts (default: 6)' },
1658
+ delayMs: { type: 'integer', minimum: 0, description: 'Delay between polls in ms (default: 1000)' },
1659
+ allowManualFinalize: {
1660
+ type: 'boolean',
1661
+ description: 'If true, attempt a manual metadata finalization when normal merge fails.',
1662
+ },
1663
+ manualMergeCommitSha: {
1664
+ type: 'string',
1665
+ description: 'Optional merge commit SHA used when finalizing with Do="manually-merged".',
1666
+ },
1667
+ validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
1668
+ full: { type: 'boolean', description: 'If true, return unfiltered merge/PR payloads.' },
1669
+ format: { type: 'string', enum: ['terse', 'debug'] },
1670
+ },
1671
+ anyOf: [{ required: ['number'] }, { required: ['args'] }],
1672
+ },
1206
1673
  },
1207
1674
  ]
1208
1675
 
@@ -1213,13 +1680,20 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1213
1680
  await new Promise<void>((resolve) => setTimeout(resolve, ms))
1214
1681
  }
1215
1682
 
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
- },
1683
+ const toOk = (
1684
+ data: unknown,
1685
+ format: McpToolOutputFormat,
1686
+ debug?: unknown,
1687
+ status: number = 0,
1688
+ full: boolean = true,
1689
+ ): McpTerseOk => {
1690
+ const outputData = full ? data : compactResponseDataForDefaultMode(data)
1691
+ const envelope: McpTerseOk = {
1692
+ ok: true,
1693
+ data: outputData,
1694
+ meta: {
1695
+ status,
1696
+ },
1223
1697
  ...(format === 'debug' && debug !== undefined ? { debug } : {}),
1224
1698
  }
1225
1699
 
@@ -1234,16 +1708,19 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1234
1708
  return redactSecretsForMcpOutput(enriched) as McpTerseErr
1235
1709
  }
1236
1710
 
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
- }
1711
+ const invokeCustomTool = async (
1712
+ toolName: string,
1713
+ payload: unknown,
1714
+ controls: McpCallControls,
1715
+ ): Promise<{ isError: boolean; envelope: McpTerseOk | McpTerseErr }> => {
1716
+ const format = controls.format ?? 'terse'
1717
+
1718
+ if (toolName === contextGetToolName) {
1719
+ return {
1720
+ isError: false,
1721
+ envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format, undefined, 0, true),
1722
+ }
1723
+ }
1247
1724
 
1248
1725
  if (toolName === contextSetToolName) {
1249
1726
  const normalized = normalizePayload(payload)
@@ -1256,8 +1733,11 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1256
1733
  context.owner = owner || undefined
1257
1734
  context.repo = repo || undefined
1258
1735
 
1259
- return { isError: false, envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format) }
1260
- }
1736
+ return {
1737
+ isError: false,
1738
+ envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format, undefined, 0, true),
1739
+ }
1740
+ }
1261
1741
 
1262
1742
  if (toolName === searchToolsToolName) {
1263
1743
  const normalized = normalizePayload(payload)
@@ -1299,8 +1779,8 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1299
1779
  return meta
1300
1780
  })
1301
1781
 
1302
- return { isError: false, envelope: toOk({ matches }, format) }
1303
- }
1782
+ return { isError: false, envelope: toOk({ matches }, format, undefined, 0, true) }
1783
+ }
1304
1784
 
1305
1785
  if (toolName === prPreflightToolName) {
1306
1786
  const normalized = normalizePayload(payload)
@@ -1327,36 +1807,43 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1327
1807
  }
1328
1808
  }
1329
1809
 
1330
- const includeIssues = Boolean((optionsWithDefaults as Record<string, unknown>).includeIssues)
1810
+ const includeIssues = Boolean((optionsWithDefaults as Record<string, unknown>).includeIssues)
1811
+ const checkBlockingIssues =
1812
+ parseBoolean((optionsWithDefaults as Record<string, unknown>).checkBlockingIssues) ?? true
1331
1813
 
1332
1814
  if (controls.validateOnly) {
1333
1815
  const toolPrefix = prefix ? `${prefix}.` : ''
1334
1816
  return {
1335
1817
  isError: false,
1336
1818
  envelope: toOk(
1337
- {
1338
- valid: true,
1339
- owner: owner ?? null,
1819
+ {
1820
+ valid: true,
1821
+ owner: owner ?? null,
1340
1822
  repo: repo ?? null,
1341
1823
  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
- }
1824
+ calls: [
1825
+ { tool: `${toolPrefix}repo.pr.view`, args: [number], options: { owner, repo } },
1826
+ { tool: `${toolPrefix}repo.pr.checks`, args: [number], options: { owner, repo } },
1827
+ { tool: `${toolPrefix}repo.pr.review`, args: [number], options: { owner, repo, method: 'GET' } },
1828
+ ],
1829
+ includeIssues,
1830
+ checkBlockingIssues,
1831
+ },
1832
+ format,
1833
+ undefined,
1834
+ 0,
1835
+ true,
1836
+ ),
1837
+ }
1838
+ }
1353
1839
 
1354
1840
  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
1841
+ const callOptions: Record<string, unknown> = { ...callOptionsBase }
1842
+ delete (callOptions as any).includeIssues
1843
+ delete (callOptions as any).checkBlockingIssues
1844
+ delete (callOptions as any).number
1845
+ delete (callOptions as any).prNumber
1846
+ delete (callOptions as any).index
1360
1847
 
1361
1848
  const pr = await (api as any).repo.pr.view(number, callOptions)
1362
1849
  const checks = await (api as any).repo.pr.checks(number, callOptions)
@@ -1374,24 +1861,55 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1374
1861
 
1375
1862
  const uniqueReferenced = Array.from(new Set(referencedIssueNumbers))
1376
1863
  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)
1864
+ if (includeIssues && uniqueReferenced.length > 0) {
1865
+ for (const n of uniqueReferenced) {
1866
+ try {
1867
+ const issue = await (api as any).repo.issue.view(String(n), callOptions)
1868
+ issues.push(issue.body)
1382
1869
  } catch {
1383
1870
  // 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
- }
1871
+ }
1872
+ }
1873
+ }
1874
+
1875
+ const blockerCheck = checkBlockingIssues ? await checkPrBlockingIssues(owner, repo, number) : null
1876
+ const blockingIssueNumbers = blockerCheck ? blockerCheck.blockingIssueNumbers : []
1877
+
1878
+ if (blockingIssueNumbers.length > 0) {
1879
+ return {
1880
+ isError: true,
1881
+ envelope: toErr(
1882
+ {
1883
+ ok: false,
1884
+ error: {
1885
+ code: 'PR_BLOCKED_BY_ISSUES',
1886
+ message: 'Pull request is blocked by issue dependencies. Close all blocking issues before merging.',
1887
+ details: {
1888
+ blockingIssueNumbers,
1889
+ blockerCheck: {
1890
+ attempted: blockerCheck?.attempted ?? false,
1891
+ ok: blockerCheck?.ok ?? false,
1892
+ status: blockerCheck?.status,
1893
+ },
1894
+ },
1895
+ retryable: false,
1896
+ },
1897
+ },
1898
+ format,
1899
+ { pr, checks, review, blockerCheck },
1900
+ ),
1901
+ }
1902
+ }
1903
+
1904
+ const data = {
1905
+ pr: applyFieldSelection(pr.body, controls.fields),
1906
+ checks: applyFieldSelection(checks.body, controls.fields),
1907
+ review: applyFieldSelection(review.body, controls.fields),
1908
+ referencedIssueNumbers: uniqueReferenced,
1909
+ ...(checkBlockingIssues ? { blockingIssueNumbers } : {}),
1910
+ ...(includeIssues ? { issues } : {}),
1911
+ }
1912
+ const full = controls.full || Boolean(controls.fields)
1395
1913
 
1396
1914
  const allOk = pr.ok && checks.ok && review.ok
1397
1915
  if (!allOk) {
@@ -1403,22 +1921,31 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1403
1921
  error: {
1404
1922
  code: 'PREFLIGHT_FAILED',
1405
1923
  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
- }
1924
+ details: {
1925
+ pr: { ok: pr.ok, status: pr.status },
1926
+ checks: { ok: checks.ok, status: checks.status },
1927
+ review: { ok: review.ok, status: review.status },
1928
+ ...(checkBlockingIssues
1929
+ ? {
1930
+ blockerCheck: {
1931
+ attempted: blockerCheck?.attempted ?? false,
1932
+ ok: blockerCheck?.ok ?? false,
1933
+ status: blockerCheck?.status,
1934
+ },
1935
+ }
1936
+ : {}),
1937
+ },
1938
+ retryable: false,
1939
+ },
1940
+ },
1941
+ format,
1942
+ { pr, checks, review, blockerCheck },
1943
+ ),
1944
+ }
1945
+ }
1946
+
1947
+ return { isError: false, envelope: toOk(data, format, { pr, checks, review, blockerCheck }, 200, full) }
1948
+ }
1422
1949
 
1423
1950
  if (toolName === prMergeAndVerifyToolName) {
1424
1951
  const normalized = normalizePayload(payload)
@@ -1445,75 +1972,201 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1445
1972
  }
1446
1973
  }
1447
1974
 
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'
1975
+ const maxAttempts = toPositiveInteger((optionsWithDefaults as Record<string, unknown>).maxAttempts) ?? 6
1976
+ const delayMs = toNonNegativeInteger((optionsWithDefaults as Record<string, unknown>).delayMs) ?? 1000
1977
+ const mergeMethod = toTrimmedString((optionsWithDefaults as Record<string, unknown>).mergeMethod) || 'merge'
1978
+ const allowManualFinalize = Boolean((optionsWithDefaults as Record<string, unknown>).allowManualFinalize)
1979
+ const manualMergeCommitSha =
1980
+ toTrimmedString(
1981
+ pickFirst(
1982
+ (optionsWithDefaults as Record<string, unknown>).manualMergeCommitSha,
1983
+ (optionsWithDefaults as Record<string, unknown>).mergeCommitSha,
1984
+ ),
1985
+ ) || ''
1451
1986
 
1452
1987
  if (controls.validateOnly) {
1453
1988
  return {
1454
1989
  isError: false,
1455
1990
  envelope: toOk(
1456
- {
1457
- valid: true,
1458
- owner: owner ?? null,
1991
+ {
1992
+ valid: true,
1993
+ owner: owner ?? null,
1459
1994
  repo: repo ?? null,
1460
- number,
1461
- mergeMethod,
1462
- maxAttempts,
1463
- delayMs,
1464
- },
1465
- format,
1466
- ),
1467
- }
1468
- }
1995
+ number,
1996
+ mergeMethod,
1997
+ maxAttempts,
1998
+ delayMs,
1999
+ allowManualFinalize,
2000
+ manualMergeCommitSha: manualMergeCommitSha || null,
2001
+ },
2002
+ format,
2003
+ undefined,
2004
+ 0,
2005
+ true,
2006
+ ),
2007
+ }
2008
+ }
1469
2009
 
1470
2010
  const callOptionsBase = stripMcpOnlyOptions(optionsWithDefaults)
1471
2011
  const callOptions: Record<string, unknown> = { ...callOptionsBase }
1472
2012
  delete (callOptions as any).maxAttempts
1473
2013
  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++) {
2014
+ delete (callOptions as any).number
2015
+ delete (callOptions as any).prNumber
2016
+ delete (callOptions as any).index
2017
+ delete (callOptions as any).mergeMethod
2018
+ delete (callOptions as any).allowManualFinalize
2019
+ delete (callOptions as any).manualMergeCommitSha
2020
+ delete (callOptions as any).mergeCommitSha
2021
+
2022
+ let mergeResult: GitServiceApiExecutionResult | null = null
2023
+ let lastMergeError: unknown = null
2024
+ let lastFailure: MergeFailureClassification | null = null
2025
+ let manualFinalize: Record<string, unknown> | null = null
2026
+
2027
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1483
2028
  mergeResult = await (api as any).repo.pr.merge(number, { ...callOptions, mergeMethod })
1484
2029
  if (mergeResult.ok && mergeResult.status < 400) {
1485
2030
  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
- }
2031
+ }
2032
+
2033
+ lastMergeError = mergeResult.body
2034
+ lastFailure = classifyMergeFailure(mergeResult.status, mergeResult.body)
2035
+ if (!lastFailure.retryable) {
2036
+ break
2037
+ }
1493
2038
 
1494
2039
  await sleep(delayMs)
1495
2040
  }
1496
2041
 
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
- }
2042
+ if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
2043
+ const failure = classifyMergeFailure(mergeResult?.status, mergeResult?.body ?? lastMergeError)
2044
+
2045
+ if (failure.blockedByIssues) {
2046
+ return {
2047
+ isError: true,
2048
+ envelope: toErr(
2049
+ {
2050
+ ok: false,
2051
+ error: {
2052
+ code: 'PR_BLOCKED_BY_ISSUES',
2053
+ status: mergeResult?.status,
2054
+ message: failure.message,
2055
+ details: mergeResult?.body ?? lastMergeError,
2056
+ retryable: false,
2057
+ },
2058
+ meta: mergeResult?.status ? { status: mergeResult.status } : undefined,
2059
+ },
2060
+ format,
2061
+ { mergeResult, lastFailure },
2062
+ ),
2063
+ }
2064
+ }
2065
+
2066
+ if (allowManualFinalize) {
2067
+ const prBeforeFinalize = await (api as any).repo.pr.view(number, callOptions)
2068
+ const prBody = isRecord(prBeforeFinalize.body) ? (prBeforeFinalize.body as Record<string, unknown>) : null
2069
+ const baseRef = prBody && isRecord(prBody.base) ? toTrimmedString((prBody.base as Record<string, unknown>).ref) : ''
2070
+ const headSha = prBody && isRecord(prBody.head) ? toTrimmedString((prBody.head as Record<string, unknown>).sha) : ''
2071
+
2072
+ const containmentCheck = manualMergeCommitSha
2073
+ ? {
2074
+ attempted: false,
2075
+ ok: true,
2076
+ contained: true,
2077
+ reason: 'manualMergeCommitSha provided by caller',
2078
+ }
2079
+ : await checkPrHeadContainedInBase(owner, repo, baseRef, headSha)
2080
+
2081
+ const canFinalize = manualMergeCommitSha.length > 0 || containmentCheck.contained === true
2082
+ manualFinalize = {
2083
+ attempted: true,
2084
+ canFinalize,
2085
+ manualMergeCommitSha: manualMergeCommitSha || null,
2086
+ baseRef: baseRef || null,
2087
+ headSha: headSha || null,
2088
+ containmentCheck,
2089
+ }
2090
+
2091
+ if (canFinalize) {
2092
+ const finalizeBody: Record<string, unknown> = {
2093
+ Do: 'manually-merged',
2094
+ }
2095
+ if (manualMergeCommitSha) {
2096
+ finalizeBody.MergeCommitID = manualMergeCommitSha
2097
+ }
2098
+
2099
+ const manualMergeResult = await (api as any).repo.pr.merge(number, {
2100
+ ...callOptions,
2101
+ mergeMethod: 'manually-merged',
2102
+ requestBody: finalizeBody,
2103
+ })
2104
+
2105
+ if (manualMergeResult.ok && manualMergeResult.status < 400) {
2106
+ mergeResult = manualMergeResult
2107
+ manualFinalize = {
2108
+ ...manualFinalize,
2109
+ mode: 'manually-merged',
2110
+ status: manualMergeResult.status,
2111
+ ok: true,
2112
+ }
2113
+ } else {
2114
+ const finalizeFailure = classifyMergeFailure(manualMergeResult.status, manualMergeResult.body)
2115
+ return {
2116
+ isError: true,
2117
+ envelope: toErr(
2118
+ {
2119
+ ok: false,
2120
+ error: {
2121
+ code: finalizeFailure.blockedByIssues ? 'PR_BLOCKED_BY_ISSUES' : 'MERGE_FINALIZE_FAILED',
2122
+ status: manualMergeResult.status,
2123
+ message: finalizeFailure.message,
2124
+ details: {
2125
+ merge: manualMergeResult.body,
2126
+ manualFinalize,
2127
+ },
2128
+ retryable: finalizeFailure.retryable,
2129
+ },
2130
+ meta: manualMergeResult.status ? { status: manualMergeResult.status } : undefined,
2131
+ },
2132
+ format,
2133
+ { mergeResult, manualMergeResult, manualFinalize, prBeforeFinalize },
2134
+ ),
2135
+ }
2136
+ }
2137
+ }
2138
+ }
2139
+
2140
+ if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
2141
+ return {
2142
+ isError: true,
2143
+ envelope: toErr(
2144
+ {
2145
+ ok: false,
2146
+ error: {
2147
+ code: failure.code,
2148
+ status: mergeResult?.status,
2149
+ message: failure.message,
2150
+ details: {
2151
+ merge: mergeResult?.body ?? lastMergeError,
2152
+ manualFinalize,
2153
+ },
2154
+ hint:
2155
+ allowManualFinalize && !manualMergeCommitSha
2156
+ ? 'Set allowManualFinalize=true and provide manualMergeCommitSha when the PR is already merged outside the API.'
2157
+ : undefined,
2158
+ retryable: failure.retryable,
2159
+ },
2160
+ meta: mergeResult?.status ? { status: mergeResult.status } : undefined,
2161
+ },
2162
+ format,
2163
+ { mergeResult, lastFailure, manualFinalize },
2164
+ ),
2165
+ }
2166
+ }
2167
+
2168
+ lastFailure = null
2169
+ }
1517
2170
 
1518
2171
  const views: GitServiceApiExecutionResult[] = []
1519
2172
  let prAfter: GitServiceApiExecutionResult | null = null
@@ -1530,12 +2183,14 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1530
2183
  await sleep(delayMs)
1531
2184
  }
1532
2185
 
1533
- const data = {
1534
- merge: mergeResult.body,
1535
- pr: prAfter ? prAfter.body : null,
1536
- polled: views.length,
1537
- }
1538
-
2186
+ const data = {
2187
+ merge: mergeResult.body,
2188
+ pr: prAfter ? prAfter.body : null,
2189
+ polled: views.length,
2190
+ ...(manualFinalize ? { finalization: manualFinalize } : {}),
2191
+ }
2192
+ const full = controls.full
2193
+
1539
2194
  const merged = prAfter && isRecord(prAfter.body) ? Boolean((prAfter.body as any).merged) : false
1540
2195
  const state = prAfter && isRecord(prAfter.body) && typeof (prAfter.body as any).state === 'string' ? String((prAfter.body as any).state) : ''
1541
2196
 
@@ -1543,23 +2198,23 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1543
2198
  return {
1544
2199
  isError: true,
1545
2200
  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
- }
2201
+ {
2202
+ ok: false,
2203
+ error: {
2204
+ code: 'MERGE_VERIFY_FAILED',
2205
+ message: 'Merge request succeeded, but PR state did not transition to merged/closed within polling window.',
2206
+ details: { merged, state },
2207
+ retryable: true,
2208
+ },
2209
+ },
2210
+ format,
2211
+ { mergeResult, prAfter, views, manualFinalize },
2212
+ ),
2213
+ }
2214
+ }
2215
+
2216
+ return { isError: false, envelope: toOk(data, format, { mergeResult, prAfter, views, manualFinalize }, 200, full) }
2217
+ }
1563
2218
 
1564
2219
  return {
1565
2220
  isError: true,
@@ -1606,12 +2261,13 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1606
2261
  if (customToolMetaByName.has(tool)) {
1607
2262
  const mergedPayload = { args, options }
1608
2263
  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
- }
2264
+ const effectiveControls = {
2265
+ ...callControls,
2266
+ format: callControls.format ?? batchControls.format ?? null,
2267
+ fields: callControls.fields ?? batchControls.fields ?? null,
2268
+ validateOnly: callControls.validateOnly || batchControls.validateOnly,
2269
+ full: callControls.full || batchControls.full,
2270
+ }
1615
2271
 
1616
2272
  try {
1617
2273
  const { isError, envelope } = await invokeCustomTool(tool, mergedPayload, effectiveControls)
@@ -1653,13 +2309,14 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1653
2309
 
1654
2310
  try {
1655
2311
  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)
2312
+ const callControls = extractMcpControls(mergedPayload)
2313
+ const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
2314
+ const effectiveFields = callControls.fields ?? batchControls.fields ?? null
2315
+ const validateOnly = callControls.validateOnly || batchControls.validateOnly
2316
+ const full = callControls.full || batchControls.full
2317
+
2318
+ if (validateOnly) {
2319
+ const normalized = normalizePayloadWithContext(toolDefinition, mergedPayload)
1663
2320
  const envelope: McpTerseOk = {
1664
2321
  ok: true,
1665
2322
  data: {
@@ -1688,13 +2345,14 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1688
2345
  ? invokeLogsForRunTailTool(toolDefinition, normalizedPayload)
1689
2346
  : isArtifactsByRunTool(toolDefinition.name)
1690
2347
  ? 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,
2348
+ : invokeTool(toolDefinition, normalizedPayload))
2349
+
2350
+ const selected = effectiveFields ? { ...data, body: applyFieldSelection(data.body, effectiveFields) } : data
2351
+ const shouldReturnFull = full || Boolean(effectiveFields)
2352
+ const { isError, envelope } = toMcpEnvelope(selected, effectiveFormat, shouldReturnFull)
2353
+ return {
2354
+ index,
2355
+ tool,
1698
2356
  isError,
1699
2357
  ...(envelope as McpTerseOk | McpTerseErr),
1700
2358
  } as BatchResult
@@ -1746,16 +2404,17 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
1746
2404
  const normalized = normalizePayloadWithContext(tool, payload)
1747
2405
  const normalizedPayload = { args: normalized.args, options: normalized.options }
1748
2406
 
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
- }
2407
+ const result = await (isLogsForRunTailTool(tool.name)
2408
+ ? invokeLogsForRunTailTool(tool, normalizedPayload)
2409
+ : isArtifactsByRunTool(tool.name)
2410
+ ? invokeArtifactsByRunTool(tool, normalizedPayload)
2411
+ : invokeTool(tool, normalizedPayload))
2412
+
2413
+ const selected = controls.fields ? { ...result, body: applyFieldSelection(result.body, controls.fields) } : result
2414
+ const shouldReturnFull = controls.full || Boolean(controls.fields)
2415
+ const { isError, envelope } = toMcpEnvelope(selected, controls.format ?? 'terse', shouldReturnFull)
2416
+ return { isError, text: JSON.stringify(envelope) }
2417
+ }
1759
2418
 
1760
2419
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1761
2420
  tools: listTools(),