@foundation0/git 1.2.4 → 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/mcp/README.md +34 -0
- package/mcp/src/client.ts +10 -1
- package/mcp/src/server.ts +401 -39
- package/package.json +1 -1
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`):
|
|
@@ -214,3 +234,17 @@ Tips:
|
|
|
214
234
|
and `repo.actions.artifacts.downloadZipUrl`
|
|
215
235
|
- Includes a discovery helper:
|
|
216
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
248
|
-
invocationArgs.push(
|
|
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
|
-
|
|
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
|
|
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
|
|
303
|
-
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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
|
|
349
|
-
const
|
|
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(
|
|
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:
|
|
726
|
+
text: JSON.stringify(toMcpThrownErrorEnvelope(error)),
|
|
365
727
|
},
|
|
366
728
|
],
|
|
367
729
|
}
|