@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/cli.ts CHANGED
@@ -1,28 +1,78 @@
1
- import { runF0McpServer } from './server'
2
-
3
- const getArgValue = (name: string, fallback?: string): string | undefined => {
4
- const exactArg = process.argv.find((arg) => arg.startsWith(`${name}=`))
5
- if (exactArg) {
6
- const [, value] = exactArg.split('=', 2)
7
- return value
1
+ import { runExampleMcpServer } from './server'
2
+
3
+ const getArgValue = (name: string, fallback?: string): string | undefined => {
4
+ const exactArg = process.argv.find((arg) => arg.startsWith(`${name}=`))
5
+ if (exactArg) {
6
+ const [, value] = exactArg.split('=', 2)
7
+ return value
8
+ }
9
+
10
+ const index = process.argv.indexOf(name)
11
+ if (index >= 0 && index + 1 < process.argv.length) {
12
+ const next = process.argv[index + 1]
13
+ if (!next.startsWith('--')) {
14
+ return next
15
+ }
16
+ }
17
+
18
+ return fallback
19
+ }
20
+
21
+ const hasFlag = (name: string): boolean => process.argv.includes(name)
22
+ const parseBooleanLiteral = (value: string | undefined): boolean | undefined => {
23
+ if (value === undefined) return undefined
24
+ const normalized = value.trim().toLowerCase()
25
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') {
26
+ return true
8
27
  }
9
-
28
+ if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') {
29
+ return false
30
+ }
31
+ return undefined
32
+ }
33
+ const resolveBooleanFlag = (name: string, envValue: string | undefined): boolean => {
10
34
  const index = process.argv.indexOf(name)
11
- if (index >= 0 && index + 1 < process.argv.length) {
35
+ if (index >= 0) {
12
36
  const next = process.argv[index + 1]
13
- if (!next.startsWith('--')) {
14
- return next
37
+ if (!next || next.startsWith('--')) {
38
+ return true
39
+ }
40
+ const parsedNext = parseBooleanLiteral(next)
41
+ if (parsedNext === undefined) {
42
+ throw new Error(`Invalid boolean value for ${name}: ${next}`)
15
43
  }
44
+ return parsedNext
16
45
  }
17
46
 
18
- return fallback
19
- }
47
+ const exactArg = process.argv.find((arg) => arg.startsWith(`${name}=`))
48
+ if (exactArg) {
49
+ const [, raw] = exactArg.split('=', 2)
50
+ const parsed = parseBooleanLiteral(raw)
51
+ if (parsed === undefined) {
52
+ throw new Error(`Invalid boolean value for ${name}: ${raw}`)
53
+ }
54
+ return parsed
55
+ }
20
56
 
21
- const hasFlag = (name: string): boolean => process.argv.includes(name)
57
+ return parseBooleanLiteral(envValue) ?? false
58
+ }
59
+ const parseListArg = (value: string | undefined): string[] => {
60
+ if (!value) return []
61
+ return value
62
+ .split(',')
63
+ .map((entry) => entry.trim())
64
+ .filter((entry) => entry.length > 0)
65
+ }
22
66
 
23
67
  const serverName = getArgValue('--server-name', 'f0-mcp')
24
68
  const serverVersion = getArgValue('--server-version', '1.0.0')
25
69
  const toolsPrefix = getArgValue('--tools-prefix') ?? process.env.MCP_TOOLS_PREFIX
70
+ const allowedRootEndpoints = parseListArg(
71
+ getArgValue('--allowed-root-endpoints') ?? process.env.MCP_ALLOWED_ROOT_ENDPOINTS,
72
+ )
73
+ const disableWrite = resolveBooleanFlag('--disable-write', process.env.MCP_DISABLE_WRITE)
74
+ const enableIssues = resolveBooleanFlag('--enable-issues', process.env.MCP_ENABLE_ISSUES)
75
+ const admin = resolveBooleanFlag('--admin', process.env.MCP_ADMIN)
26
76
 
27
77
  if (hasFlag('--help') || hasFlag('-h')) {
28
78
  console.log('Usage: f0-mcp [--tools-prefix=api]')
@@ -30,14 +80,27 @@ if (hasFlag('--help') || hasFlag('-h')) {
30
80
  console.log(' --server-name <name>')
31
81
  console.log(' --server-version <version>')
32
82
  console.log(' --tools-prefix <prefix>')
83
+ console.log(' --allowed-root-endpoints <csv>')
84
+ console.log(' Example: --allowed-root-endpoints projects')
85
+ console.log(' Example: --allowed-root-endpoints agents,projects')
86
+ console.log(' --disable-write')
87
+ console.log(' Expose read-only tools and disable write-capable endpoints.')
88
+ console.log(' --enable-issues')
89
+ console.log(' With --disable-write, re-enable issue read/write endpoints.')
90
+ console.log(' --admin')
91
+ console.log(' Expose admin-only destructive endpoints (syncTasks, clearIssues).')
33
92
  process.exit(0)
34
93
  }
35
94
 
36
- void runF0McpServer({
95
+ void runExampleMcpServer({
37
96
  serverName: serverName ?? undefined,
38
97
  serverVersion: serverVersion ?? undefined,
39
98
  toolsPrefix,
99
+ allowedRootEndpoints,
100
+ disableWrite,
101
+ enableIssues,
102
+ admin,
40
103
  }).catch((error) => {
41
- console.error('Failed to start F0 MCP server', error)
42
- process.exit(1)
43
- })
104
+ console.error('Failed to start example-org MCP server', error)
105
+ process.exit(1)
106
+ })
@@ -0,0 +1,129 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import fs from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { ExampleMcpClient } from './client'
6
+
7
+ type RawToolResponse = {
8
+ isError?: boolean
9
+ content: Array<{ type: 'text'; text: string }>
10
+ }
11
+
12
+ const toRawToolResponse = (data: unknown, isError = false): RawToolResponse => ({
13
+ isError,
14
+ content: [
15
+ {
16
+ type: 'text',
17
+ text: JSON.stringify(data),
18
+ },
19
+ ],
20
+ })
21
+
22
+ const toGitFileToolData = (content: string) => ({
23
+ ok: true,
24
+ status: 200,
25
+ body: {
26
+ path: 'README.md',
27
+ type: 'file',
28
+ encoding: 'base64',
29
+ size: Buffer.byteLength(content, 'utf8'),
30
+ content: Buffer.from(content, 'utf8').toString('base64'),
31
+ },
32
+ })
33
+
34
+ describe('ExampleMcpClient git file guard', () => {
35
+ let cacheDir: string
36
+ let client: ExampleMcpClient
37
+ let capturedRequests: unknown[]
38
+
39
+ beforeEach(async () => {
40
+ cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'f0-mcp-client-test-'))
41
+ capturedRequests = []
42
+ client = new ExampleMcpClient({
43
+ maxInlineFileLines: 3,
44
+ fileCacheDir: cacheDir,
45
+ requestTimeoutMs: 1_000,
46
+ })
47
+ })
48
+
49
+ afterEach(async () => {
50
+ await fs.rm(cacheDir, { recursive: true, force: true })
51
+ })
52
+
53
+ const stubRequests = (handler: (request: any) => RawToolResponse): void => {
54
+ const internalClient = (client as any).client
55
+ internalClient.request = async (request: any) => {
56
+ capturedRequests.push(request)
57
+ return handler(request)
58
+ }
59
+ }
60
+
61
+ it('blocks large file responses unless allowLargeSize is set', async () => {
62
+ const content = ['line 1', 'line 2', 'line 3', 'line 4'].join('\n')
63
+ stubRequests(() => toRawToolResponse(toGitFileToolData(content)))
64
+
65
+ const response = await client.call('repo.contents.view', {
66
+ args: ['owner', 'repo', 'README.md'],
67
+ })
68
+
69
+ expect(response.isError).toBe(true)
70
+ expect((response.data as any).hint).toContain('allowLargeSize')
71
+ expect((response.data as any).stats.lines).toBe(4)
72
+ })
73
+
74
+ it('allows large file responses when allowLargeSize=true and strips client-only options', async () => {
75
+ const content = ['line 1', 'line 2', 'line 3', 'line 4'].join('\n')
76
+ stubRequests(() => toRawToolResponse(toGitFileToolData(content)))
77
+
78
+ const response = await client.call('repo.contents.view', {
79
+ args: ['owner', 'repo', 'README.md'],
80
+ options: {
81
+ allowLargeSize: true,
82
+ },
83
+ })
84
+
85
+ expect(response.isError).toBe(false)
86
+ expect((response.data as any).body.path).toBe('README.md')
87
+ expect((capturedRequests[0] as any).params.arguments.options).toBeUndefined()
88
+ })
89
+
90
+ it('downloads once and serves line-by-line reads from local cache', async () => {
91
+ const content = ['line 1', 'line 2', 'line 3', 'line 4'].join('\n')
92
+ stubRequests(() => toRawToolResponse(toGitFileToolData(content)))
93
+
94
+ const first = await client.call('repo.contents.view', {
95
+ args: ['owner', 'repo', 'README.md'],
96
+ options: {
97
+ line: 2,
98
+ },
99
+ })
100
+
101
+ expect(first.isError).toBe(false)
102
+ expect((first.data as any).lines).toEqual([{ line: 2, text: 'line 2' }])
103
+ expect(capturedRequests.length).toBe(1)
104
+
105
+ const second = await client.call('repo.contents.view', {
106
+ args: ['owner', 'repo', 'README.md'],
107
+ options: {
108
+ line: 3,
109
+ },
110
+ })
111
+
112
+ expect(second.isError).toBe(false)
113
+ expect((second.data as any).lines).toEqual([{ line: 3, text: 'line 3' }])
114
+ expect(capturedRequests.length).toBe(1)
115
+
116
+ const cacheEntries = await fs.readdir(cacheDir)
117
+ expect(cacheEntries.some((entry) => entry.endsWith('.txt'))).toBe(true)
118
+ expect(cacheEntries.some((entry) => entry.endsWith('.json'))).toBe(true)
119
+ })
120
+
121
+ it('keeps non-file tool calls unchanged', async () => {
122
+ stubRequests(() => toRawToolResponse({ ok: true, body: { result: 'pass-through' } }))
123
+
124
+ const response = await client.call('projects.listProjects')
125
+
126
+ expect(response.isError).toBe(false)
127
+ expect(response.data).toEqual({ ok: true, body: { result: 'pass-through' } })
128
+ })
129
+ })