@foundation0/api 1.1.0 → 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 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.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) => {
96
+ serverName: serverName ?? undefined,
97
+ serverVersion: serverVersion ?? undefined,
98
+ toolsPrefix,
99
+ allowedRootEndpoints,
100
+ disableWrite,
101
+ enableIssues,
102
+ admin,
103
+ }).catch((error) => {
41
104
  console.error('Failed to start example-org MCP server', error)
42
105
  process.exit(1)
43
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
+ })
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) {
@@ -44,6 +228,8 @@ 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
235
  export interface ExampleMcpCallArgs {
@@ -61,10 +247,14 @@ export type ExampleMcpCallResponse = {
61
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
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 ExampleMcpClient {
110
300
  return response.tools.map((tool) => tool.name)
111
301
  }
112
302
 
113
- public async call(toolName: string, args: ExampleMcpCallArgs = {}): Promise<ExampleMcpCallResponse> {
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,6 +370,178 @@ export class ExampleMcpClient {
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[] = [],
@@ -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
+ })
package/mcp/server.ts CHANGED
@@ -312,6 +312,10 @@ export interface ExampleMcpServerOptions {
312
312
  serverName?: string
313
313
  serverVersion?: string
314
314
  toolsPrefix?: string
315
+ allowedRootEndpoints?: string[]
316
+ disableWrite?: boolean
317
+ enableIssues?: boolean
318
+ admin?: boolean
315
319
  }
316
320
 
317
321
  export type ExampleMcpServerInstance = {
@@ -321,16 +325,73 @@ export type ExampleMcpServerInstance = {
321
325
  run: () => Promise<Server>
322
326
  }
323
327
 
328
+ const READ_ONLY_TOOL_NAMES = new Set([
329
+ 'agents.usage',
330
+ 'agents.resolveAgentsRoot',
331
+ 'agents.resolveAgentsRootFrom',
332
+ 'agents.listAgents',
333
+ 'agents.parseTargetSpec',
334
+ 'agents.resolveTargetFile',
335
+ 'agents.loadAgent',
336
+ 'agents.loadAgentPrompt',
337
+ 'projects.usage',
338
+ 'projects.resolveProjectRoot',
339
+ 'projects.listProjects',
340
+ 'projects.listProjectDocs',
341
+ 'projects.readProjectDoc',
342
+ 'projects.resolveImplementationPlan',
343
+ 'projects.fetchGitTasks',
344
+ 'projects.readGitTask',
345
+ 'projects.parseProjectTargetSpec',
346
+ 'projects.resolveProjectTargetFile',
347
+ ])
348
+
349
+ const isWriteCapableTool = (toolName: string): boolean => !READ_ONLY_TOOL_NAMES.has(toolName)
350
+ const ISSUE_TOOL_NAMES = new Set([
351
+ 'projects.fetchGitTasks',
352
+ 'projects.readGitTask',
353
+ 'projects.writeGitTask',
354
+ 'projects.syncTasks',
355
+ 'projects.clearIssues',
356
+ ])
357
+ const isIssueTool = (toolName: string): boolean => ISSUE_TOOL_NAMES.has(toolName)
358
+ const ADMIN_TOOL_NAMES = new Set([
359
+ 'projects.syncTasks',
360
+ 'projects.clearIssues',
361
+ ])
362
+ const isAdminTool = (toolName: string): boolean => ADMIN_TOOL_NAMES.has(toolName)
363
+
324
364
  export const createExampleMcpServer = (options: ExampleMcpServerOptions = {}): ExampleMcpServerInstance => {
325
365
  const api: ApiEndpoint = {
326
366
  agents: agentsApi,
327
367
  projects: projectsApi,
328
368
  }
329
369
 
330
- const tools = [
331
- ...collectTools(api.agents, ['agents']),
332
- ...collectTools(api.projects, ['projects']),
333
- ]
370
+ const allRoots = Object.keys(api) as Array<keyof ApiEndpoint>
371
+ const selectedRoots = (() => {
372
+ if (!options.allowedRootEndpoints || options.allowedRootEndpoints.length === 0) {
373
+ return allRoots
374
+ }
375
+
376
+ const normalized = options.allowedRootEndpoints
377
+ .map((value) => value.trim())
378
+ .filter((value) => value.length > 0)
379
+
380
+ const unknown = normalized.filter((value) => !allRoots.includes(value as keyof ApiEndpoint))
381
+ if (unknown.length > 0) {
382
+ throw new Error(`Unknown root endpoints: ${unknown.join(', ')}. Allowed: ${allRoots.join(', ')}`)
383
+ }
384
+
385
+ return [...new Set(normalized)] as Array<keyof ApiEndpoint>
386
+ })()
387
+
388
+ const selectedTools = selectedRoots.flatMap((root) => collectTools(api[root], [root]))
389
+ const adminFilteredTools = Boolean(options.admin)
390
+ ? selectedTools
391
+ : selectedTools.filter((tool) => !isAdminTool(tool.name))
392
+ const tools = options.disableWrite
393
+ ? adminFilteredTools.filter((tool) => !isWriteCapableTool(tool.name) || (Boolean(options.enableIssues) && isIssueTool(tool.name)))
394
+ : adminFilteredTools
334
395
  const prefix = options.toolsPrefix
335
396
  const batchToolName = prefix ? `${prefix}.batch` : 'batch'
336
397
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/api",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Foundation 0 API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,8 +34,8 @@
34
34
  "mcp": "bun run mcp/cli.ts",
35
35
  "test": "echo \"Error: no test specified\" && exit 1",
36
36
  "deploy": "pnpm publish --access public",
37
- "version:patch": "pnpm version patch",
38
- "version:minor": "pnpm version minor",
39
- "version:major": "pnpm version major"
37
+ "version:patch": "pnpm version patch && git commit -am \"Bump version to %s\"",
38
+ "version:minor": "pnpm version minor && git commit -am \"Bump version to %s\"",
39
+ "version:major": "pnpm version major && git commit -am \"Bump version to %s\""
40
40
  }
41
41
  }