@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 +15 -0
- package/agents.ts +2 -2
- package/git.ts +8 -2
- package/mcp/cli.ts +79 -16
- package/mcp/client.test.ts +129 -0
- package/mcp/client.ts +409 -1
- package/mcp/server.test.ts +117 -0
- package/mcp/server.ts +65 -4
- package/package.json +4 -4
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.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
|
-
|
|
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
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
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.
|
|
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
|
}
|