@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/README.md +15 -0
- package/agents.ts +2 -2
- package/git.ts +8 -2
- package/mcp/cli.mjs +1 -1
- package/mcp/cli.ts +82 -19
- package/mcp/client.test.ts +142 -0
- package/mcp/client.ts +419 -3
- package/mcp/server.test.ts +250 -0
- package/mcp/server.ts +1406 -108
- package/package.json +4 -4
- package/projects.ts +1189 -0
package/README.md
CHANGED
|
@@ -26,6 +26,10 @@ The server supports:
|
|
|
26
26
|
- `FALLBACK_GITEA_HOST` (fallback host variable)
|
|
27
27
|
- `GITEA_TOKEN` (for authenticated actions)
|
|
28
28
|
- `MCP_TOOLS_PREFIX` (or `--tools-prefix`) to namespace tools in clients
|
|
29
|
+
- `MCP_ALLOWED_ROOT_ENDPOINTS` (or `--allowed-root-endpoints`) to whitelist API root endpoints
|
|
30
|
+
- `MCP_DISABLE_WRITE` (or `--disable-write`) to expose read-only tools only
|
|
31
|
+
- `MCP_ENABLE_ISSUES` (or `--enable-issues`) to allow issue endpoints when write mode is disabled
|
|
32
|
+
- `MCP_ADMIN` (or `--admin`) to expose admin-only destructive endpoints
|
|
29
33
|
|
|
30
34
|
Useful defaults:
|
|
31
35
|
|
|
@@ -107,7 +111,18 @@ f0-mcp --help
|
|
|
107
111
|
# examples
|
|
108
112
|
f0-mcp --tools-prefix=example --server-name=my-example
|
|
109
113
|
f0-mcp --tools-prefix api --server-version 1.2.3
|
|
114
|
+
f0-mcp --allowed-root-endpoints projects
|
|
115
|
+
f0-mcp --allowed-root-endpoints agents,projects
|
|
116
|
+
f0-mcp --disable-write
|
|
117
|
+
f0-mcp --allowed-root-endpoints projects --disable-write
|
|
118
|
+
f0-mcp --disable-write --enable-issues
|
|
119
|
+
f0-mcp --admin
|
|
120
|
+
f0-mcp --admin --disable-write --enable-issues
|
|
110
121
|
```
|
|
111
122
|
|
|
112
123
|
- `--tools-prefix` is useful when running multiple MCP servers side-by-side.
|
|
113
124
|
- `--server-name` and `--server-version` are mostly metadata but can help identify logs and client tool sets.
|
|
125
|
+
- `--allowed-root-endpoints` restricts exposed tools to selected root namespaces (`agents`, `projects`).
|
|
126
|
+
- `--disable-write` removes write-capable tools (for example create/update/delete/sync/set/main/run operations).
|
|
127
|
+
- `--enable-issues` is a special-case override for `--disable-write`: issue endpoints remain enabled (`fetchGitTasks`, `readGitTask`, `writeGitTask`).
|
|
128
|
+
- `projects.syncTasks` and `projects.clearIssues` are admin-only and hidden by default; they are exposed only with `--admin` (or `MCP_ADMIN=true`).
|
package/agents.ts
CHANGED
|
@@ -864,7 +864,7 @@ export async function main(argv: string[], processRoot: string = process.cwd()):
|
|
|
864
864
|
|
|
865
865
|
const agentName = maybeAgentOrFlag
|
|
866
866
|
const target = maybeTarget
|
|
867
|
-
const
|
|
867
|
+
const setActiveRequested = rest.includes('--set-active')
|
|
868
868
|
const useLatest = rest.includes('--latest')
|
|
869
869
|
|
|
870
870
|
if (target === 'load') {
|
|
@@ -887,7 +887,7 @@ export async function main(argv: string[], processRoot: string = process.cwd()):
|
|
|
887
887
|
throw new Error('Missing required arguments: <agent-name> and <file-path>.')
|
|
888
888
|
}
|
|
889
889
|
|
|
890
|
-
if (!
|
|
890
|
+
if (!setActiveRequested) {
|
|
891
891
|
throw new Error('`--set-active` is required for this operation.')
|
|
892
892
|
}
|
|
893
893
|
|
package/git.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
export type {
|
|
2
|
+
GitLabelManagementApi,
|
|
3
|
+
GitLabelManagementDefaults,
|
|
4
|
+
GitRepositoryLabel,
|
|
2
5
|
GitServiceApi,
|
|
3
6
|
GitServiceApiExecutionResult,
|
|
4
7
|
GitServiceApiMethod,
|
|
5
|
-
} from '
|
|
8
|
+
} from '../git/packages/git/src/index.ts'
|
|
6
9
|
|
|
7
10
|
export {
|
|
11
|
+
attachGitLabelManagementApi,
|
|
8
12
|
buildGitApiMockResponse,
|
|
9
13
|
callIssueDependenciesApi,
|
|
14
|
+
createGitLabelManagementApi,
|
|
10
15
|
createGitServiceApi,
|
|
16
|
+
extractRepositoryLabels,
|
|
11
17
|
extractDependencyIssueNumbers,
|
|
12
18
|
resolveProjectRepoIdentity,
|
|
13
19
|
syncIssueDependencies,
|
|
14
|
-
} from '
|
|
20
|
+
} from '../git/packages/git/src/index.ts'
|
package/mcp/cli.mjs
CHANGED
package/mcp/cli.ts
CHANGED
|
@@ -18,26 +18,89 @@ const getArgValue = (name: string, fallback?: string): string | undefined => {
|
|
|
18
18
|
return fallback
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const hasFlag = (name: string): boolean => process.argv.includes(name)
|
|
22
|
-
|
|
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
|
|
27
|
+
}
|
|
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 => {
|
|
34
|
+
const index = process.argv.indexOf(name)
|
|
35
|
+
if (index >= 0) {
|
|
36
|
+
const next = process.argv[index + 1]
|
|
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}`)
|
|
43
|
+
}
|
|
44
|
+
return parsedNext
|
|
45
|
+
}
|
|
46
|
+
|
|
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
|
+
}
|
|
56
|
+
|
|
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
|
+
}
|
|
66
|
+
|
|
23
67
|
const serverName = getArgValue('--server-name', 'f0-mcp')
|
|
24
|
-
const serverVersion = getArgValue('--server-version', '1.0.0')
|
|
25
|
-
const toolsPrefix = getArgValue('--tools-prefix') ?? process.env.MCP_TOOLS_PREFIX
|
|
26
|
-
|
|
68
|
+
const serverVersion = getArgValue('--server-version', '1.0.0')
|
|
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)
|
|
76
|
+
|
|
27
77
|
if (hasFlag('--help') || hasFlag('-h')) {
|
|
28
78
|
console.log('Usage: f0-mcp [--tools-prefix=api]')
|
|
29
|
-
console.log('Optional flags:')
|
|
30
|
-
console.log(' --server-name <name>')
|
|
31
|
-
console.log(' --server-version <version>')
|
|
32
|
-
console.log(' --tools-prefix <prefix>')
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
79
|
+
console.log('Optional flags:')
|
|
80
|
+
console.log(' --server-name <name>')
|
|
81
|
+
console.log(' --server-version <version>')
|
|
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).')
|
|
92
|
+
process.exit(0)
|
|
93
|
+
}
|
|
94
|
+
|
|
36
95
|
void runExampleMcpServer({
|
|
37
|
-
serverName: serverName ?? undefined,
|
|
38
|
-
serverVersion: serverVersion ?? undefined,
|
|
39
|
-
toolsPrefix,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
96
|
+
serverName: serverName ?? undefined,
|
|
97
|
+
serverVersion: serverVersion ?? undefined,
|
|
98
|
+
toolsPrefix,
|
|
99
|
+
allowedRootEndpoints,
|
|
100
|
+
disableWrite,
|
|
101
|
+
enableIssues,
|
|
102
|
+
admin,
|
|
103
|
+
}).catch((error) => {
|
|
104
|
+
console.error('Failed to start f0-mcp server', error)
|
|
105
|
+
process.exit(1)
|
|
106
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
|
|
130
|
+
it('keeps plain-text tool responses as data when JSON parsing fails', async () => {
|
|
131
|
+
stubRequests(() => ({
|
|
132
|
+
isError: false,
|
|
133
|
+
content: [{ type: 'text', text: 'not-json' }],
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
const response = await client.call('projects.listProjects')
|
|
137
|
+
|
|
138
|
+
expect(response.isError).toBe(false)
|
|
139
|
+
expect(response.text).toBe('not-json')
|
|
140
|
+
expect(response.data).toBe('not-json')
|
|
141
|
+
})
|
|
142
|
+
})
|