@foundation0/api 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,186 @@ 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
+ if (!isRecord(data) || !isRecord(data.body)) {
155
+ return null
156
+ }
157
+
158
+ const body = data.body
159
+ if (typeof body.content !== 'string') {
160
+ return null
161
+ }
162
+
163
+ const encoding = typeof body.encoding === 'string' ? body.encoding.toLowerCase() : ''
164
+ let content = ''
165
+ try {
166
+ content = decodeFileContent(body.content, encoding)
167
+ } catch {
168
+ return null
169
+ }
170
+ const lines = splitLines(content).length
171
+ const chars = content.length
172
+ const bytes = Buffer.byteLength(content, 'utf8')
173
+ const declaredBytes = typeof body.size === 'number'
174
+ ? body.size
175
+ : (typeof body.size === 'string' && body.size.trim() !== '' ? Number(body.size) : null)
176
+
177
+ return {
178
+ content,
179
+ stats: {
180
+ lines,
181
+ chars,
182
+ bytes,
183
+ declaredBytes: Number.isFinite(declaredBytes) ? Number(declaredBytes) : null,
184
+ },
185
+ }
186
+ }
187
+
188
+ const toResponseText = (value: unknown): string => {
189
+ if (typeof value === 'string') {
190
+ return value
191
+ }
192
+ return JSON.stringify(value, null, 2)
193
+ }
194
+
195
+ const buildResponse = (isError: boolean, data: unknown): ExampleMcpCallResponse => ({
196
+ isError,
197
+ text: toResponseText(data),
198
+ data,
199
+ })
16
200
 
