@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/README.md +24 -9
- package/agents.ts +431 -6
- package/git.ts +12 -23
- package/mcp/cli.mjs +1 -1
- package/mcp/cli.ts +81 -18
- package/mcp/client.test.ts +129 -0
- package/mcp/client.ts +417 -9
- package/mcp/index.ts +9 -9
- package/mcp/server.test.ts +117 -0
- package/mcp/server.ts +71 -10
- package/package.json +7 -9
- package/projects.ts +11 -11
- package/dist/git.js +0 -4
package/mcp/cli.ts
CHANGED
|
@@ -1,28 +1,78 @@
|
|
|
1
|
-
import {
|
|
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
|
|
35
|
+
if (index >= 0) {
|
|
12
36
|
const next = process.argv[index + 1]
|
|
13
|
-
if (!next.startsWith('--')) {
|
|
14
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
})
|