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