17
201
  const toText = (result: ToolResult | null): string => {
18
202
  if (!result || !result.content || result.content.length === 0) {
@@ -40,31 +224,37 @@ const tryParseResult = (text: string): { parsed?: unknown; text: string } => {
40
224
  }
41
225
  }
42
226
 
43
- export interface F0McpClientOptions {
227
+ export interface ExampleMcpClientOptions {
44
228
  name?: string
45
229
  version?: string
46
230
  requestTimeoutMs?: number
231
+ maxInlineFileLines?: number
232
+ fileCacheDir?: string
47
233
  }
48
234
 
49
- export interface F0McpCallArgs {
235
+ export interface ExampleMcpCallArgs {
50
236
  args?: unknown[]
51
237
  options?: Record<string, unknown>
52
238
  [key: string]: unknown
53
239
  }
54
240
 
55
- export type F0McpCallResponse = {
241
+ export type ExampleMcpCallResponse = {
56
242
  isError: boolean
57
243
  text: string
58
244
  data: unknown
59
245
  }
60
246
 
61
- export class F0McpClient {
247
+ export class ExampleMcpClient {
62
248
  private readonly client: Client
63
249
  private readonly requestTimeoutMs: number
250
+ private readonly maxInlineFileLines: number
251
+ private readonly fileCacheDir: string
64
252
  private transport: StdioClientTransport | null = null
65
253
 
66
- public constructor(options: F0McpClientOptions = {}) {
254
+ public constructor(options: ExampleMcpClientOptions = {}) {
67
255
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
256
+ this.maxInlineFileLines = options.maxInlineFileLines ?? DEFAULT_MAX_INLINE_FILE_LINES
257
+ this.fileCacheDir = options.fileCacheDir ?? DEFAULT_FILE_CACHE_DIR
68
258
  this.client = new Client(
69
259
  {
70
260
  name: options.name ?? 'f0-mcp-client',
@@ -110,7 +300,53 @@ export class F0McpClient {
110
300
  return response.tools.map((tool) => tool.name)
111
301
  }
112
302
 
113
- public async call(toolName: string, args: F0McpCallArgs = {}): Promise<F0McpCallResponse> {
303
+ private parseCallPayload(args: ExampleMcpCallArgs): ParsedCallPayload {
304
+ const input = isRecord(args) ? args : {}
305
+ const options = isRecord(input.options) ? { ...input.options } : {}
306
+ const allowLargeSize = parseBoolean(input.allowLargeSize)
307
+ || parseBoolean(input.allowLargeFile)
308
+ || parseBoolean(input.authorizeLargeSize)
309
+ || parseBoolean(options.allowLargeSize)
310
+ || parseBoolean(options.allowLargeFile)
311
+ || parseBoolean(options.authorizeLargeSize)
312
+
313
+ const lineSelection = computeLineSelection(
314
+ input.line ?? options.line,
315
+ input.lineStart ?? options.lineStart,
316
+ input.lineEnd ?? options.lineEnd,
317
+ )
318
+
319
+ for (const key of CLIENT_FILE_OPTION_KEYS) {
320
+ delete options[key]
321
+ }
322
+
323
+ const payload: ExampleMcpCallArgs = {}
324
+ if (Array.isArray(input.args)) {
325
+ payload.args = [...input.args]
326
+ }
327
+ if (Object.keys(options).length > 0) {
328
+ payload.options = options
329
+ }
330
+
331
+ for (const [key, value] of Object.entries(input)) {
332
+ if (key === 'args' || key === 'options' || CLIENT_FILE_OPTION_KEYS.has(key)) {
333
+ continue
334
+ }
335
+ if (value !== undefined) {
336
+ payload[key] = value
337
+ }
338
+ }
339
+
340
+ return {
341
+ payload,
342
+ controls: {
343
+ allowLargeSize,
344
+ lineSelection,
345
+ },
346
+ }
347
+ }
348
+
349
+ private async requestTool(toolName: string, args: ExampleMcpCallArgs): Promise<ExampleMcpCallResponse> {
114
350
  const response = await this.client.request(
115
351
  {
116
352
  method: 'tools/call',
@@ -134,16 +370,188 @@ export class F0McpClient {
134
370
  }
135
371
  }
136
372
 
373
+ private extractGitFileIdentity(payload: ExampleMcpCallArgs): GitFileIdentity {
374
+ const args = Array.isArray(payload.args) ? payload.args : []
375
+ const options = isRecord(payload.options) ? payload.options : {}
376
+ const query = isRecord(options.query) ? options.query : {}
377
+
378
+ const owner = args.length > 0 ? String(args[0]) : null
379
+ const repo = args.length > 1 ? String(args[1]) : null
380
+ const filePath = args.length > 2 ? String(args[2]) : null
381
+ const refSource = options.ref ?? options.branch ?? options.sha ?? query.ref ?? query.branch ?? query.sha
382
+ const ref = refSource === undefined ? null : String(refSource)
383
+
384
+ return { owner, repo, filePath, ref }
385
+ }
386
+
387
+ private getCachePaths(cacheKey: string): { contentPath: string; metaPath: string } {
388
+ return {
389
+ contentPath: path.join(this.fileCacheDir, `${cacheKey}.txt`),
390
+ metaPath: path.join(this.fileCacheDir, `${cacheKey}.json`),
391
+ }
392
+ }
393
+
394
+ private async readCachedGitFile(cacheKey: string): Promise<CachedGitFile | null> {
395
+ const { contentPath, metaPath } = this.getCachePaths(cacheKey)
396
+ try {
397
+ const [content, metaRaw] = await Promise.all([
398
+ fs.readFile(contentPath, 'utf8'),
399
+ fs.readFile(metaPath, 'utf8'),
400
+ ])
401
+
402
+ const metaParsed = JSON.parse(metaRaw)
403
+ if (!isRecord(metaParsed)) {
404
+ return null
405
+ }
406
+
407
+ const statsRaw = isRecord(metaParsed.stats) ? metaParsed.stats : {}
408
+ const lines = parsePositiveInteger(statsRaw.lines) ?? splitLines(content).length
409
+ const chars = parsePositiveInteger(statsRaw.chars) ?? content.length
410
+ const bytes = parsePositiveInteger(statsRaw.bytes) ?? Buffer.byteLength(content, 'utf8')
411
+ const declaredBytesRaw = statsRaw.declaredBytes
412
+ const declaredBytes = typeof declaredBytesRaw === 'number'
413
+ ? declaredBytesRaw
414
+ : (typeof declaredBytesRaw === 'string' && declaredBytesRaw.trim() !== '' ? Number(declaredBytesRaw) : null)
415
+
416
+ return {
417
+ content,
418
+ stats: {
419
+ lines,
420
+ chars,
421
+ bytes,
422
+ declaredBytes: Number.isFinite(declaredBytes) ? Number(declaredBytes) : null,
423
+ },
424
+ cacheKey,
425
+ cachePath: contentPath,
426
+ }
427
+ } catch {
428
+ return null
429
+ }
430
+ }
431
+
432
+ private async writeCachedGitFile(cacheKey: string, content: string, stats: GitFileReadStats): Promise<CachedGitFile> {
433
+ await fs.mkdir(this.fileCacheDir, { recursive: true })
434
+ const { contentPath, metaPath } = this.getCachePaths(cacheKey)
435
+ await Promise.all([
436
+ fs.writeFile(contentPath, content, 'utf8'),
437
+ fs.writeFile(metaPath, JSON.stringify({ stats }, null, 2), 'utf8'),
438
+ ])
439
+
440
+ return {
441
+ content,
442
+ stats,
443
+ cacheKey,
444
+ cachePath: contentPath,
445
+ }
446
+ }
447
+
448
+ private buildLineReadResponse(cache: CachedGitFile, selection: Exclude<LineSelection, { kind: 'none' | 'invalid' }>): ExampleMcpCallResponse {
449
+ const allLines = splitLines(cache.content)
450
+ const start = selection.kind === 'single' ? selection.line : selection.start
451
+ const end = selection.kind === 'single' ? selection.line : selection.end
452
+
453
+ if (start > allLines.length) {
454
+ return buildResponse(true, {
455
+ error: 'Requested line is out of range.',
456
+ requested: { start, end },
457
+ totalLines: allLines.length,
458
+ cachePath: cache.cachePath,
459
+ })
460
+ }
461
+
462
+ const clampedEnd = Math.min(end, allLines.length)
463
+ const lines = []
464
+ for (let lineNo = start; lineNo <= clampedEnd; lineNo += 1) {
465
+ lines.push({
466
+ line: lineNo,
467
+ text: allLines[lineNo - 1] ?? '',
468
+ })
469
+ }
470
+
471
+ return buildResponse(false, {
472
+ mode: 'line',
473
+ requested: { start, end: clampedEnd },
474
+ totalLines: allLines.length,
475
+ cachePath: cache.cachePath,
476
+ stats: cache.stats,
477
+ lines,
478
+ })
479
+ }
480
+
481
+ public async call(toolName: string, args: ExampleMcpCallArgs = {}): Promise<ExampleMcpCallResponse> {
482
+ const { payload, controls } = this.parseCallPayload(args)
483
+
484
+ if (!isGitFileViewTool(toolName)) {
485
+ return this.requestTool(toolName, payload)
486
+ }
487
+
488
+ if (controls.lineSelection.kind === 'invalid') {
489
+ return buildResponse(true, {
490
+ error: controls.lineSelection.message,
491
+ hint: 'Use options.line=<positive-int> or options.lineStart/options.lineEnd for ranged reads.',
492
+ })
493
+ }
494
+
495
+ const identity = this.extractGitFileIdentity(payload)
496
+ const cacheKey = buildCacheKey(toolName, identity)
497
+ const requiresLineRead = controls.lineSelection.kind === 'single' || controls.lineSelection.kind === 'range'
498
+
499
+ if (requiresLineRead) {
500
+ const cached = await this.readCachedGitFile(cacheKey)
501
+ if (cached) {
502
+ return this.buildLineReadResponse(cached, controls.lineSelection)
503
+ }
504
+ }
505
+
506
+ const response = await this.requestTool(toolName, payload)
507
+ if (response.isError) {
508
+ return response
509
+ }
510
+
511
+ const extracted = extractGitFileContent(response.data)
512
+ if (!extracted) {
513
+ if (requiresLineRead) {
514
+ return buildResponse(true, {
515
+ error: 'Line-by-line mode requires a file response with textual content.',
516
+ hint: 'Use repo.contents.view for files. Directory listings are not line-readable.',
517
+ })
518
+ }
519
+ return response
520
+ }
521
+
522
+ if (!requiresLineRead) {
523
+ if (extracted.stats.lines > this.maxInlineFileLines && !controls.allowLargeSize) {
524
+ return buildResponse(true, {
525
+ error: 'Large file response blocked by client-side safety guard.',
526
+ hint: `File has ${extracted.stats.lines} lines; default limit is ${this.maxInlineFileLines}. Set options.allowLargeSize=true to allow full-file responses.`,
527
+ lineByLineHint: 'For safer reads, request options.line=<n> or options.lineStart=<a>, options.lineEnd=<b>.',
528
+ stats: extracted.stats,
529
+ file: identity,
530
+ optionExample: {
531
+ options: {
532
+ allowLargeSize: true,
533
+ },
534
+ },
535
+ })
536
+ }
537
+
538
+ return response
539
+ }
540
+
541
+ const cached = await this.writeCachedGitFile(cacheKey, extracted.content, extracted.stats)
542
+ return this.buildLineReadResponse(cached, controls.lineSelection)
543
+ }
544
+
137
545
  public async callByPath(
138
546
  path: string,
139
547
  methodArgs: unknown[] = [],
140
548
  methodOptions: Record<string, unknown> = {},
141
- ): Promise<F0McpCallResponse> {
549
+ ): Promise<ExampleMcpCallResponse> {
142
550
  const args = { args: methodArgs.map((value) => String(value)), options: methodOptions }
143
551
  return this.call(path, args)
144
552
  }
145
553
  }
146
554
 
147
- export const createF0McpClient = (options: F0McpClientOptions = {}): F0McpClient => {
148
- return new F0McpClient(options)
555
+ export const createExampleMcpClient = (options: ExampleMcpClientOptions = {}): ExampleMcpClient => {
556
+ return new ExampleMcpClient(options)
149
557
  }
package/mcp/index.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  export {
2
- createF0McpServer,
3
- runF0McpServer,
2
+ createExampleMcpServer,
3
+ runExampleMcpServer,
4
4
  normalizeToolCallNameForServer,
5
- type F0McpServerOptions,
6
- type F0McpServerInstance,
5
+ type ExampleMcpServerOptions,
6
+ type ExampleMcpServerInstance,
7
7
  } from './server'
8
8
 
9
9
  export {
10
- F0McpClient,
11
- createF0McpClient,
12
- type F0McpCallArgs,
13
- type F0McpCallResponse,
14
- type F0McpClientOptions,
10
+ ExampleMcpClient,
11
+ createExampleMcpClient,
12
+ type ExampleMcpCallArgs,
13
+ type ExampleMcpCallResponse,
14
+ type ExampleMcpClientOptions,
15
15
  } from './client'
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { createExampleMcpServer } from './server'
3
+
4
+ describe('createExampleMcpServer endpoint whitelist', () => {
5
+ it('exposes all root endpoints by default', () => {
6
+ const instance = createExampleMcpServer()
7
+ const names = instance.tools.map((tool) => tool.name)
8
+
9
+ expect(names.some((name) => name.startsWith('agents.'))).toBe(true)
10
+ expect(names.some((name) => name.startsWith('projects.'))).toBe(true)
11
+ expect(names).not.toContain('projects.syncTasks')
12
+ expect(names).not.toContain('projects.clearIssues')
13
+ })
14
+
15
+ it('filters tools to selected root endpoints', () => {
16
+ const instance = createExampleMcpServer({
17
+ allowedRootEndpoints: ['projects'],
18
+ })
19
+ const names = instance.tools.map((tool) => tool.name)
20
+
21
+ expect(names.length).toBeGreaterThan(0)
22
+ expect(names.every((name) => name.startsWith('projects.'))).toBe(true)
23
+ expect(names.some((name) => name.startsWith('agents.'))).toBe(false)
24
+ })
25
+
26
+ it('throws on unknown root endpoints', () => {
27
+ expect(() => createExampleMcpServer({
28
+ allowedRootEndpoints: ['unknown-root'],
29
+ })).toThrow('Unknown root endpoints')
30
+ })
31
+
32
+ it('disables write-capable tools when disableWrite=true', () => {
33
+ const instance = createExampleMcpServer({
34
+ disableWrite: true,
35
+ })
36
+ const names = instance.tools.map((tool) => tool.name)
37
+
38
+ expect(names).toContain('projects.readGitTask')
39
+ expect(names).toContain('projects.listProjects')
40
+ expect(names).toContain('agents.listAgents')
41
+ expect(names).not.toContain('projects.writeGitTask')
42
+ expect(names).not.toContain('projects.syncTasks')
43
+ expect(names).not.toContain('projects.clearIssues')
44
+ expect(names).not.toContain('projects.generateSpec')
45
+ expect(names).not.toContain('projects.setActive')
46
+ expect(names).not.toContain('projects.main')
47
+ expect(names).not.toContain('agents.createAgent')
48
+ expect(names).not.toContain('agents.setActive')
49
+ expect(names).not.toContain('agents.setActiveLink')
50
+ expect(names).not.toContain('agents.runAgent')
51
+ expect(names).not.toContain('agents.main')
52
+ })
53
+
54
+ it('applies disableWrite with root endpoint whitelist', () => {
55
+ const instance = createExampleMcpServer({
56
+ allowedRootEndpoints: ['projects'],
57
+ disableWrite: true,
58
+ })
59
+ const names = instance.tools.map((tool) => tool.name)
60
+
61
+ expect(names.length).toBeGreaterThan(0)
62
+ expect(names.every((name) => name.startsWith('projects.'))).toBe(true)
63
+ expect(names).toContain('projects.readGitTask')
64
+ expect(names).toContain('projects.fetchGitTasks')
65
+ expect(names).not.toContain('projects.writeGitTask')
66
+ expect(names).not.toContain('projects.syncTasks')
67
+ })
68
+
69
+ it('re-enables issue write endpoints when enableIssues=true with disableWrite', () => {
70
+ const instance = createExampleMcpServer({
71
+ disableWrite: true,
72
+ enableIssues: true,
73
+ })
74
+ const names = instance.tools.map((tool) => tool.name)
75
+
76
+ expect(names).toContain('projects.fetchGitTasks')
77
+ expect(names).toContain('projects.readGitTask')
78
+ expect(names).toContain('projects.writeGitTask')
79
+ expect(names).not.toContain('projects.syncTasks')
80
+ expect(names).not.toContain('projects.clearIssues')
81
+ expect(names).not.toContain('projects.generateSpec')
82
+ expect(names).not.toContain('projects.setActive')
83
+ expect(names).not.toContain('agents.createAgent')
84
+ expect(names).not.toContain('agents.runAgent')
85
+ })
86
+
87
+ it('exposes syncTasks and clearIssues only when admin=true', () => {
88
+ const defaultInstance = createExampleMcpServer()
89
+ const defaultNames = defaultInstance.tools.map((tool) => tool.name)
90
+ expect(defaultNames).not.toContain('projects.syncTasks')
91
+ expect(defaultNames).not.toContain('projects.clearIssues')
92
+
93
+ const adminInstance = createExampleMcpServer({ admin: true })
94
+ const adminNames = adminInstance.tools.map((tool) => tool.name)
95
+ expect(adminNames).toContain('projects.syncTasks')
96
+ expect(adminNames).toContain('projects.clearIssues')
97
+ })
98
+
99
+ it('requires admin along with enableIssues for destructive issue endpoints under disableWrite', () => {
100
+ const nonAdmin = createExampleMcpServer({
101
+ disableWrite: true,
102
+ enableIssues: true,
103
+ })
104
+ const nonAdminNames = nonAdmin.tools.map((tool) => tool.name)
105
+ expect(nonAdminNames).not.toContain('projects.syncTasks')
106
+ expect(nonAdminNames).not.toContain('projects.clearIssues')
107
+
108
+ const admin = createExampleMcpServer({
109
+ disableWrite: true,
110
+ enableIssues: true,
111
+ admin: true,
112
+ })
113
+ const adminNames = admin.tools.map((tool) => tool.name)
114
+ expect(adminNames).toContain('projects.syncTasks')
115
+ expect(adminNames).toContain('projects.clearIssues')
116
+ })
117
+ })