@foundation0/api 1.1.11 → 1.1.13
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 +128 -128
- package/agents.ts +918 -918
- package/git.ts +20 -20
- package/libs/curl.ts +770 -770
- package/mcp/cli.mjs +37 -37
- package/mcp/cli.ts +87 -87
- package/mcp/client.ts +565 -565
- package/mcp/index.ts +15 -15
- package/mcp/server.ts +2991 -2956
- package/net.ts +170 -170
- package/package.json +13 -9
- package/projects.ts +4250 -4318
- package/taskgraph-parser.ts +217 -217
- package/libs/curl.test.ts +0 -130
- package/mcp/AGENTS.md +0 -130
- package/mcp/client.test.ts +0 -142
- package/mcp/manual.md +0 -161
- package/mcp/server.test.ts +0 -870
package/mcp/client.ts
CHANGED
|
@@ -1,565 +1,565 @@
|
|
|
1
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
2
|
-
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
3
|
-
import { CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
4
|
-
import crypto from 'node:crypto'
|
|
5
|
-
import fs from 'node:fs/promises'
|
|
6
|
-
import os from 'node:os'
|
|
7
|
-
import path from 'node:path'
|
|
8
|
-
|
|
9
|
-
type ToolResultText = {
|
|
10
|
-
type: 'text'
|
|
11
|
-
text: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type ToolResult = {
|
|
15
|
-
isError?: boolean
|
|
16
|
-
content?: ToolResultText[]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 5 * 60 * 1000
|
|
20
|
-
const DEFAULT_MAX_INLINE_FILE_LINES = 300
|
|
21
|
-
const DEFAULT_FILE_CACHE_DIR = path.join(os.tmpdir(), 'f0-mcp-file-cache')
|
|
22
|
-
const CLIENT_FILE_OPTION_KEYS = new Set(['allowLargeSize', 'allowLargeFile', 'authorizeLargeSize', 'line', 'lineStart', 'lineEnd'])
|
|
23
|
-
|
|
24
|
-
type LineSelection =
|
|
25
|
-
| { kind: 'none' }
|
|
26
|
-
| { kind: 'single'; line: number }
|
|
27
|
-
| { kind: 'range'; start: number; end: number }
|
|
28
|
-
| { kind: 'invalid'; message: string }
|
|
29
|
-
|
|
30
|
-
type ClientFileControls = {
|
|
31
|
-
allowLargeSize: boolean
|
|
32
|
-
lineSelection: LineSelection
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
type GitFileReadStats = {
|
|
36
|
-
lines: number
|
|
37
|
-
chars: number
|
|
38
|
-
bytes: number
|
|
39
|
-
declaredBytes: number | null
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
type CachedGitFile = {
|
|
43
|
-
content: string
|
|
44
|
-
stats: GitFileReadStats
|
|
45
|
-
cacheKey: string
|
|
46
|
-
cachePath: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
type ParsedCallPayload = {
|
|
50
|
-
payload: ExampleMcpCallArgs
|
|
51
|
-
controls: ClientFileControls
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
type GitFileIdentity = {
|
|
55
|
-
owner: string | null
|
|
56
|
-
repo: string | null
|
|
57
|
-
filePath: string | null
|
|
58
|
-
ref: string | null
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
type ExtractedGitFileContent = {
|
|
62
|
-
content: string
|
|
63
|
-
stats: GitFileReadStats
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
67
|
-
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
68
|
-
|
|
69
|
-
const parseBoolean = (value: unknown): boolean =>
|
|
70
|
-
value === true || value === 'true' || value === 1 || value === '1'
|
|
71
|
-
|
|
72
|
-
const parsePositiveInteger = (value: unknown): number | null => {
|
|
73
|
-
const numeric = typeof value === 'string' && value.trim() !== ''
|
|
74
|
-
? Number(value)
|
|
75
|
-
: (typeof value === 'number' ? value : NaN)
|
|
76
|
-
|
|
77
|
-
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
78
|
-
return null
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return numeric
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const splitLines = (value: string): string[] => {
|
|
85
|
-
const normalized = value.replace(/\r/g, '')
|
|
86
|
-
const lines = normalized.split('\n')
|
|
87
|
-
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
88
|
-
lines.pop()
|
|
89
|
-
}
|
|
90
|
-
return lines
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const computeLineSelection = (
|
|
94
|
-
lineRaw: unknown,
|
|
95
|
-
lineStartRaw: unknown,
|
|
96
|
-
lineEndRaw: unknown,
|
|
97
|
-
): LineSelection => {
|
|
98
|
-
const lineProvided = lineRaw !== undefined
|
|
99
|
-
const lineStartProvided = lineStartRaw !== undefined
|
|
100
|
-
const lineEndProvided = lineEndRaw !== undefined
|
|
101
|
-
|
|
102
|
-
if (!lineProvided && !lineStartProvided && !lineEndProvided) {
|
|
103
|
-
return { kind: 'none' }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (lineProvided && (lineStartProvided || lineEndProvided)) {
|
|
107
|
-
return { kind: 'invalid', message: 'Use either "line" or "lineStart/lineEnd", not both.' }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (lineProvided) {
|
|
111
|
-
const line = parsePositiveInteger(lineRaw)
|
|
112
|
-
if (!line) {
|
|
113
|
-
return { kind: 'invalid', message: '"line" must be a positive integer.' }
|
|
114
|
-
}
|
|
115
|
-
return { kind: 'single', line }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const parsedStart = lineStartProvided ? parsePositiveInteger(lineStartRaw) : null
|
|
119
|
-
const parsedEnd = lineEndProvided ? parsePositiveInteger(lineEndRaw) : null
|
|
120
|
-
|
|
121
|
-
if (lineStartProvided && !parsedStart) {
|
|
122
|
-
return { kind: 'invalid', message: '"lineStart" must be a positive integer.' }
|
|
123
|
-
}
|
|
124
|
-
if (lineEndProvided && !parsedEnd) {
|
|
125
|
-
return { kind: 'invalid', message: '"lineEnd" must be a positive integer.' }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const start = parsedStart ?? parsedEnd
|
|
129
|
-
const end = parsedEnd ?? parsedStart
|
|
130
|
-
if (!start || !end) {
|
|
131
|
-
return { kind: 'invalid', message: 'Line range could not be parsed.' }
|
|
132
|
-
}
|
|
133
|
-
if (start > end) {
|
|
134
|
-
return { kind: 'invalid', message: '"lineStart" cannot be greater than "lineEnd".' }
|
|
135
|
-
}
|
|
136
|
-
return { kind: 'range', start, end }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const isGitFileViewTool = (toolName: string): boolean => /(^|\.)repo\.contents\.view$/.test(toolName)
|
|
140
|
-
|
|
141
|
-
const buildCacheKey = (toolName: string, identity: GitFileIdentity): string => {
|
|
142
|
-
const keyBody = JSON.stringify({ toolName, ...identity })
|
|
143
|
-
return crypto.createHash('sha256').update(keyBody).digest('hex')
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const decodeFileContent = (content: string, encoding: string): string => {
|
|
147
|
-
if (encoding === 'base64') {
|
|
148
|
-
return Buffer.from(content.replace(/\s+/g, ''), 'base64').toString('utf8')
|
|
149
|
-
}
|
|
150
|
-
return content
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const extractGitFileContent = (data: unknown): ExtractedGitFileContent | null => {
|
|
154
|
-
const unwrapped = (() => {
|
|
155
|
-
if (!isRecord(data)) return data
|
|
156
|
-
if (typeof data.ok === 'boolean' && 'result' in data) {
|
|
157
|
-
return (data as any).result
|
|
158
|
-
}
|
|
159
|
-
return data
|
|
160
|
-
})()
|
|
161
|
-
|
|
162
|
-
if (!isRecord(unwrapped) || !isRecord(unwrapped.body)) {
|
|
163
|
-
return null
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const body = unwrapped.body
|
|
167
|
-
if (typeof body.content !== 'string') {
|
|
168
|
-
return null
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const encoding = typeof body.encoding === 'string' ? body.encoding.toLowerCase() : ''
|
|
172
|
-
let content = ''
|
|
173
|
-
try {
|
|
174
|
-
content = decodeFileContent(body.content, encoding)
|
|
175
|
-
} catch {
|
|
176
|
-
return null
|
|
177
|
-
}
|
|
178
|
-
const lines = splitLines(content).length
|
|
179
|
-
const chars = content.length
|
|
180
|
-
const bytes = Buffer.byteLength(content, 'utf8')
|
|
181
|
-
const declaredBytes = typeof body.size === 'number'
|
|
182
|
-
? body.size
|
|
183
|
-
: (typeof body.size === 'string' && body.size.trim() !== '' ? Number(body.size) : null)
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
content,
|
|
187
|
-
stats: {
|
|
188
|
-
lines,
|
|
189
|
-
chars,
|
|
190
|
-
bytes,
|
|
191
|
-
declaredBytes: Number.isFinite(declaredBytes) ? Number(declaredBytes) : null,
|
|
192
|
-
},
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const toResponseText = (value: unknown): string => {
|
|
197
|
-
if (typeof value === 'string') {
|
|
198
|
-
return value
|
|
199
|
-
}
|
|
200
|
-
return JSON.stringify(value, null, 2)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const buildResponse = (isError: boolean, data: unknown): ExampleMcpCallResponse => ({
|
|
204
|
-
isError,
|
|
205
|
-
text: toResponseText(data),
|
|
206
|
-
data,
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
const toText = (result: ToolResult | null): string => {
|
|
210
|
-
if (!result || !result.content || result.content.length === 0) {
|
|
211
|
-
return ''
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return result.content
|
|
215
|
-
.map((entry) => (entry.type === 'text' ? entry.text : ''))
|
|
216
|
-
.filter((entry) => entry.length > 0)
|
|
217
|
-
.join('\n')
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const tryParseResult = (text: string): { parsed?: unknown; text: string } => {
|
|
221
|
-
if (!text) {
|
|
222
|
-
return { text }
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
return {
|
|
227
|
-
text,
|
|
228
|
-
parsed: JSON.parse(text),
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
return { text }
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export interface ExampleMcpClientOptions {
|
|
236
|
-
name?: string
|
|
237
|
-
version?: string
|
|
238
|
-
requestTimeoutMs?: number
|
|
239
|
-
maxInlineFileLines?: number
|
|
240
|
-
fileCacheDir?: string
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export interface ExampleMcpCallArgs {
|
|
244
|
-
args?: unknown[]
|
|
245
|
-
options?: Record<string, unknown>
|
|
246
|
-
[key: string]: unknown
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export type ExampleMcpCallResponse = {
|
|
250
|
-
isError: boolean
|
|
251
|
-
text: string
|
|
252
|
-
data: unknown
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export class ExampleMcpClient {
|
|
256
|
-
private readonly client: Client
|
|
257
|
-
private readonly requestTimeoutMs: number
|
|
258
|
-
private readonly maxInlineFileLines: number
|
|
259
|
-
private readonly fileCacheDir: string
|
|
260
|
-
private transport: StdioClientTransport | null = null
|
|
261
|
-
|
|
262
|
-
public constructor(options: ExampleMcpClientOptions = {}) {
|
|
263
|
-
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
264
|
-
this.maxInlineFileLines = options.maxInlineFileLines ?? DEFAULT_MAX_INLINE_FILE_LINES
|
|
265
|
-
this.fileCacheDir = options.fileCacheDir ?? DEFAULT_FILE_CACHE_DIR
|
|
266
|
-
this.client = new Client(
|
|
267
|
-
{
|
|
268
|
-
name: options.name ?? 'f0-mcp-client',
|
|
269
|
-
version: options.version ?? '1.0.0',
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
capabilities: {},
|
|
273
|
-
},
|
|
274
|
-
)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
public async connect(command: string, args: string[] = []): Promise<void> {
|
|
278
|
-
if (this.transport) {
|
|
279
|
-
throw new Error('Client transport is already connected')
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
this.transport = new StdioClientTransport({
|
|
283
|
-
command,
|
|
284
|
-
args,
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
await this.client.connect(this.transport)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
public async close(): Promise<void> {
|
|
291
|
-
if (this.transport) {
|
|
292
|
-
await this.client.close()
|
|
293
|
-
this.transport = null
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
public async listTools(): Promise<string[]> {
|
|
298
|
-
const response = await this.client.request(
|
|
299
|
-
{
|
|
300
|
-
method: 'tools/list',
|
|
301
|
-
},
|
|
302
|
-
ListToolsResultSchema,
|
|
303
|
-
{
|
|
304
|
-
timeout: this.requestTimeoutMs,
|
|
305
|
-
},
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
return response.tools.map((tool) => tool.name)
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private parseCallPayload(args: ExampleMcpCallArgs): ParsedCallPayload {
|
|
312
|
-
const input = isRecord(args) ? args : {}
|
|
313
|
-
const options = isRecord(input.options) ? { ...input.options } : {}
|
|
314
|
-
const allowLargeSize = parseBoolean(input.allowLargeSize)
|
|
315
|
-
|| parseBoolean(input.allowLargeFile)
|
|
316
|
-
|| parseBoolean(input.authorizeLargeSize)
|
|
317
|
-
|| parseBoolean(options.allowLargeSize)
|
|
318
|
-
|| parseBoolean(options.allowLargeFile)
|
|
319
|
-
|| parseBoolean(options.authorizeLargeSize)
|
|
320
|
-
|
|
321
|
-
const lineSelection = computeLineSelection(
|
|
322
|
-
input.line ?? options.line,
|
|
323
|
-
input.lineStart ?? options.lineStart,
|
|
324
|
-
input.lineEnd ?? options.lineEnd,
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
for (const key of CLIENT_FILE_OPTION_KEYS) {
|
|
328
|
-
delete options[key]
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const payload: ExampleMcpCallArgs = {}
|
|
332
|
-
if (Array.isArray(input.args)) {
|
|
333
|
-
payload.args = [...input.args]
|
|
334
|
-
}
|
|
335
|
-
if (Object.keys(options).length > 0) {
|
|
336
|
-
payload.options = options
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
for (const [key, value] of Object.entries(input)) {
|
|
340
|
-
if (key === 'args' || key === 'options' || CLIENT_FILE_OPTION_KEYS.has(key)) {
|
|
341
|
-
continue
|
|
342
|
-
}
|
|
343
|
-
if (value !== undefined) {
|
|
344
|
-
payload[key] = value
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return {
|
|
349
|
-
payload,
|
|
350
|
-
controls: {
|
|
351
|
-
allowLargeSize,
|
|
352
|
-
lineSelection,
|
|
353
|
-
},
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
private async requestTool(toolName: string, args: ExampleMcpCallArgs): Promise<ExampleMcpCallResponse> {
|
|
358
|
-
const response = await this.client.request(
|
|
359
|
-
{
|
|
360
|
-
method: 'tools/call',
|
|
361
|
-
params: {
|
|
362
|
-
name: toolName,
|
|
363
|
-
arguments: args,
|
|
364
|
-
},
|
|
365
|
-
},
|
|
366
|
-
CallToolResultSchema,
|
|
367
|
-
{
|
|
368
|
-
timeout: this.requestTimeoutMs,
|
|
369
|
-
},
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
const text = toText(response as ToolResult)
|
|
373
|
-
const parsed = tryParseResult(text)
|
|
374
|
-
return {
|
|
375
|
-
isError: Boolean(response.isError),
|
|
376
|
-
text: parsed.text,
|
|
377
|
-
data: parsed.parsed ?? parsed.text,
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
private extractGitFileIdentity(payload: ExampleMcpCallArgs): GitFileIdentity {
|
|
382
|
-
const args = Array.isArray(payload.args) ? payload.args : []
|
|
383
|
-
const options = isRecord(payload.options) ? payload.options : {}
|
|
384
|
-
const query = isRecord(options.query) ? options.query : {}
|
|
385
|
-
|
|
386
|
-
const owner = args.length > 0 ? String(args[0]) : null
|
|
387
|
-
const repo = args.length > 1 ? String(args[1]) : null
|
|
388
|
-
const filePath = args.length > 2 ? String(args[2]) : null
|
|
389
|
-
const refSource = options.ref ?? options.branch ?? options.sha ?? query.ref ?? query.branch ?? query.sha
|
|
390
|
-
const ref = refSource === undefined ? null : String(refSource)
|
|
391
|
-
|
|
392
|
-
return { owner, repo, filePath, ref }
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
private getCachePaths(cacheKey: string): { contentPath: string; metaPath: string } {
|
|
396
|
-
return {
|
|
397
|
-
contentPath: path.join(this.fileCacheDir, `${cacheKey}.txt`),
|
|
398
|
-
metaPath: path.join(this.fileCacheDir, `${cacheKey}.json`),
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
private async readCachedGitFile(cacheKey: string): Promise<CachedGitFile | null> {
|
|
403
|
-
const { contentPath, metaPath } = this.getCachePaths(cacheKey)
|
|
404
|
-
try {
|
|
405
|
-
const [content, metaRaw] = await Promise.all([
|
|
406
|
-
fs.readFile(contentPath, 'utf8'),
|
|
407
|
-
fs.readFile(metaPath, 'utf8'),
|
|
408
|
-
])
|
|
409
|
-
|
|
410
|
-
const metaParsed = JSON.parse(metaRaw)
|
|
411
|
-
if (!isRecord(metaParsed)) {
|
|
412
|
-
return null
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const statsRaw = isRecord(metaParsed.stats) ? metaParsed.stats : {}
|
|
416
|
-
const lines = parsePositiveInteger(statsRaw.lines) ?? splitLines(content).length
|
|
417
|
-
const chars = parsePositiveInteger(statsRaw.chars) ?? content.length
|
|
418
|
-
const bytes = parsePositiveInteger(statsRaw.bytes) ?? Buffer.byteLength(content, 'utf8')
|
|
419
|
-
const declaredBytesRaw = statsRaw.declaredBytes
|
|
420
|
-
const declaredBytes = typeof declaredBytesRaw === 'number'
|
|
421
|
-
? declaredBytesRaw
|
|
422
|
-
: (typeof declaredBytesRaw === 'string' && declaredBytesRaw.trim() !== '' ? Number(declaredBytesRaw) : null)
|
|
423
|
-
|
|
424
|
-
return {
|
|
425
|
-
content,
|
|
426
|
-
stats: {
|
|
427
|
-
lines,
|
|
428
|
-
chars,
|
|
429
|
-
bytes,
|
|
430
|
-
declaredBytes: Number.isFinite(declaredBytes) ? Number(declaredBytes) : null,
|
|
431
|
-
},
|
|
432
|
-
cacheKey,
|
|
433
|
-
cachePath: contentPath,
|
|
434
|
-
}
|
|
435
|
-
} catch {
|
|
436
|
-
return null
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
private async writeCachedGitFile(cacheKey: string, content: string, stats: GitFileReadStats): Promise<CachedGitFile> {
|
|
441
|
-
await fs.mkdir(this.fileCacheDir, { recursive: true })
|
|
442
|
-
const { contentPath, metaPath } = this.getCachePaths(cacheKey)
|
|
443
|
-
await Promise.all([
|
|
444
|
-
fs.writeFile(contentPath, content, 'utf8'),
|
|
445
|
-
fs.writeFile(metaPath, JSON.stringify({ stats }, null, 2), 'utf8'),
|
|
446
|
-
])
|
|
447
|
-
|
|
448
|
-
return {
|
|
449
|
-
content,
|
|
450
|
-
stats,
|
|
451
|
-
cacheKey,
|
|
452
|
-
cachePath: contentPath,
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
private buildLineReadResponse(cache: CachedGitFile, selection: Exclude<LineSelection, { kind: 'none' | 'invalid' }>): ExampleMcpCallResponse {
|
|
457
|
-
const allLines = splitLines(cache.content)
|
|
458
|
-
const start = selection.kind === 'single' ? selection.line : selection.start
|
|
459
|
-
const end = selection.kind === 'single' ? selection.line : selection.end
|
|
460
|
-
|
|
461
|
-
if (start > allLines.length) {
|
|
462
|
-
return buildResponse(true, {
|
|
463
|
-
error: 'Requested line is out of range.',
|
|
464
|
-
requested: { start, end },
|
|
465
|
-
totalLines: allLines.length,
|
|
466
|
-
cachePath: cache.cachePath,
|
|
467
|
-
})
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const clampedEnd = Math.min(end, allLines.length)
|
|
471
|
-
const lines = []
|
|
472
|
-
for (let lineNo = start; lineNo <= clampedEnd; lineNo += 1) {
|
|
473
|
-
lines.push({
|
|
474
|
-
line: lineNo,
|
|
475
|
-
text: allLines[lineNo - 1] ?? '',
|
|
476
|
-
})
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
return buildResponse(false, {
|
|
480
|
-
mode: 'line',
|
|
481
|
-
requested: { start, end: clampedEnd },
|
|
482
|
-
totalLines: allLines.length,
|
|
483
|
-
cachePath: cache.cachePath,
|
|
484
|
-
stats: cache.stats,
|
|
485
|
-
lines,
|
|
486
|
-
})
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
public async call(toolName: string, args: ExampleMcpCallArgs = {}): Promise<ExampleMcpCallResponse> {
|
|
490
|
-
const { payload, controls } = this.parseCallPayload(args)
|
|
491
|
-
|
|
492
|
-
if (!isGitFileViewTool(toolName)) {
|
|
493
|
-
return this.requestTool(toolName, payload)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
if (controls.lineSelection.kind === 'invalid') {
|
|
497
|
-
return buildResponse(true, {
|
|
498
|
-
error: controls.lineSelection.message,
|
|
499
|
-
hint: 'Use options.line=<positive-int> or options.lineStart/options.lineEnd for ranged reads.',
|
|
500
|
-
})
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const identity = this.extractGitFileIdentity(payload)
|
|
504
|
-
const cacheKey = buildCacheKey(toolName, identity)
|
|
505
|
-
const requiresLineRead = controls.lineSelection.kind === 'single' || controls.lineSelection.kind === 'range'
|
|
506
|
-
|
|
507
|
-
if (requiresLineRead) {
|
|
508
|
-
const cached = await this.readCachedGitFile(cacheKey)
|
|
509
|
-
if (cached) {
|
|
510
|
-
return this.buildLineReadResponse(cached, controls.lineSelection)
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const response = await this.requestTool(toolName, payload)
|
|
515
|
-
if (response.isError) {
|
|
516
|
-
return response
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const extracted = extractGitFileContent(response.data)
|
|
520
|
-
if (!extracted) {
|
|
521
|
-
if (requiresLineRead) {
|
|
522
|
-
return buildResponse(true, {
|
|
523
|
-
error: 'Line-by-line mode requires a file response with textual content.',
|
|
524
|
-
hint: 'Use repo.contents.view for files. Directory listings are not line-readable.',
|
|
525
|
-
})
|
|
526
|
-
}
|
|
527
|
-
return response
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (!requiresLineRead) {
|
|
531
|
-
if (extracted.stats.lines > this.maxInlineFileLines && !controls.allowLargeSize) {
|
|
532
|
-
return buildResponse(true, {
|
|
533
|
-
error: 'Large file response blocked by client-side safety guard.',
|
|
534
|
-
hint: `File has ${extracted.stats.lines} lines; default limit is ${this.maxInlineFileLines}. Set options.allowLargeSize=true to allow full-file responses.`,
|
|
535
|
-
lineByLineHint: 'For safer reads, request options.line=<n> or options.lineStart=<a>, options.lineEnd=<b>.',
|
|
536
|
-
stats: extracted.stats,
|
|
537
|
-
file: identity,
|
|
538
|
-
optionExample: {
|
|
539
|
-
options: {
|
|
540
|
-
allowLargeSize: true,
|
|
541
|
-
},
|
|
542
|
-
},
|
|
543
|
-
})
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
return response
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const cached = await this.writeCachedGitFile(cacheKey, extracted.content, extracted.stats)
|
|
550
|
-
return this.buildLineReadResponse(cached, controls.lineSelection)
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
public async callByPath(
|
|
554
|
-
path: string,
|
|
555
|
-
methodArgs: unknown[] = [],
|
|
556
|
-
methodOptions: Record<string, unknown> = {},
|
|
557
|
-
): Promise<ExampleMcpCallResponse> {
|
|
558
|
-
const args = { args: methodArgs, options: methodOptions }
|
|
559
|
-
return this.call(path, args)
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
export const createExampleMcpClient = (options: ExampleMcpClientOptions = {}): ExampleMcpClient => {
|
|
564
|
-
return new ExampleMcpClient(options)
|
|
565
|
-
}
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
2
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
3
|
+
import { CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import fs from 'node:fs/promises'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
type ToolResultText = {
|
|
10
|
+
type: 'text'
|
|
11
|
+
text: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ToolResult = {
|
|
15
|
+
isError?: boolean
|
|
16
|
+
content?: ToolResultText[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5 * 60 * 1000
|
|
20
|
+
const DEFAULT_MAX_INLINE_FILE_LINES = 300
|
|
21
|
+
const DEFAULT_FILE_CACHE_DIR = path.join(os.tmpdir(), 'f0-mcp-file-cache')
|
|
22
|
+
const CLIENT_FILE_OPTION_KEYS = new Set(['allowLargeSize', 'allowLargeFile', 'authorizeLargeSize', 'line', 'lineStart', 'lineEnd'])
|
|
23
|
+
|
|
24
|
+
type LineSelection =
|
|
25
|
+
| { kind: 'none' }
|
|
26
|
+
| { kind: 'single'; line: number }
|
|
27
|
+
| { kind: 'range'; start: number; end: number }
|
|
28
|
+
| { kind: 'invalid'; message: string }
|
|
29
|
+
|
|
30
|
+
type ClientFileControls = {
|
|
31
|
+
allowLargeSize: boolean
|
|
32
|
+
lineSelection: LineSelection
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type GitFileReadStats = {
|
|
36
|
+
lines: number
|
|
37
|
+
chars: number
|
|
38
|
+
bytes: number
|
|
39
|
+
declaredBytes: number | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type CachedGitFile = {
|
|
43
|
+
content: string
|
|
44
|
+
stats: GitFileReadStats
|
|
45
|
+
cacheKey: string
|
|
46
|
+
cachePath: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ParsedCallPayload = {
|
|
50
|
+
payload: ExampleMcpCallArgs
|
|
51
|
+
controls: ClientFileControls
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type GitFileIdentity = {
|
|
55
|
+
owner: string | null
|
|
56
|
+
repo: string | null
|
|
57
|
+
filePath: string | null
|
|
58
|
+
ref: string | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ExtractedGitFileContent = {
|
|
62
|
+
content: string
|
|
63
|
+
stats: GitFileReadStats
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
67
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
68
|
+
|
|
69
|
+
const parseBoolean = (value: unknown): boolean =>
|
|
70
|
+
value === true || value === 'true' || value === 1 || value === '1'
|
|
71
|
+
|
|
72
|
+
const parsePositiveInteger = (value: unknown): number | null => {
|
|
73
|
+
const numeric = typeof value === 'string' && value.trim() !== ''
|
|
74
|
+
? Number(value)
|
|
75
|
+
: (typeof value === 'number' ? value : NaN)
|
|
76
|
+
|
|
77
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return numeric
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const splitLines = (value: string): string[] => {
|
|
85
|
+
const normalized = value.replace(/\r/g, '')
|
|
86
|
+
const lines = normalized.split('\n')
|
|
87
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
88
|
+
lines.pop()
|
|
89
|
+
}
|
|
90
|
+
return lines
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const computeLineSelection = (
|
|
94
|
+
lineRaw: unknown,
|
|
95
|
+
lineStartRaw: unknown,
|
|
96
|
+
lineEndRaw: unknown,
|
|
97
|
+
): LineSelection => {
|
|
98
|
+
const lineProvided = lineRaw !== undefined
|
|
99
|
+
const lineStartProvided = lineStartRaw !== undefined
|
|
100
|
+
const lineEndProvided = lineEndRaw !== undefined
|
|
101
|
+
|
|
102
|
+
if (!lineProvided && !lineStartProvided && !lineEndProvided) {
|
|
103
|
+
return { kind: 'none' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (lineProvided && (lineStartProvided || lineEndProvided)) {
|
|
107
|
+
return { kind: 'invalid', message: 'Use either "line" or "lineStart/lineEnd", not both.' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (lineProvided) {
|
|
111
|
+
const line = parsePositiveInteger(lineRaw)
|
|
112
|
+
if (!line) {
|
|
113
|
+
return { kind: 'invalid', message: '"line" must be a positive integer.' }
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'single', line }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parsedStart = lineStartProvided ? parsePositiveInteger(lineStartRaw) : null
|
|
119
|
+
const parsedEnd = lineEndProvided ? parsePositiveInteger(lineEndRaw) : null
|
|
120
|
+
|
|
121
|
+
if (lineStartProvided && !parsedStart) {
|
|
122
|
+
return { kind: 'invalid', message: '"lineStart" must be a positive integer.' }
|
|
123
|
+
}
|
|
124
|
+
if (lineEndProvided && !parsedEnd) {
|
|
125
|
+
return { kind: 'invalid', message: '"lineEnd" must be a positive integer.' }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const start = parsedStart ?? parsedEnd
|
|
129
|
+
const end = parsedEnd ?? parsedStart
|
|
130
|
+
if (!start || !end) {
|
|
131
|
+
return { kind: 'invalid', message: 'Line range could not be parsed.' }
|
|
132
|
+
}
|
|
133
|
+
if (start > end) {
|
|
134
|
+
return { kind: 'invalid', message: '"lineStart" cannot be greater than "lineEnd".' }
|
|
135
|
+
}
|
|
136
|
+
return { kind: 'range', start, end }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isGitFileViewTool = (toolName: string): boolean => /(^|\.)repo\.contents\.view$/.test(toolName)
|
|
140
|
+
|
|
141
|
+
const buildCacheKey = (toolName: string, identity: GitFileIdentity): string => {
|
|
142
|
+
const keyBody = JSON.stringify({ toolName, ...identity })
|
|
143
|
+
return crypto.createHash('sha256').update(keyBody).digest('hex')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const decodeFileContent = (content: string, encoding: string): string => {
|
|
147
|
+
if (encoding === 'base64') {
|
|
148
|
+
return Buffer.from(content.replace(/\s+/g, ''), 'base64').toString('utf8')
|
|
149
|
+
}
|
|
150
|
+
return content
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const extractGitFileContent = (data: unknown): ExtractedGitFileContent | null => {
|
|
154
|
+
const unwrapped = (() => {
|
|
155
|
+
if (!isRecord(data)) return data
|
|
156
|
+
if (typeof data.ok === 'boolean' && 'result' in data) {
|
|
157
|
+
return (data as any).result
|
|
158
|
+
}
|
|
159
|
+
return data
|
|
160
|
+
})()
|
|
161
|
+
|
|
162
|
+
if (!isRecord(unwrapped) || !isRecord(unwrapped.body)) {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const body = unwrapped.body
|
|
167
|
+
if (typeof body.content !== 'string') {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const encoding = typeof body.encoding === 'string' ? body.encoding.toLowerCase() : ''
|
|
172
|
+
let content = ''
|
|
173
|
+
try {
|
|
174
|
+
content = decodeFileContent(body.content, encoding)
|
|
175
|
+
} catch {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
const lines = splitLines(content).length
|
|
179
|
+
const chars = content.length
|
|
180
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
181
|
+
const declaredBytes = typeof body.size === 'number'
|
|
182
|
+
? body.size
|
|
183
|
+
: (typeof body.size === 'string' && body.size.trim() !== '' ? Number(body.size) : null)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
content,
|
|
187
|
+
stats: {
|
|
188
|
+
lines,
|
|
189
|
+
chars,
|
|
190
|
+
bytes,
|
|
191
|
+
declaredBytes: Number.isFinite(declaredBytes) ? Number(declaredBytes) : null,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const toResponseText = (value: unknown): string => {
|
|
197
|
+
if (typeof value === 'string') {
|
|
198
|
+
return value
|
|
199
|
+
}
|
|
200
|
+
return JSON.stringify(value, null, 2)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const buildResponse = (isError: boolean, data: unknown): ExampleMcpCallResponse => ({
|
|
204
|
+
isError,
|
|
205
|
+
text: toResponseText(data),
|
|
206
|
+
data,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const toText = (result: ToolResult | null): string => {
|
|
210
|
+
if (!result || !result.content || result.content.length === 0) {
|
|
211
|
+
return ''
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return result.content
|
|
215
|
+
.map((entry) => (entry.type === 'text' ? entry.text : ''))
|
|
216
|
+
.filter((entry) => entry.length > 0)
|
|
217
|
+
.join('\n')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const tryParseResult = (text: string): { parsed?: unknown; text: string } => {
|
|
221
|
+
if (!text) {
|
|
222
|
+
return { text }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
return {
|
|
227
|
+
text,
|
|
228
|
+
parsed: JSON.parse(text),
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
return { text }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface ExampleMcpClientOptions {
|
|
236
|
+
name?: string
|
|
237
|
+
version?: string
|
|
238
|
+
requestTimeoutMs?: number
|
|
239
|
+
maxInlineFileLines?: number
|
|
240
|
+
fileCacheDir?: string
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface ExampleMcpCallArgs {
|
|
244
|
+
args?: unknown[]
|
|
245
|
+
options?: Record<string, unknown>
|
|
246
|
+
[key: string]: unknown
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type ExampleMcpCallResponse = {
|
|
250
|
+
isError: boolean
|
|
251
|
+
text: string
|
|
252
|
+
data: unknown
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export class ExampleMcpClient {
|
|
256
|
+
private readonly client: Client
|
|
257
|
+
private readonly requestTimeoutMs: number
|
|
258
|
+
private readonly maxInlineFileLines: number
|
|
259
|
+
private readonly fileCacheDir: string
|
|
260
|
+
private transport: StdioClientTransport | null = null
|
|
261
|
+
|
|
262
|
+
public constructor(options: ExampleMcpClientOptions = {}) {
|
|
263
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
264
|
+
this.maxInlineFileLines = options.maxInlineFileLines ?? DEFAULT_MAX_INLINE_FILE_LINES
|
|
265
|
+
this.fileCacheDir = options.fileCacheDir ?? DEFAULT_FILE_CACHE_DIR
|
|
266
|
+
this.client = new Client(
|
|
267
|
+
{
|
|
268
|
+
name: options.name ?? 'f0-mcp-client',
|
|
269
|
+
version: options.version ?? '1.0.0',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
capabilities: {},
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public async connect(command: string, args: string[] = []): Promise<void> {
|
|
278
|
+
if (this.transport) {
|
|
279
|
+
throw new Error('Client transport is already connected')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.transport = new StdioClientTransport({
|
|
283
|
+
command,
|
|
284
|
+
args,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
await this.client.connect(this.transport)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
public async close(): Promise<void> {
|
|
291
|
+
if (this.transport) {
|
|
292
|
+
await this.client.close()
|
|
293
|
+
this.transport = null
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
public async listTools(): Promise<string[]> {
|
|
298
|
+
const response = await this.client.request(
|
|
299
|
+
{
|
|
300
|
+
method: 'tools/list',
|
|
301
|
+
},
|
|
302
|
+
ListToolsResultSchema,
|
|
303
|
+
{
|
|
304
|
+
timeout: this.requestTimeoutMs,
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return response.tools.map((tool) => tool.name)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private parseCallPayload(args: ExampleMcpCallArgs): ParsedCallPayload {
|
|
312
|
+
const input = isRecord(args) ? args : {}
|
|
313
|
+
const options = isRecord(input.options) ? { ...input.options } : {}
|
|
314
|
+
const allowLargeSize = parseBoolean(input.allowLargeSize)
|
|
315
|
+
|| parseBoolean(input.allowLargeFile)
|
|
316
|
+
|| parseBoolean(input.authorizeLargeSize)
|
|
317
|
+
|| parseBoolean(options.allowLargeSize)
|
|
318
|
+
|| parseBoolean(options.allowLargeFile)
|
|
319
|
+
|| parseBoolean(options.authorizeLargeSize)
|
|
320
|
+
|
|
321
|
+
const lineSelection = computeLineSelection(
|
|
322
|
+
input.line ?? options.line,
|
|
323
|
+
input.lineStart ?? options.lineStart,
|
|
324
|
+
input.lineEnd ?? options.lineEnd,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
for (const key of CLIENT_FILE_OPTION_KEYS) {
|
|
328
|
+
delete options[key]
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const payload: ExampleMcpCallArgs = {}
|
|
332
|
+
if (Array.isArray(input.args)) {
|
|
333
|
+
payload.args = [...input.args]
|
|
334
|
+
}
|
|
335
|
+
if (Object.keys(options).length > 0) {
|
|
336
|
+
payload.options = options
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const [key, value] of Object.entries(input)) {
|
|
340
|
+
if (key === 'args' || key === 'options' || CLIENT_FILE_OPTION_KEYS.has(key)) {
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (value !== undefined) {
|
|
344
|
+
payload[key] = value
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
payload,
|
|
350
|
+
controls: {
|
|
351
|
+
allowLargeSize,
|
|
352
|
+
lineSelection,
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async requestTool(toolName: string, args: ExampleMcpCallArgs): Promise<ExampleMcpCallResponse> {
|
|
358
|
+
const response = await this.client.request(
|
|
359
|
+
{
|
|
360
|
+
method: 'tools/call',
|
|
361
|
+
params: {
|
|
362
|
+
name: toolName,
|
|
363
|
+
arguments: args,
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
CallToolResultSchema,
|
|
367
|
+
{
|
|
368
|
+
timeout: this.requestTimeoutMs,
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
const text = toText(response as ToolResult)
|
|
373
|
+
const parsed = tryParseResult(text)
|
|
374
|
+
return {
|
|
375
|
+
isError: Boolean(response.isError),
|
|
376
|
+
text: parsed.text,
|
|
377
|
+
data: parsed.parsed ?? parsed.text,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private extractGitFileIdentity(payload: ExampleMcpCallArgs): GitFileIdentity {
|
|
382
|
+
const args = Array.isArray(payload.args) ? payload.args : []
|
|
383
|
+
const options = isRecord(payload.options) ? payload.options : {}
|
|
384
|
+
const query = isRecord(options.query) ? options.query : {}
|
|
385
|
+
|
|
386
|
+
const owner = args.length > 0 ? String(args[0]) : null
|
|
387
|
+
const repo = args.length > 1 ? String(args[1]) : null
|
|
388
|
+
const filePath = args.length > 2 ? String(args[2]) : null
|
|
389
|
+
const refSource = options.ref ?? options.branch ?? options.sha ?? query.ref ?? query.branch ?? query.sha
|
|
390
|
+
const ref = refSource === undefined ? null : String(refSource)
|
|
391
|
+
|
|
392
|
+
return { owner, repo, filePath, ref }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private getCachePaths(cacheKey: string): { contentPath: string; metaPath: string } {
|
|
396
|
+
return {
|
|
397
|
+
contentPath: path.join(this.fileCacheDir, `${cacheKey}.txt`),
|
|
398
|
+
metaPath: path.join(this.fileCacheDir, `${cacheKey}.json`),
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private async readCachedGitFile(cacheKey: string): Promise<CachedGitFile | null> {
|
|
403
|
+
const { contentPath, metaPath } = this.getCachePaths(cacheKey)
|
|
404
|
+
try {
|
|
405
|
+
const [content, metaRaw] = await Promise.all([
|
|
406
|
+
fs.readFile(contentPath, 'utf8'),
|
|
407
|
+
fs.readFile(metaPath, 'utf8'),
|
|
408
|
+
])
|
|
409
|
+
|
|
410
|
+
const metaParsed = JSON.parse(metaRaw)
|
|
411
|
+
if (!isRecord(metaParsed)) {
|
|
412
|
+
return null
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const statsRaw = isRecord(metaParsed.stats) ? metaParsed.stats : {}
|
|
416
|
+
const lines = parsePositiveInteger(statsRaw.lines) ?? splitLines(content).length
|
|
417
|
+
const chars = parsePositiveInteger(statsRaw.chars) ?? content.length
|
|
418
|
+
const bytes = parsePositiveInteger(statsRaw.bytes) ?? Buffer.byteLength(content, 'utf8')
|
|
419
|
+
const declaredBytesRaw = statsRaw.declaredBytes
|
|
420
|
+
const declaredBytes = typeof declaredBytesRaw === 'number'
|
|
421
|
+
? declaredBytesRaw
|
|
422
|
+
: (typeof declaredBytesRaw === 'string' && declaredBytesRaw.trim() !== '' ? Number(declaredBytesRaw) : null)
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
content,
|
|
426
|
+
stats: {
|
|
427
|
+
lines,
|
|
428
|
+
chars,
|
|
429
|
+
bytes,
|
|
430
|
+
declaredBytes: Number.isFinite(declaredBytes) ? Number(declaredBytes) : null,
|
|
431
|
+
},
|
|
432
|
+
cacheKey,
|
|
433
|
+
cachePath: contentPath,
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async writeCachedGitFile(cacheKey: string, content: string, stats: GitFileReadStats): Promise<CachedGitFile> {
|
|
441
|
+
await fs.mkdir(this.fileCacheDir, { recursive: true })
|
|
442
|
+
const { contentPath, metaPath } = this.getCachePaths(cacheKey)
|
|
443
|
+
await Promise.all([
|
|
444
|
+
fs.writeFile(contentPath, content, 'utf8'),
|
|
445
|
+
fs.writeFile(metaPath, JSON.stringify({ stats }, null, 2), 'utf8'),
|
|
446
|
+
])
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
content,
|
|
450
|
+
stats,
|
|
451
|
+
cacheKey,
|
|
452
|
+
cachePath: contentPath,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private buildLineReadResponse(cache: CachedGitFile, selection: Exclude<LineSelection, { kind: 'none' | 'invalid' }>): ExampleMcpCallResponse {
|
|
457
|
+
const allLines = splitLines(cache.content)
|
|
458
|
+
const start = selection.kind === 'single' ? selection.line : selection.start
|
|
459
|
+
const end = selection.kind === 'single' ? selection.line : selection.end
|
|
460
|
+
|
|
461
|
+
if (start > allLines.length) {
|
|
462
|
+
return buildResponse(true, {
|
|
463
|
+
error: 'Requested line is out of range.',
|
|
464
|
+
requested: { start, end },
|
|
465
|
+
totalLines: allLines.length,
|
|
466
|
+
cachePath: cache.cachePath,
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const clampedEnd = Math.min(end, allLines.length)
|
|
471
|
+
const lines = []
|
|
472
|
+
for (let lineNo = start; lineNo <= clampedEnd; lineNo += 1) {
|
|
473
|
+
lines.push({
|
|
474
|
+
line: lineNo,
|
|
475
|
+
text: allLines[lineNo - 1] ?? '',
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return buildResponse(false, {
|
|
480
|
+
mode: 'line',
|
|
481
|
+
requested: { start, end: clampedEnd },
|
|
482
|
+
totalLines: allLines.length,
|
|
483
|
+
cachePath: cache.cachePath,
|
|
484
|
+
stats: cache.stats,
|
|
485
|
+
lines,
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
public async call(toolName: string, args: ExampleMcpCallArgs = {}): Promise<ExampleMcpCallResponse> {
|
|
490
|
+
const { payload, controls } = this.parseCallPayload(args)
|
|
491
|
+
|
|
492
|
+
if (!isGitFileViewTool(toolName)) {
|
|
493
|
+
return this.requestTool(toolName, payload)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (controls.lineSelection.kind === 'invalid') {
|
|
497
|
+
return buildResponse(true, {
|
|
498
|
+
error: controls.lineSelection.message,
|
|
499
|
+
hint: 'Use options.line=<positive-int> or options.lineStart/options.lineEnd for ranged reads.',
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const identity = this.extractGitFileIdentity(payload)
|
|
504
|
+
const cacheKey = buildCacheKey(toolName, identity)
|
|
505
|
+
const requiresLineRead = controls.lineSelection.kind === 'single' || controls.lineSelection.kind === 'range'
|
|
506
|
+
|
|
507
|
+
if (requiresLineRead) {
|
|
508
|
+
const cached = await this.readCachedGitFile(cacheKey)
|
|
509
|
+
if (cached) {
|
|
510
|
+
return this.buildLineReadResponse(cached, controls.lineSelection)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const response = await this.requestTool(toolName, payload)
|
|
515
|
+
if (response.isError) {
|
|
516
|
+
return response
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const extracted = extractGitFileContent(response.data)
|
|
520
|
+
if (!extracted) {
|
|
521
|
+
if (requiresLineRead) {
|
|
522
|
+
return buildResponse(true, {
|
|
523
|
+
error: 'Line-by-line mode requires a file response with textual content.',
|
|
524
|
+
hint: 'Use repo.contents.view for files. Directory listings are not line-readable.',
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
return response
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!requiresLineRead) {
|
|
531
|
+
if (extracted.stats.lines > this.maxInlineFileLines && !controls.allowLargeSize) {
|
|
532
|
+
return buildResponse(true, {
|
|
533
|
+
error: 'Large file response blocked by client-side safety guard.',
|
|
534
|
+
hint: `File has ${extracted.stats.lines} lines; default limit is ${this.maxInlineFileLines}. Set options.allowLargeSize=true to allow full-file responses.`,
|
|
535
|
+
lineByLineHint: 'For safer reads, request options.line=<n> or options.lineStart=<a>, options.lineEnd=<b>.',
|
|
536
|
+
stats: extracted.stats,
|
|
537
|
+
file: identity,
|
|
538
|
+
optionExample: {
|
|
539
|
+
options: {
|
|
540
|
+
allowLargeSize: true,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return response
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const cached = await this.writeCachedGitFile(cacheKey, extracted.content, extracted.stats)
|
|
550
|
+
return this.buildLineReadResponse(cached, controls.lineSelection)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
public async callByPath(
|
|
554
|
+
path: string,
|
|
555
|
+
methodArgs: unknown[] = [],
|
|
556
|
+
methodOptions: Record<string, unknown> = {},
|
|
557
|
+
): Promise<ExampleMcpCallResponse> {
|
|
558
|
+
const args = { args: methodArgs, options: methodOptions }
|
|
559
|
+
return this.call(path, args)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export const createExampleMcpClient = (options: ExampleMcpClientOptions = {}): ExampleMcpClient => {
|
|
564
|
+
return new ExampleMcpClient(options)
|
|
565
|
+
}
|