@foundation0/api 1.1.1 → 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/mcp/cli.mjs +1 -1
- package/mcp/cli.ts +3 -3
- package/mcp/client.test.ts +13 -0
- package/mcp/client.ts +12 -4
- package/mcp/server.test.ts +133 -0
- package/mcp/server.ts +1344 -107
- package/package.json +1 -1
- package/projects.ts +1189 -0
package/mcp/cli.mjs
CHANGED
package/mcp/cli.ts
CHANGED
|
@@ -101,6 +101,6 @@ void runExampleMcpServer({
|
|
|
101
101
|
enableIssues,
|
|
102
102
|
admin,
|
|
103
103
|
}).catch((error) => {
|
|
104
|
-
console.error('Failed to start
|
|
105
|
-
process.exit(1)
|
|
106
|
-
})
|
|
104
|
+
console.error('Failed to start f0-mcp server', error)
|
|
105
|
+
process.exit(1)
|
|
106
|
+
})
|
package/mcp/client.test.ts
CHANGED
|
@@ -126,4 +126,17 @@ describe('ExampleMcpClient git file guard', () => {
|
|
|
126
126
|
expect(response.isError).toBe(false)
|
|
127
127
|
expect(response.data).toEqual({ ok: true, body: { result: 'pass-through' } })
|
|
128
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
|
+
})
|
|
129
142
|
})
|
package/mcp/client.ts
CHANGED
|
@@ -151,11 +151,19 @@ const decodeFileContent = (content: string, encoding: string): string => {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
const extractGitFileContent = (data: unknown): ExtractedGitFileContent | null => {
|
|
154
|
-
|
|
154
|
+
const unwrapped = (() => {
|
|
155
|
+
if (!isRecord(data)) return data
|
|
156
|
+
if (typeof data.ok === 'boolean' && 'result' in data) {
|
|
157
|
+
return (data as any).result
|
|
158
|
+
}
|
|
159
|
+
return data
|
|
160
|
+
})()
|
|
161
|
+
|
|
162
|
+
if (!isRecord(unwrapped) || !isRecord(unwrapped.body)) {
|
|
155
163
|
return null
|
|
156
164
|
}
|
|
157
165
|
|
|
158
|
-
const body =
|
|
166
|
+
const body = unwrapped.body
|
|
159
167
|
if (typeof body.content !== 'string') {
|
|
160
168
|
return null
|
|
161
169
|
}
|
|
@@ -366,7 +374,7 @@ export class ExampleMcpClient {
|
|
|
366
374
|
return {
|
|
367
375
|
isError: Boolean(response.isError),
|
|
368
376
|
text: parsed.text,
|
|
369
|
-
data: parsed.parsed,
|
|
377
|
+
data: parsed.parsed ?? parsed.text,
|
|
370
378
|
}
|
|
371
379
|
}
|
|
372
380
|
|
|
@@ -547,7 +555,7 @@ export class ExampleMcpClient {
|
|
|
547
555
|
methodArgs: unknown[] = [],
|
|
548
556
|
methodOptions: Record<string, unknown> = {},
|
|
549
557
|
): Promise<ExampleMcpCallResponse> {
|
|
550
|
-
const args = { args: methodArgs
|
|
558
|
+
const args = { args: methodArgs, options: methodOptions }
|
|
551
559
|
return this.call(path, args)
|
|
552
560
|
}
|
|
553
561
|
}
|
package/mcp/server.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { 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'
|
|
2
5
|
import { createExampleMcpServer } from './server'
|
|
3
6
|
|
|
4
7
|
describe('createExampleMcpServer endpoint whitelist', () => {
|
|
@@ -115,3 +118,133 @@ describe('createExampleMcpServer endpoint whitelist', () => {
|
|
|
115
118
|
expect(adminNames).toContain('projects.clearIssues')
|
|
116
119
|
})
|
|
117
120
|
})
|
|
121
|
+
|
|
122
|
+
describe('createExampleMcpServer request handling', () => {
|
|
123
|
+
const getToolHandler = (instance: ReturnType<typeof createExampleMcpServer>) => {
|
|
124
|
+
const handler = (instance.server as any)._requestHandlers?.get('tools/call')
|
|
125
|
+
if (!handler) {
|
|
126
|
+
throw new Error('tools/call handler not registered')
|
|
127
|
+
}
|
|
128
|
+
return handler as (request: any, extra: any) => Promise<any>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('passes structured args without stringifying', async () => {
|
|
132
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'f0-mcp-server-test-'))
|
|
133
|
+
try {
|
|
134
|
+
await fs.writeFile(path.join(tempDir, 'boot.v0.0.1.md'), '# boot\n', 'utf8')
|
|
135
|
+
const instance = createExampleMcpServer()
|
|
136
|
+
const handler = getToolHandler(instance)
|
|
137
|
+
|
|
138
|
+
const result = await handler({
|
|
139
|
+
method: 'tools/call',
|
|
140
|
+
params: {
|
|
141
|
+
name: 'agents.resolveTargetFile',
|
|
142
|
+
arguments: {
|
|
143
|
+
args: [
|
|
144
|
+
tempDir,
|
|
145
|
+
{ parts: [], base: 'boot', version: 'v0.0.1', ext: 'md' },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}, {})
|
|
150
|
+
|
|
151
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? '{}')
|
|
152
|
+
expect(payload.ok).toBe(true)
|
|
153
|
+
expect(payload.result).toBe('boot.v0.0.1.md')
|
|
154
|
+
} finally {
|
|
155
|
+
await fs.rm(tempDir, { recursive: true, force: true })
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('parses continueOnError from string "false" (fails fast)', async () => {
|
|
160
|
+
const instance = createExampleMcpServer()
|
|
161
|
+
const handler = getToolHandler(instance)
|
|
162
|
+
|
|
163
|
+
const result = await handler({
|
|
164
|
+
method: 'tools/call',
|
|
165
|
+
params: {
|
|
166
|
+
name: 'batch',
|
|
167
|
+
arguments: {
|
|
168
|
+
calls: [
|
|
169
|
+
{ tool: 'projects.usage' },
|
|
170
|
+
{ tool: 'unknown.tool' },
|
|
171
|
+
],
|
|
172
|
+
continueOnError: 'false',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
}, {})
|
|
176
|
+
|
|
177
|
+
expect(result.isError).toBe(true)
|
|
178
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? '{}')
|
|
179
|
+
expect(payload.ok).toBe(false)
|
|
180
|
+
expect(payload.error?.message).toContain('Unknown tool')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('parses continueOnError from string "true" (returns per-call errors)', async () => {
|
|
184
|
+
const instance = createExampleMcpServer()
|
|
185
|
+
const handler = getToolHandler(instance)
|
|
186
|
+
|
|
187
|
+
const result = await handler({
|
|
188
|
+
method: 'tools/call',
|
|
189
|
+
params: {
|
|
190
|
+
name: 'batch',
|
|
191
|
+
arguments: {
|
|
192
|
+
calls: [
|
|
193
|
+
{ tool: 'projects.usage' },
|
|
194
|
+
{ tool: 'unknown.tool' },
|
|
195
|
+
],
|
|
196
|
+
continueOnError: 'true',
|
|
197
|
+
maxConcurrency: 2,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
}, {})
|
|
201
|
+
|
|
202
|
+
expect(result.isError).toBe(true)
|
|
203
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? '{}')
|
|
204
|
+
expect(payload.ok).toBe(false)
|
|
205
|
+
expect(payload.error?.message).toContain('batch calls failed')
|
|
206
|
+
expect(Array.isArray(payload.error?.details?.results)).toBe(true)
|
|
207
|
+
expect(payload.error.details.results[1].isError).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('accepts unprefixed tool name when toolsPrefix is set', async () => {
|
|
211
|
+
const instance = createExampleMcpServer({ toolsPrefix: 'api' })
|
|
212
|
+
const handler = getToolHandler(instance)
|
|
213
|
+
|
|
214
|
+
const result = await handler({
|
|
215
|
+
method: 'tools/call',
|
|
216
|
+
params: {
|
|
217
|
+
name: 'projects.usage',
|
|
218
|
+
arguments: {},
|
|
219
|
+
},
|
|
220
|
+
}, {})
|
|
221
|
+
|
|
222
|
+
expect(result.isError).toBe(false)
|
|
223
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? '{}')
|
|
224
|
+
expect(payload.ok).toBe(true)
|
|
225
|
+
expect(typeof payload.result).toBe('string')
|
|
226
|
+
expect(payload.result).toContain('Usage')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('exposes mcp.describeTool for tool discovery', async () => {
|
|
230
|
+
const instance = createExampleMcpServer({ toolsPrefix: 'api' })
|
|
231
|
+
const handler = getToolHandler(instance)
|
|
232
|
+
|
|
233
|
+
const result = await handler({
|
|
234
|
+
method: 'tools/call',
|
|
235
|
+
params: {
|
|
236
|
+
name: 'mcp.describeTool',
|
|
237
|
+
arguments: {
|
|
238
|
+
args: ['projects.usage'],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
}, {})
|
|
242
|
+
|
|
243
|
+
expect(result.isError).toBe(false)
|
|
244
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? '{}')
|
|
245
|
+
expect(payload.ok).toBe(true)
|
|
246
|
+
expect(payload.result.name).toBe('api.projects.usage')
|
|
247
|
+
expect(payload.result.unprefixedName).toBe('projects.usage')
|
|
248
|
+
expect(payload.result.access).toBe('read')
|
|
249
|
+
})
|
|
250
|
+
})
|