@foundation0/git 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,12 +80,18 @@ type GitServiceApiExecutionResult = {
80
80
 
81
81
  ## Core request shapes
82
82
 
83
- Use `data`, `json`, `body`, or `payload` for request bodies on write methods.
83
+ Write methods accept request fields directly (recommended), or you can set the raw request body with `data` / `json` / `payload` / `requestBody`.
84
84
 
85
85
  ```ts
86
86
  await api.repo.issue.create({
87
- data: { title: 'Bug report', body: 'Describe issue details' },
88
- query: { labels: 'bug', assignee: 'bot' },
87
+ title: 'Bug report',
88
+ body: 'Describe issue details',
89
+ labels: ['bug'],
90
+ })
91
+
92
+ // equivalent explicit-body form
93
+ await api.repo.issue.create({
94
+ data: { title: 'Bug report', body: 'Describe issue details', labels: ['bug'] },
89
95
  })
90
96
  ```
91
97
 
package/mcp/README.md CHANGED
@@ -12,6 +12,20 @@ Each tool is addressed by the full API path (`repo.issue.create`, `search.issues
12
12
  - `args`: array of positional string arguments
13
13
  - `options`: object for request body/query/header customisation
14
14
 
15
+ Tool results are returned as a compact envelope by default:
16
+
17
+ ```json
18
+ { "ok": true, "data": { "...": "..." }, "meta": { "status": 200 } }
19
+ ```
20
+
21
+ If the upstream git API returns `{ ok: false }` or an HTTP status `>= 400`, the MCP tool call is marked as an error (`isError: true`) and a typed error envelope is returned:
22
+
23
+ ```json
24
+ { "ok": false, "error": { "code": "HTTP_NOT_FOUND", "status": 404, "message": "HTTP 404", "retryable": false }, "meta": { "status": 404 } }
25
+ ```
26
+
27
+ To include the full debug payload (mapping/request/response headers), pass `format: "debug"` (either top-level or in `options`).
28
+
15
29
  Use the `batch` tool to run multiple calls in one request. It is the preferred mode when you want to issue multiple MCP calls because it executes them in parallel and returns a combined response.
16
30
 
17
31
  Batch payload format:
@@ -111,6 +125,12 @@ await client.call('repo.issue.create', {
111
125
  })
112
126
  ```
113
127
 
128
+ To return a debug payload for a specific call:
129
+
130
+ ```ts
131
+ await client.call('repo.issue.list', { format: 'debug' })
132
+ ```
133
+
114
134
  ## Using with MCP-compatible agents
115
135
 
116
136
  Create a small server entry file for your agent (for example `packages/git/mcp/server-entry.ts`):
@@ -209,3 +229,22 @@ Tips:
209
229
  - `normalizeToolCallNameForServer(prefix, toolName)`
210
230
  - Includes label-management tools exposed from the API object:
211
231
  `repo.label.listManaged`, `repo.label.getByName`, `repo.label.upsert`, and `repo.label.deleteByName`
232
+ - Includes Gitea Actions convenience tools exposed from the API object (best-effort helpers):
233
+ `repo.actions.tasks.list`, `repo.actions.jobs.logs`, `repo.actions.jobs.logsTail`, `repo.actions.jobs.logsForRunTail`,
234
+ and `repo.actions.artifacts.downloadZipUrl`
235
+ - Includes a discovery helper:
236
+ `help.actionsLogs` (also available under `repo.help.actionsLogs`)
237
+
238
+ ## Notes: jobs.logsForRunTail
239
+
240
+ `repo.actions.jobs.logsForRunTail` (and the `help.*` variants) expect a `headSha` and a `runNumber`. Prefer the named form to avoid positional confusion:
241
+
242
+ ```json
243
+ {
244
+ "owner": "F0",
245
+ "repo": "adl",
246
+ "headSha": "6dd4062ec271277e7e83ac6864fec9208559564c",
247
+ "runNumber": 11,
248
+ "maxLines": 250
249
+ }
250
+ ```
package/mcp/src/client.ts CHANGED
@@ -12,6 +12,9 @@ type ToolResult = {
12
12
  content?: ToolResultText[]
13
13
  }
14
14
 
15
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
16
+ typeof value === 'object' && value !== null && !Array.isArray(value)
17
+
15
18
  const toText = (result: ToolResult | null): string => {
16
19
  if (!result || !result.content || result.content.length === 0) {
17
20
  return ''
@@ -116,8 +119,14 @@ export class GitMcpClient {
116
119
 
117
120
  const text = toText(response as ToolResult)
118
121
  const parsed = tryParseResult(text)
122
+
123
+ // Back-compat: if a server returns an { ok:false } envelope but forgets to mark MCP isError,
124
+ // treat it as an error on the client side as well.
125
+ const parsedIsError =
126
+ isRecord(parsed.parsed) && typeof parsed.parsed.ok === 'boolean' ? parsed.parsed.ok === false : false
127
+
119
128
  return {
120
- isError: Boolean(response.isError),
129
+ isError: Boolean(response.isError) || parsedIsError,
121
130
  text: parsed.text,
122
131
  data: parsed.parsed,
123
132
  }
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,111 @@ 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
+
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
+
168
465
  const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?: string) => {
169
466
  const toolNames = tools.map((tool) => ({
170
467
  name: buildToolName(tool, prefix),
171
468
  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
- },
469
+ inputSchema: buildToolInputSchema(tool),
188
470
  }))
189
471
 
190
472
  const batchTool = {
@@ -216,6 +498,11 @@ const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?:
216
498
  additionalProperties: true,
217
499
  description: 'Tool invocation options',
218
500
  },
501
+ format: {
502
+ type: 'string',
503
+ enum: ['terse', 'debug'],
504
+ description: 'Per-call output format (default: "terse").',
505
+ },
219
506
  },
220
507
  required: ['tool'],
221
508
  },
@@ -226,6 +513,11 @@ const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?:
226
513
  description: 'Whether to continue when a call in the batch fails',
227
514
  default: false,
228
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
+ },
229
521
  },
