@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 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 setActive = rest.includes('--set-active')
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 (!setActive) {
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 '@foundation0/git'
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 '@foundation0/git'
20
+ } from '../git/packages/git/src/index.ts'
package/mcp/cli.mjs CHANGED
@@ -32,6 +32,6 @@ try {
32
32
  process.exit(1)
33
33
  }
34
34
 
35
- console.error('Failed to launch example-org MCP server', error)
35
+ console.error('Failed to launch f0-mcp server', error)
36
36
  process.exit(1)
37
37
  }
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
- process.exit(0)
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
- }).catch((error) => {
41
- console.error('Failed to start example-org MCP server', error)
42
- process.exit(1)
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
+ })