@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/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
+ }