230
522
  required: ['calls'],
231
523
  },
@@ -242,15 +534,63 @@ const invokeTool = async (
242
534
  payload: unknown,
243
535
  ): Promise<GitServiceApiExecutionResult> => {
244
536
  const { args, options } = normalizePayload(payload)
537
+ const cleanedOptions = stripMcpOnlyOptions(options)
245
538
  const invocationArgs: unknown[] = args
246
539
 
247
- if (Object.keys(options).length > 0) {
248
- invocationArgs.push(options)
540
+ if (Object.keys(cleanedOptions).length > 0) {
541
+ invocationArgs.push(cleanedOptions)
249
542
  }
250
543
 
251
544
  return tool.method(...invocationArgs)
252
545
  }
253
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
+
254
594
  export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpServerInstance => {
255
595
  const api = createGitServiceApi(options)
256
596
  const tools = collectGitTools(api)
@@ -281,6 +621,8 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
281
621
  if (requestedName === batchToolName) {
282
622
  try {
283
623
  const { calls, continueOnError } = normalizeBatchPayload(request.params.arguments)
624
+ const batchControls = extractOutputControls(request.params.arguments)
625
+
284
626
  const executions = calls.map(({ tool, args = [], options = {} }, index) => ({ tool, args, options, index }))
285
627
  const results = await Promise.all(
286
628
  executions.map(async ({ tool, args, options, index }) => {
@@ -290,17 +632,32 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
290
632
  index,
291
633
  tool,
292
634
  isError: true,
293
- data: `Unknown tool: ${tool}`,
635
+ ...({
636
+ ok: false,
637
+ error: {
638
+ code: 'UNKNOWN_TOOL',
639
+ message: `Unknown tool: ${tool}`,
640
+ retryable: false,
641
+ },
642
+ } satisfies McpTerseErr),
294
643
  } as BatchResult
295
644
  }
296
645
 
297
646
  try {
298
- const data = await invokeTool(toolDefinition, { args, options })
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)
299
656
  return {
300
657
  index,
301
658
  tool,
302
- isError: false,
303
- data: redactSecretsForMcpOutput(data),
659
+ isError,
660
+ ...(envelope as McpTerseOk | McpTerseErr),
304
661
  } as BatchResult
305
662
  } catch (error) {
306
663
  if (continueOnError) {
@@ -308,7 +665,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
308
665
  index,
309
666
  tool,
310
667
  isError: true,
311
- data: redactSecretsInText(error instanceof Error ? error.message : String(error)),
668
+ ...(toMcpThrownErrorEnvelope(error) as McpTerseErr),
312
669
  } as BatchResult
313
670
  }
314
671
  throw error
@@ -321,7 +678,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
321
678
  content: [
322
679
  {
323
680
  type: 'text',
324
- text: JSON.stringify(redactSecretsForMcpOutput(results), null, 2),
681
+ text: JSON.stringify(redactSecretsForMcpOutput(results)),
325
682
  },
326
683
  ],
327
684
  }
@@ -331,7 +688,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
331
688
  content: [
332
689
  {
333
690
  type: 'text',
334
- text: redactSecretsInText(error instanceof Error ? error.message : String(error)),
691
+ text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
335
692
  },
336
693
  ],
337
694
  }
@@ -345,13 +702,18 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
345
702
  }
346
703
 
347
704
  try {
348
- const result = await invokeTool(tool, request.params.arguments)
349
- const sanitized = redactSecretsForMcpOutput(result)
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')
350
711
  return {
712
+ ...(isError ? { isError: true } : {}),
351
713
  content: [
352
714
  {
353
715
  type: 'text',
354
- text: JSON.stringify(sanitized, null, 2),
716
+ text: JSON.stringify(envelope),
355
717
  },
356
718
  ],
357
719
  }
@@ -361,7 +723,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
361
723
  content: [
362
724
  {
363
725
  type: 'text',
364
- text: redactSecretsInText(error instanceof Error ? error.message : String(error)),
726
+ text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
365
727
  },
366
728
  ],
367
729
  }