@foundation0/api 1.1.0 → 1.1.2

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,6 +1,10 @@
1
1
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
2
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
3
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'
4
8
 
5
9
  type ToolResultText = {
6
10
  type: 'text'
@@ -13,6 +17,194 @@ type ToolResult = {
13
17
  }
14
18
 
15
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
+ })
16
208
 
17
209
  const toText = (result: ToolResult | null): string => {
18
210
  if (!result || !result.content || result.content.length === 0) {
@@ -44,6 +236,8 @@ export interface ExampleMcpClientOptions {
44
236
  name?: string
45
237
  version?: string
46
238
  requestTimeoutMs?: number
239
+ maxInlineFileLines?: number
240
+ fileCacheDir?: string
47
241
  }
48
242
 
49
243
  export interface ExampleMcpCallArgs {
@@ -61,10 +255,14 @@ export type ExampleMcpCallResponse = {
61
255
  export class ExampleMcpClient {
62
256
  private readonly client: Client
63
257
  private readonly requestTimeoutMs: number
258
+ private readonly maxInlineFileLines: number
259
+ private readonly fileCacheDir: string
64
260
  private transport: StdioClientTransport | null = null
65
261
 
66
262
  public constructor(options: ExampleMcpClientOptions = {}) {
67
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
68
266
  this.client = new Client(
69
267
  {
70
268
  name: options.name ?? 'f0-mcp-client',
@@ -110,7 +308,53 @@ export class ExampleMcpClient {
110
308
  return response.tools.map((tool) => tool.name)
111
309
  }
112
310
 
113
- public async call(toolName: string, args: ExampleMcpCallArgs = {}): Promise<ExampleMcpCallResponse> {
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> {
114
358
  const response = await this.client.request(
115
359
  {
116
360
  method: 'tools/call',
@@ -130,16 +374,188 @@ export class ExampleMcpClient {
130
374
  return {
131
375
  isError: Boolean(response.isError),
132
376
  text: parsed.text,
133
- data: parsed.parsed,
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
134
437
  }
135
438
  }
136
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
+
137
553
  public async callByPath(
138
554
  path: string,
139
555
  methodArgs: unknown[] = [],
140
556
  methodOptions: Record<string, unknown> = {},
141
557
  ): Promise<ExampleMcpCallResponse> {
142
- const args = { args: methodArgs.map((value) => String(value)), options: methodOptions }
558
+ const args = { args: methodArgs, options: methodOptions }
143
559
  return this.call(path, args)
144
560
  }
145
561
  }