@vibe-forge/mcp 0.8.0
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/AGENTS.md +39 -0
- package/LICENSE +21 -0
- package/__tests__/mcp-test-utils.ts +82 -0
- package/__tests__/proxy.spec.ts +68 -0
- package/__tests__/task-manager.spec.ts +91 -0
- package/__tests__/tools.spec.ts +49 -0
- package/cli.js +3 -0
- package/package.json +47 -0
- package/src/cli.ts +15 -0
- package/src/command.ts +63 -0
- package/src/index.ts +14 -0
- package/src/package-config.ts +22 -0
- package/src/sync.ts +64 -0
- package/src/tools/general/wait.ts +22 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/interaction/ask-user.ts +63 -0
- package/src/tools/proxy.ts +63 -0
- package/src/tools/task/index.ts +172 -0
- package/src/tools/task/manager.ts +303 -0
- package/src/tools/types.ts +13 -0
- package/src/types.ts +23 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# MCP 包说明
|
|
2
|
+
|
|
3
|
+
`@vibe-forge/mcp` 提供独立的 MCP stdio server,以及 `vf-mcp` / `vibe-forge-mcp` 二进制入口。
|
|
4
|
+
|
|
5
|
+
## 什么时候先看这里
|
|
6
|
+
|
|
7
|
+
- `vf-mcp` 启动失败
|
|
8
|
+
- MCP tool 注册、过滤、分类行为异常
|
|
9
|
+
- `StartTasks` / `ListTasks` / `AskUserQuestion` 行为异常
|
|
10
|
+
- 想确认独立 MCP CLI 与 core / server 的依赖边界
|
|
11
|
+
|
|
12
|
+
## 入口
|
|
13
|
+
|
|
14
|
+
- `cli.js`
|
|
15
|
+
- 独立 CLI 壳,补齐工作区环境并转交给共享 loader
|
|
16
|
+
- `src/cli.ts`
|
|
17
|
+
- standalone `vf-mcp` commander 入口
|
|
18
|
+
- `src/command.ts`
|
|
19
|
+
- MCP options / stdio server 启动逻辑
|
|
20
|
+
- `src/tools/*`
|
|
21
|
+
- MCP tool 注册与实现
|
|
22
|
+
- `src/tools/task/manager.ts`
|
|
23
|
+
- MCP task tools 的运行时主逻辑
|
|
24
|
+
- 直接引用 `@vibe-forge/task`、`@vibe-forge/hooks`、`@vibe-forge/config` 和 `src/sync.ts`
|
|
25
|
+
- `src/sync.ts`
|
|
26
|
+
- 与 Vibe Forge server 的 session 同步 HTTP API
|
|
27
|
+
|
|
28
|
+
## 边界约定
|
|
29
|
+
|
|
30
|
+
- CLI loader 统一走 `@vibe-forge/cli-helper/loader`
|
|
31
|
+
- `@vibe-forge/mcp` 直接引用本包内需要的 task / hook / prompt / sync 模块,不再通过额外 bindings 装配层传递上下文
|
|
32
|
+
- `task` 类工具默认启用
|
|
33
|
+
- MCP tool 实现集中在本包
|
|
34
|
+
|
|
35
|
+
## 相关文档
|
|
36
|
+
|
|
37
|
+
- [架构说明](/Users/bytedance/projects/vibe-forge.ai/.ai/rules/docs/ARCHITECTURE.md)
|
|
38
|
+
- [使用文档](/Users/bytedance/projects/vibe-forge.ai/.ai/rules/docs/USAGE.md)
|
|
39
|
+
- [CLI 维护说明](/Users/bytedance/projects/vibe-forge.ai/apps/cli/src/AGENTS.md)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Vibe-Forge.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Register } from '#~/tools/types.js'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
type ToolHandler = (args: any) => Promise<any>
|
|
5
|
+
type RegisterServer = Parameters<Register>[0]
|
|
6
|
+
interface RegisterToolOptions {
|
|
7
|
+
inputSchema?: z.ZodTypeAny
|
|
8
|
+
title: string
|
|
9
|
+
description?: string
|
|
10
|
+
}
|
|
11
|
+
type RegisterToolHandler = (args: any) => Promise<any>
|
|
12
|
+
|
|
13
|
+
interface ToolEntry {
|
|
14
|
+
handler: ToolHandler
|
|
15
|
+
schema: z.ZodType<any>
|
|
16
|
+
title: string
|
|
17
|
+
description?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates a tool tester that can register and call tools, simulating the MCP server environment.
|
|
22
|
+
*/
|
|
23
|
+
export function createToolTester() {
|
|
24
|
+
const tools = new Map<string, ToolEntry>()
|
|
25
|
+
|
|
26
|
+
const mockRegister = {
|
|
27
|
+
registerTool: (name: string, options: RegisterToolOptions, handler: RegisterToolHandler) => {
|
|
28
|
+
tools.set(name, {
|
|
29
|
+
// @ts-ignore - The handler type from the SDK is complex with extra context
|
|
30
|
+
handler: handler as ToolHandler,
|
|
31
|
+
schema: options.inputSchema as z.ZodType<any>,
|
|
32
|
+
title: options.title,
|
|
33
|
+
description: options.description
|
|
34
|
+
})
|
|
35
|
+
return {
|
|
36
|
+
disable: () => {
|
|
37
|
+
// In a real server this would disable the tool
|
|
38
|
+
}
|
|
39
|
+
} as any
|
|
40
|
+
},
|
|
41
|
+
registerPrompt: () => {
|
|
42
|
+
return {
|
|
43
|
+
disable: () => {}
|
|
44
|
+
} as any
|
|
45
|
+
},
|
|
46
|
+
registerResource: () => {
|
|
47
|
+
return {
|
|
48
|
+
disable: () => {}
|
|
49
|
+
} as any
|
|
50
|
+
}
|
|
51
|
+
} as RegisterServer
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
mockRegister,
|
|
55
|
+
/**
|
|
56
|
+
* Calls a registered tool with the given arguments.
|
|
57
|
+
* Performs Zod validation before calling the handler.
|
|
58
|
+
*/
|
|
59
|
+
async callTool(name: string, args: unknown) {
|
|
60
|
+
const tool = tools.get(name)
|
|
61
|
+
if (!tool) {
|
|
62
|
+
throw new Error(`Tool "${name}" not found. Registered tools: ${Array.from(tools.keys()).join(', ')}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Simulate Zod validation that McpServer would do
|
|
66
|
+
const validatedArgs = tool.schema.parse(args)
|
|
67
|
+
return tool.handler(validatedArgs)
|
|
68
|
+
},
|
|
69
|
+
/**
|
|
70
|
+
* Gets information about a registered tool.
|
|
71
|
+
*/
|
|
72
|
+
getToolInfo(name: string) {
|
|
73
|
+
return tools.get(name)
|
|
74
|
+
},
|
|
75
|
+
/**
|
|
76
|
+
* Returns a list of all registered tool names.
|
|
77
|
+
*/
|
|
78
|
+
getRegisteredTools() {
|
|
79
|
+
return Array.from(tools.keys())
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { createFilteredRegister, shouldEnableCategory } from '#~/tools/proxy.js'
|
|
5
|
+
|
|
6
|
+
describe('proxy logic', () => {
|
|
7
|
+
describe('shouldEnableCategory', () => {
|
|
8
|
+
it('should enable when no filters', () => {
|
|
9
|
+
expect(shouldEnableCategory('cat1', {})).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('should respect include', () => {
|
|
13
|
+
expect(shouldEnableCategory('cat1', { include: ['cat1'] })).toBe(true)
|
|
14
|
+
expect(shouldEnableCategory('cat2', { include: ['cat1'] })).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should respect exclude', () => {
|
|
18
|
+
expect(shouldEnableCategory('cat1', { exclude: ['cat1'] })).toBe(false)
|
|
19
|
+
expect(shouldEnableCategory('cat2', { exclude: ['cat1'] })).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should prioritize include then exclude', () => {
|
|
23
|
+
expect(shouldEnableCategory('cat1', { include: ['cat1'], exclude: ['cat1'] })).toBe(false)
|
|
24
|
+
expect(shouldEnableCategory('cat1', { include: ['cat1'], exclude: ['cat2'] })).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('createFilteredRegister', () => {
|
|
29
|
+
const mockTool = { disable: vi.fn() }
|
|
30
|
+
const mockPrompt = { disable: vi.fn() }
|
|
31
|
+
const mockResource = { disable: vi.fn() }
|
|
32
|
+
|
|
33
|
+
const mockServer = {
|
|
34
|
+
registerTool: vi.fn().mockReturnValue(mockTool),
|
|
35
|
+
registerPrompt: vi.fn().mockReturnValue(mockPrompt),
|
|
36
|
+
registerResource: vi.fn().mockReturnValue(mockResource)
|
|
37
|
+
} as unknown as McpServer
|
|
38
|
+
|
|
39
|
+
it('should disable tool if not included', () => {
|
|
40
|
+
const proxy = createFilteredRegister(mockServer, {
|
|
41
|
+
tools: { include: ['tool1'] }
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
proxy.registerTool('tool2', { description: 'test' }, async () => ({ content: [] }))
|
|
45
|
+
expect(mockTool.disable).toHaveBeenCalled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should NOT disable tool if included', () => {
|
|
49
|
+
const proxy = createFilteredRegister(mockServer, {
|
|
50
|
+
tools: { include: ['tool1'] }
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
vi.clearAllMocks()
|
|
54
|
+
proxy.registerTool('tool1', { description: 'test' }, async () => ({ content: [] }))
|
|
55
|
+
expect(mockTool.disable).not.toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should disable tool if excluded', () => {
|
|
59
|
+
const proxy = createFilteredRegister(mockServer, {
|
|
60
|
+
tools: { exclude: ['tool1'] }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
vi.clearAllMocks()
|
|
64
|
+
proxy.registerTool('tool1', { description: 'test' }, async () => ({ content: [] }))
|
|
65
|
+
expect(mockTool.disable).toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
run: vi.fn(),
|
|
5
|
+
generateAdapterQueryOptions: vi.fn(),
|
|
6
|
+
callHook: vi.fn(),
|
|
7
|
+
loadInjectDefaultSystemPromptValue: vi.fn(),
|
|
8
|
+
mergeSystemPrompts: vi.fn(),
|
|
9
|
+
extractTextFromMessage: vi.fn(),
|
|
10
|
+
postSessionEvent: vi.fn(),
|
|
11
|
+
fetchSessionMessages: vi.fn()
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock('@vibe-forge/task', () => ({
|
|
15
|
+
run: mocks.run,
|
|
16
|
+
generateAdapterQueryOptions: mocks.generateAdapterQueryOptions
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('@vibe-forge/hooks', () => ({
|
|
20
|
+
callHook: mocks.callHook
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
vi.mock('@vibe-forge/config', () => ({
|
|
24
|
+
loadInjectDefaultSystemPromptValue: mocks.loadInjectDefaultSystemPromptValue,
|
|
25
|
+
mergeSystemPrompts: mocks.mergeSystemPrompts
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
vi.mock('@vibe-forge/utils/chat-message', () => ({
|
|
29
|
+
extractTextFromMessage: mocks.extractTextFromMessage
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
vi.mock('#~/sync.js', () => ({
|
|
33
|
+
postSessionEvent: mocks.postSessionEvent,
|
|
34
|
+
fetchSessionMessages: mocks.fetchSessionMessages
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
describe('taskManager fatal error scenarios', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks()
|
|
40
|
+
mocks.generateAdapterQueryOptions.mockResolvedValue([
|
|
41
|
+
{},
|
|
42
|
+
{
|
|
43
|
+
systemPrompt: undefined,
|
|
44
|
+
tools: undefined,
|
|
45
|
+
skills: undefined,
|
|
46
|
+
mcpServers: undefined
|
|
47
|
+
}
|
|
48
|
+
])
|
|
49
|
+
mocks.callHook.mockResolvedValue(undefined)
|
|
50
|
+
mocks.loadInjectDefaultSystemPromptValue.mockResolvedValue(true)
|
|
51
|
+
mocks.mergeSystemPrompts.mockReturnValue(undefined)
|
|
52
|
+
mocks.postSessionEvent.mockResolvedValue(undefined)
|
|
53
|
+
mocks.fetchSessionMessages.mockResolvedValue([])
|
|
54
|
+
mocks.extractTextFromMessage.mockReturnValue('')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps the task failed when a fatal error is followed by stop', async () => {
|
|
58
|
+
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
59
|
+
|
|
60
|
+
mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
|
|
61
|
+
const session = {
|
|
62
|
+
emit: vi.fn(() => {
|
|
63
|
+
adapterOptions.onEvent({
|
|
64
|
+
type: 'error',
|
|
65
|
+
data: {
|
|
66
|
+
message: 'Incomplete response returned',
|
|
67
|
+
fatal: true
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
adapterOptions.onEvent({
|
|
71
|
+
type: 'stop',
|
|
72
|
+
data: undefined
|
|
73
|
+
})
|
|
74
|
+
}),
|
|
75
|
+
kill: vi.fn()
|
|
76
|
+
}
|
|
77
|
+
return { session }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const managedTaskManager = new TaskManager()
|
|
81
|
+
await managedTaskManager.startTask({
|
|
82
|
+
taskId: 'task-fatal-stop',
|
|
83
|
+
description: 'trigger',
|
|
84
|
+
background: false
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const task = managedTaskManager.getTask('task-fatal-stop')
|
|
88
|
+
expect(task?.status).toBe('failed')
|
|
89
|
+
expect(task?.logs).toContain('Incomplete response returned')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { createMcpTools } from '#~/tools/index.js'
|
|
4
|
+
import wait from '#~/tools/general/wait.js'
|
|
5
|
+
|
|
6
|
+
import { createToolTester } from './mcp-test-utils.js'
|
|
7
|
+
|
|
8
|
+
describe('mcp tools integration', () => {
|
|
9
|
+
it('registers task tools by default', () => {
|
|
10
|
+
expect(createMcpTools()).toHaveProperty('task')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('wait tool', () => {
|
|
14
|
+
it('should register wait tool', () => {
|
|
15
|
+
const tester = createToolTester()
|
|
16
|
+
wait(tester.mockRegister)
|
|
17
|
+
|
|
18
|
+
expect(tester.getRegisteredTools()).toContain('wait')
|
|
19
|
+
const info = tester.getToolInfo('wait')
|
|
20
|
+
expect(info?.title).toBe('Wait Tool')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should wait for specified time', async () => {
|
|
24
|
+
const tester = createToolTester()
|
|
25
|
+
wait(tester.mockRegister)
|
|
26
|
+
|
|
27
|
+
const start = Date.now()
|
|
28
|
+
const ms = 100
|
|
29
|
+
const result = await tester.callTool('wait', { ms }) as any
|
|
30
|
+
const duration = Date.now() - start
|
|
31
|
+
|
|
32
|
+
expect(duration).toBeGreaterThanOrEqual(ms - 10) // Allow some jitter
|
|
33
|
+
expect(result.content[0].text).toBe(`Finished waiting for ${ms}ms`)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should fail validation with out-of-range input', async () => {
|
|
37
|
+
const tester = createToolTester()
|
|
38
|
+
wait(tester.mockRegister)
|
|
39
|
+
|
|
40
|
+
// Max is 60000ms
|
|
41
|
+
await expect(tester.callTool('wait', { ms: 70000 }))
|
|
42
|
+
.rejects.toThrow()
|
|
43
|
+
|
|
44
|
+
// Min is 0ms
|
|
45
|
+
await expect(tester.callTool('wait', { ms: -1 }))
|
|
46
|
+
.rejects.toThrow()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
})
|
package/cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vibe-forge/mcp",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Vibe Forge MCP server",
|
|
5
|
+
"imports": {
|
|
6
|
+
"#~/*.js": {
|
|
7
|
+
"__vibe-forge__": {
|
|
8
|
+
"default": "./src/*.ts"
|
|
9
|
+
},
|
|
10
|
+
"default": {
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"__vibe-forge__": {
|
|
19
|
+
"default": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"default": {
|
|
22
|
+
"import": "./dist/index.mjs",
|
|
23
|
+
"require": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"vibe-forge-mcp": "./cli.js",
|
|
30
|
+
"vf-mcp": "./cli.js"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
34
|
+
"commander": "^12.1.0",
|
|
35
|
+
"zod": "^3.24.1",
|
|
36
|
+
"@vibe-forge/cli-helper": "^0.8.0",
|
|
37
|
+
"@vibe-forge/config": "^0.8.0",
|
|
38
|
+
"@vibe-forge/register": "^0.8.0",
|
|
39
|
+
"@vibe-forge/hooks": "^0.8.0",
|
|
40
|
+
"@vibe-forge/task": "^0.8.0",
|
|
41
|
+
"@vibe-forge/utils": "^0.8.0",
|
|
42
|
+
"@vibe-forge/types": "^0.8.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "pnpm -C ../.. exec vitest run --workspace vitest.workspace.ts --project bundler packages/mcp/__tests__"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { program } from 'commander'
|
|
2
|
+
|
|
3
|
+
import { configureMcpCommand } from './command'
|
|
4
|
+
import { getMcpDescription, getMcpVersion } from './package-config'
|
|
5
|
+
|
|
6
|
+
const version = getMcpVersion()
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name('vf-mcp')
|
|
10
|
+
.description(getMcpDescription())
|
|
11
|
+
.version(version)
|
|
12
|
+
|
|
13
|
+
configureMcpCommand(program, version)
|
|
14
|
+
|
|
15
|
+
void program.parseAsync()
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
5
|
+
import type { Command } from 'commander'
|
|
6
|
+
|
|
7
|
+
import type { McpOptions } from './types'
|
|
8
|
+
import { createMcpTools } from './tools'
|
|
9
|
+
import { createFilteredRegister, shouldEnableCategory } from './tools/proxy'
|
|
10
|
+
|
|
11
|
+
export const configureMcpCommand = (command: Command, version: string) => (
|
|
12
|
+
command
|
|
13
|
+
.description('Start MCP server (stdio)')
|
|
14
|
+
.option('--include-tools <tools>', 'Comma-separated list of tools to include')
|
|
15
|
+
.option('--exclude-tools <tools>', 'Comma-separated list of tools to exclude')
|
|
16
|
+
.option('--include-prompts <prompts>', 'Comma-separated list of prompts to include')
|
|
17
|
+
.option('--exclude-prompts <prompts>', 'Comma-separated list of prompts to exclude')
|
|
18
|
+
.option('--include-resources <resources>', 'Comma-separated list of resources to include')
|
|
19
|
+
.option('--exclude-resources <resources>', 'Comma-separated list of resources to exclude')
|
|
20
|
+
.option('--include-category <categories>', 'Comma-separated list of categories to include')
|
|
21
|
+
.option('--exclude-category <categories>', 'Comma-separated list of categories to exclude')
|
|
22
|
+
.action(async (opts: McpOptions) => {
|
|
23
|
+
const parseList = (s?: string) => s?.split(',').map((t) => t.trim()).filter(Boolean) ?? []
|
|
24
|
+
const tools = createMcpTools()
|
|
25
|
+
|
|
26
|
+
const server = new McpServer({
|
|
27
|
+
name: 'vibe-forge',
|
|
28
|
+
version
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const proxyServer = createFilteredRegister(server, {
|
|
32
|
+
tools: { include: parseList(opts.includeTools), exclude: parseList(opts.excludeTools) },
|
|
33
|
+
prompts: { include: parseList(opts.includePrompts), exclude: parseList(opts.excludePrompts) },
|
|
34
|
+
resources: { include: parseList(opts.includeResources), exclude: parseList(opts.excludeResources) }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const categoryFilter = {
|
|
38
|
+
include: parseList(opts.includeCategory),
|
|
39
|
+
exclude: parseList(opts.excludeCategory)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const runType = process.env.__VF_PROJECT_AI_RUN_TYPE__ ?? 'cli'
|
|
43
|
+
|
|
44
|
+
Object.entries(tools).forEach(([category, register]) => {
|
|
45
|
+
if (category === 'interaction' && runType !== 'server') {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (shouldEnableCategory(category, categoryFilter)) {
|
|
50
|
+
register(proxyServer)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const transport = new StdioServerTransport()
|
|
55
|
+
await server.connect(transport)
|
|
56
|
+
|
|
57
|
+
console.error('MCP server started on stdio')
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
export function registerMcpCommand(program: Command, version: string) {
|
|
62
|
+
return configureMcpCommand(program.command('mcp'), version)
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { configureMcpCommand, registerMcpCommand } from './command'
|
|
2
|
+
export type {
|
|
3
|
+
McpManagedTaskInput,
|
|
4
|
+
McpOptions,
|
|
5
|
+
McpResolvedTaskQueryOptions,
|
|
6
|
+
McpSelectionFilter,
|
|
7
|
+
McpTaskBindings,
|
|
8
|
+
McpTaskDefinitionType,
|
|
9
|
+
McpTaskHookInputs,
|
|
10
|
+
McpTaskOutputEvent,
|
|
11
|
+
McpTaskQueryOptions,
|
|
12
|
+
McpTaskRunOptions,
|
|
13
|
+
McpTaskSession
|
|
14
|
+
} from './types'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
let packageJson: Record<string, unknown> | undefined
|
|
2
|
+
|
|
3
|
+
const getPackageJson = () => {
|
|
4
|
+
if (!packageJson) {
|
|
5
|
+
try {
|
|
6
|
+
// eslint-disable-next-line ts/no-require-imports
|
|
7
|
+
packageJson = require('@vibe-forge/mcp/package.json') as Record<string, unknown>
|
|
8
|
+
} catch {
|
|
9
|
+
packageJson = {}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return packageJson
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const getPackageConfig = (key: string, defaultValue = '') => {
|
|
16
|
+
const value = getPackageJson()[key]
|
|
17
|
+
return typeof value === 'string' ? value : defaultValue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const getMcpVersion = () => getPackageConfig('version', '0.0.0')
|
|
21
|
+
|
|
22
|
+
export const getMcpDescription = () => getPackageConfig('description', 'Vibe Forge MCP')
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import type { WSEvent } from '@vibe-forge/types'
|
|
4
|
+
import { extractTextFromMessage } from '@vibe-forge/utils/chat-message'
|
|
5
|
+
|
|
6
|
+
const getServerBaseUrl = () => {
|
|
7
|
+
const host = process.env.__VF_PROJECT_AI_SERVER_HOST__ ?? 'localhost'
|
|
8
|
+
const port = process.env.__VF_PROJECT_AI_SERVER_PORT__ ?? '8787'
|
|
9
|
+
return `http://${host}:${port}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const getParentSessionId = () => {
|
|
13
|
+
const ctxId = process.env.__VF_PROJECT_AI_CTX_ID__
|
|
14
|
+
return ctxId ?? undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const createChildSession = async (params: {
|
|
18
|
+
id: string
|
|
19
|
+
title?: string
|
|
20
|
+
parentSessionId?: string
|
|
21
|
+
}) => {
|
|
22
|
+
const baseUrl = getServerBaseUrl()
|
|
23
|
+
const response = await fetch(`${baseUrl}/api/sessions`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
id: params.id,
|
|
28
|
+
title: params.title,
|
|
29
|
+
parentSessionId: params.parentSessionId,
|
|
30
|
+
start: false
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const errorText = await response.text()
|
|
35
|
+
throw new Error(`Failed to create session: ${response.statusText} - ${errorText}`)
|
|
36
|
+
}
|
|
37
|
+
return response.json()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const postSessionEvent = async (sessionId: string, payload: Record<string, unknown>) => {
|
|
41
|
+
const baseUrl = getServerBaseUrl()
|
|
42
|
+
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/events`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify(payload)
|
|
46
|
+
})
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const errorText = await response.text()
|
|
49
|
+
throw new Error(`Failed to post session event: ${response.statusText} - ${errorText}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const fetchSessionMessages = async (sessionId: string) => {
|
|
54
|
+
const baseUrl = getServerBaseUrl()
|
|
55
|
+
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const errorText = await response.text()
|
|
58
|
+
throw new Error(`Failed to fetch session messages: ${response.statusText} - ${errorText}`)
|
|
59
|
+
}
|
|
60
|
+
const data = await response.json() as { messages: WSEvent[] }
|
|
61
|
+
return data.messages ?? []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { extractTextFromMessage }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { defineRegister } from '../types'
|
|
4
|
+
|
|
5
|
+
export default defineRegister(({ registerTool }) => {
|
|
6
|
+
registerTool(
|
|
7
|
+
'wait',
|
|
8
|
+
{
|
|
9
|
+
title: 'Wait Tool',
|
|
10
|
+
description: 'Wait for a specified amount of time (milliseconds)',
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
ms: z.number().min(0).max(60000).describe('Time to wait in milliseconds')
|
|
13
|
+
})
|
|
14
|
+
},
|
|
15
|
+
async ({ ms }) => {
|
|
16
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: 'text', text: `Finished waiting for ${ms}ms` }]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import wait from './general/wait'
|
|
2
|
+
import askUser from './interaction/ask-user'
|
|
3
|
+
import type { Register } from './types'
|
|
4
|
+
import { createTaskRegister } from './task'
|
|
5
|
+
|
|
6
|
+
export const createMcpTools = (): Record<string, Register> => ({
|
|
7
|
+
general: (server) => {
|
|
8
|
+
wait(server)
|
|
9
|
+
},
|
|
10
|
+
interaction: (server) => {
|
|
11
|
+
askUser(server)
|
|
12
|
+
},
|
|
13
|
+
task: createTaskRegister()
|
|
14
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { defineRegister } from '../types'
|
|
4
|
+
|
|
5
|
+
const Schema = z.object({
|
|
6
|
+
question: z.string().describe('The question to ask the user'),
|
|
7
|
+
options: z.array(z.object({
|
|
8
|
+
label: z.string().describe('The label of the option'),
|
|
9
|
+
description: z.string().optional().describe('The description of the option')
|
|
10
|
+
})).optional().describe('The options for the user to select from'),
|
|
11
|
+
multiselect: z.boolean().optional().describe('Whether the user can select multiple options')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default defineRegister(({ registerTool }) => {
|
|
15
|
+
registerTool(
|
|
16
|
+
'AskUserQuestion',
|
|
17
|
+
{
|
|
18
|
+
title: 'Ask User Question',
|
|
19
|
+
description: 'Ask the user a question via the web interface',
|
|
20
|
+
inputSchema: Schema
|
|
21
|
+
},
|
|
22
|
+
async (args) => {
|
|
23
|
+
const { question, options, multiselect } = args
|
|
24
|
+
const sessionId = process.env.__VF_PROJECT_AI_SESSION_ID__
|
|
25
|
+
|
|
26
|
+
if (!sessionId) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'Session ID not found in environment variables. This tool can only be used within a Vibe Forge session.'
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const host = process.env.__VF_PROJECT_AI_SERVER_HOST__ ?? 'localhost'
|
|
33
|
+
const port = process.env.__VF_PROJECT_AI_SERVER_PORT__ ?? '8787'
|
|
34
|
+
const response = await fetch(`http://${host}:${port}/api/interact/ask`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json'
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
sessionId,
|
|
41
|
+
question,
|
|
42
|
+
options,
|
|
43
|
+
multiselect
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const errorText = await response.text()
|
|
49
|
+
throw new Error(`Failed to ask user question: ${response.statusText} - ${errorText}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = await response.json()
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: 'text',
|
|
57
|
+
text: JSON.stringify(result.result)
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
|
|
3
|
+
import type { Register } from './types.js'
|
|
4
|
+
|
|
5
|
+
export interface FilterOptions {
|
|
6
|
+
include?: string[]
|
|
7
|
+
exclude?: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createFilteredRegister(
|
|
11
|
+
server: McpServer,
|
|
12
|
+
options: {
|
|
13
|
+
tools?: FilterOptions
|
|
14
|
+
prompts?: FilterOptions
|
|
15
|
+
resources?: FilterOptions
|
|
16
|
+
}
|
|
17
|
+
): Parameters<Register>[0] {
|
|
18
|
+
const shouldEnable = (name: string, filter?: FilterOptions) => {
|
|
19
|
+
if (!filter) return true
|
|
20
|
+
const { include = [], exclude = [] } = filter
|
|
21
|
+
if (include.length > 0 && !include.includes(name)) return false
|
|
22
|
+
if (exclude.includes(name)) return false
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
registerTool: (...args) => {
|
|
28
|
+
const tool = server.registerTool(...args)
|
|
29
|
+
const name = args[0]
|
|
30
|
+
if (!shouldEnable(name, options.tools)) {
|
|
31
|
+
tool.disable()
|
|
32
|
+
}
|
|
33
|
+
return tool
|
|
34
|
+
},
|
|
35
|
+
registerPrompt: (...args) => {
|
|
36
|
+
const prompt = server.registerPrompt(...args)
|
|
37
|
+
const name = args[0]
|
|
38
|
+
if (!shouldEnable(name, options.prompts)) {
|
|
39
|
+
prompt.disable()
|
|
40
|
+
}
|
|
41
|
+
return prompt
|
|
42
|
+
},
|
|
43
|
+
registerResource: (...args) => {
|
|
44
|
+
// @ts-ignore - McpServer types have complex overloads that are hard to proxy perfectly
|
|
45
|
+
const resource = server.registerResource(...args)
|
|
46
|
+
const name = args[0]
|
|
47
|
+
if (!shouldEnable(name, options.resources)) {
|
|
48
|
+
resource.disable()
|
|
49
|
+
}
|
|
50
|
+
return resource
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const shouldEnableCategory = (
|
|
56
|
+
category: string,
|
|
57
|
+
options: FilterOptions
|
|
58
|
+
) => {
|
|
59
|
+
const { include = [], exclude = [] } = options
|
|
60
|
+
if (include.length > 0 && !include.includes(category)) return false
|
|
61
|
+
if (exclude.includes(category)) return false
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import { callHook } from '@vibe-forge/hooks'
|
|
4
|
+
import { uuid } from '@vibe-forge/utils/uuid'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
import { createChildSession, getParentSessionId } from '#~/sync.js'
|
|
8
|
+
import type { McpManagedTaskInput } from '../../types'
|
|
9
|
+
import { defineRegister } from '../types'
|
|
10
|
+
import { TaskManager } from './manager'
|
|
11
|
+
|
|
12
|
+
export const createTaskRegister = () => {
|
|
13
|
+
const taskManager = new TaskManager()
|
|
14
|
+
|
|
15
|
+
return defineRegister((server) => {
|
|
16
|
+
server.registerTool(
|
|
17
|
+
'StartTasks',
|
|
18
|
+
{
|
|
19
|
+
title: 'Start Tasks',
|
|
20
|
+
description: 'Start multiple tasks in background or foreground',
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
tasks: z
|
|
23
|
+
.array(
|
|
24
|
+
z.object({
|
|
25
|
+
description: z
|
|
26
|
+
.string()
|
|
27
|
+
.describe('The description or prompt for the task'),
|
|
28
|
+
type: z
|
|
29
|
+
.enum([
|
|
30
|
+
'default',
|
|
31
|
+
'spec',
|
|
32
|
+
'entity'
|
|
33
|
+
])
|
|
34
|
+
.describe('The type of definition to load (default, spec or entity)'),
|
|
35
|
+
name: z
|
|
36
|
+
.string()
|
|
37
|
+
.describe('The name of the spec or entity to load, if type is spec or entity. Otherwise, ignored.')
|
|
38
|
+
.optional(),
|
|
39
|
+
adapter: z
|
|
40
|
+
.string()
|
|
41
|
+
.describe('The adapter to use for the task (e.g. claude-code)')
|
|
42
|
+
.optional(),
|
|
43
|
+
permissionMode: z
|
|
44
|
+
.enum(['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'])
|
|
45
|
+
.describe('Permission mode for the task')
|
|
46
|
+
.optional(),
|
|
47
|
+
background: z
|
|
48
|
+
.boolean()
|
|
49
|
+
.describe(
|
|
50
|
+
'Whether to run in background (default: true). If false, waits for completion and returns logs.'
|
|
51
|
+
)
|
|
52
|
+
.optional()
|
|
53
|
+
})
|
|
54
|
+
)
|
|
55
|
+
.describe('List of tasks to start')
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
async ({ tasks }) => {
|
|
59
|
+
const resolvedTasks = tasks.map((task): McpManagedTaskInput & { taskId: string } => ({
|
|
60
|
+
...task,
|
|
61
|
+
taskId: uuid()
|
|
62
|
+
}))
|
|
63
|
+
const parentSessionId = getParentSessionId()
|
|
64
|
+
|
|
65
|
+
await callHook('StartTasks', {
|
|
66
|
+
cwd: process.cwd(),
|
|
67
|
+
sessionId: process.env.__VF_PROJECT_AI_SESSION_ID__!,
|
|
68
|
+
tasks
|
|
69
|
+
})
|
|
70
|
+
const syncResults = parentSessionId
|
|
71
|
+
? await Promise.allSettled(resolvedTasks.map(task =>
|
|
72
|
+
createChildSession({
|
|
73
|
+
id: task.taskId,
|
|
74
|
+
title: task.name ?? task.description,
|
|
75
|
+
parentSessionId
|
|
76
|
+
})
|
|
77
|
+
))
|
|
78
|
+
: []
|
|
79
|
+
const results = await Promise.allSettled(resolvedTasks
|
|
80
|
+
.map((task, idx) =>
|
|
81
|
+
taskManager.startTask({
|
|
82
|
+
...task,
|
|
83
|
+
enableServerSync: parentSessionId != null && syncResults[idx]?.status === 'fulfilled'
|
|
84
|
+
})
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
content: [{
|
|
89
|
+
type: 'text',
|
|
90
|
+
text: JSON.stringify(results.map((r, idx) => {
|
|
91
|
+
const { taskId, description } = resolvedTasks[idx]
|
|
92
|
+
const info = taskManager.getTask(taskId)
|
|
93
|
+
const { session, onStop, serverSync, createdAt, ...safeInfo } = info ?? {}
|
|
94
|
+
return {
|
|
95
|
+
taskId,
|
|
96
|
+
description,
|
|
97
|
+
status: info?.status ?? r.status,
|
|
98
|
+
logs: info?.logs ?? [],
|
|
99
|
+
...safeInfo
|
|
100
|
+
}
|
|
101
|
+
}))
|
|
102
|
+
}]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
server.registerTool(
|
|
108
|
+
'GetTaskInfo',
|
|
109
|
+
{
|
|
110
|
+
title: 'Get Task Info',
|
|
111
|
+
description: 'Get the status and logs of a specific task',
|
|
112
|
+
inputSchema: z.object({
|
|
113
|
+
taskId: z.string().describe('The ID of the task to check')
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
async ({ taskId }) => {
|
|
117
|
+
const task = taskManager.getTask(taskId)
|
|
118
|
+
if (!task) {
|
|
119
|
+
return {
|
|
120
|
+
content: [{ type: 'text', text: `Task ${taskId} not found.` }],
|
|
121
|
+
isError: true
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const { session, onStop, serverSync, createdAt, ...safeTask } = task
|
|
125
|
+
return {
|
|
126
|
+
content: [{
|
|
127
|
+
type: 'text',
|
|
128
|
+
text: JSON.stringify([safeTask])
|
|
129
|
+
}]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
server.registerTool(
|
|
135
|
+
'StopTask',
|
|
136
|
+
{
|
|
137
|
+
title: 'Stop Task',
|
|
138
|
+
description: 'Stop a running task',
|
|
139
|
+
inputSchema: z.object({
|
|
140
|
+
taskId: z.string().describe('The ID of the task to stop')
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
async ({ taskId }) => {
|
|
144
|
+
const success = taskManager.stopTask(taskId)
|
|
145
|
+
return {
|
|
146
|
+
content: [{
|
|
147
|
+
type: 'text',
|
|
148
|
+
text: success ? `Task ${taskId} stopped.` : `Failed to stop task ${taskId} (not found or already stopped).`
|
|
149
|
+
}]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
server.registerTool(
|
|
155
|
+
'ListTasks',
|
|
156
|
+
{
|
|
157
|
+
title: 'List Tasks',
|
|
158
|
+
description: 'List all managed tasks',
|
|
159
|
+
inputSchema: z.object({})
|
|
160
|
+
},
|
|
161
|
+
async () => {
|
|
162
|
+
const tasks = taskManager.getAllTasks()
|
|
163
|
+
return {
|
|
164
|
+
content: [{
|
|
165
|
+
type: 'text',
|
|
166
|
+
text: JSON.stringify(tasks.map(({ session, onStop, serverSync, createdAt, ...task }) => task))
|
|
167
|
+
}]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import { loadInjectDefaultSystemPromptValue, mergeSystemPrompts } from '@vibe-forge/config'
|
|
4
|
+
import { callHook } from '@vibe-forge/hooks'
|
|
5
|
+
import { generateAdapterQueryOptions, run } from '@vibe-forge/task'
|
|
6
|
+
import type { ChatMessage, McpTaskOutputEvent, McpTaskSession, SessionPermissionMode } from '@vibe-forge/types'
|
|
7
|
+
import { extractTextFromMessage } from '@vibe-forge/utils/chat-message'
|
|
8
|
+
|
|
9
|
+
import { fetchSessionMessages, postSessionEvent } from '#~/sync.js'
|
|
10
|
+
|
|
11
|
+
interface ServerSyncState {
|
|
12
|
+
sessionId: string
|
|
13
|
+
lastEventIndex: number
|
|
14
|
+
lastAssistantMessageId?: string
|
|
15
|
+
seenMessageIds: Set<string>
|
|
16
|
+
poller?: NodeJS.Timeout
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskInfo {
|
|
20
|
+
taskId: string
|
|
21
|
+
adapter?: string
|
|
22
|
+
description: string
|
|
23
|
+
type?: 'default' | 'spec' | 'entity'
|
|
24
|
+
name?: string
|
|
25
|
+
permissionMode?: SessionPermissionMode
|
|
26
|
+
background?: boolean
|
|
27
|
+
status: 'running' | 'completed' | 'failed'
|
|
28
|
+
exitCode?: number
|
|
29
|
+
logs: string[]
|
|
30
|
+
session?: McpTaskSession
|
|
31
|
+
createdAt: number
|
|
32
|
+
onStop?: () => void
|
|
33
|
+
serverSync?: ServerSyncState
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TaskManager {
|
|
37
|
+
private tasks: Map<string, TaskInfo> = new Map()
|
|
38
|
+
|
|
39
|
+
public async startTask(options: {
|
|
40
|
+
taskId: string
|
|
41
|
+
description: string
|
|
42
|
+
type?: 'default' | 'spec' | 'entity'
|
|
43
|
+
name?: string
|
|
44
|
+
permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
|
|
45
|
+
adapter?: string
|
|
46
|
+
background?: boolean
|
|
47
|
+
enableServerSync?: boolean
|
|
48
|
+
}): Promise<{ taskId: string; logs?: string[] }> {
|
|
49
|
+
const { taskId, adapter, description, type, name, permissionMode, background = true, enableServerSync } = options
|
|
50
|
+
|
|
51
|
+
// Initialize Task Info
|
|
52
|
+
const taskInfo: TaskInfo = {
|
|
53
|
+
taskId,
|
|
54
|
+
adapter,
|
|
55
|
+
description,
|
|
56
|
+
type,
|
|
57
|
+
name,
|
|
58
|
+
permissionMode,
|
|
59
|
+
background,
|
|
60
|
+
status: 'running',
|
|
61
|
+
logs: [],
|
|
62
|
+
createdAt: Date.now()
|
|
63
|
+
}
|
|
64
|
+
if (enableServerSync) {
|
|
65
|
+
taskInfo.serverSync = {
|
|
66
|
+
sessionId: taskId,
|
|
67
|
+
lastEventIndex: 0,
|
|
68
|
+
seenMessageIds: new Set()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.tasks.set(taskId, taskInfo)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Resolve Config
|
|
75
|
+
const promptType = type !== 'default' ? type : undefined
|
|
76
|
+
const promptName = name
|
|
77
|
+
const promptCWD = process.cwd()
|
|
78
|
+
const [data, resolvedConfig] = await generateAdapterQueryOptions(
|
|
79
|
+
promptType,
|
|
80
|
+
promptName,
|
|
81
|
+
promptCWD
|
|
82
|
+
)
|
|
83
|
+
const env = {
|
|
84
|
+
...process.env,
|
|
85
|
+
__VF_PROJECT_AI_CTX_ID__: process.env.__VF_PROJECT_AI_CTX_ID__ ?? taskId
|
|
86
|
+
}
|
|
87
|
+
await callHook('GenerateSystemPrompt', {
|
|
88
|
+
cwd: promptCWD,
|
|
89
|
+
sessionId: taskId,
|
|
90
|
+
type: promptType,
|
|
91
|
+
name: promptName,
|
|
92
|
+
data
|
|
93
|
+
}, env)
|
|
94
|
+
|
|
95
|
+
const injectDefaultSystemPrompt = await loadInjectDefaultSystemPromptValue(promptCWD)
|
|
96
|
+
|
|
97
|
+
// Start Task
|
|
98
|
+
const ctxId = process.env.__VF_PROJECT_AI_CTX_ID__ ?? taskId
|
|
99
|
+
const { session } = await run({
|
|
100
|
+
adapter,
|
|
101
|
+
cwd: process.cwd(),
|
|
102
|
+
env: {
|
|
103
|
+
...process.env,
|
|
104
|
+
__VF_PROJECT_AI_CTX_ID__: ctxId
|
|
105
|
+
}
|
|
106
|
+
}, {
|
|
107
|
+
type: 'create',
|
|
108
|
+
runtime: 'mcp',
|
|
109
|
+
mode: 'stream',
|
|
110
|
+
sessionId: taskId,
|
|
111
|
+
systemPrompt: mergeSystemPrompts({
|
|
112
|
+
generatedSystemPrompt: resolvedConfig.systemPrompt,
|
|
113
|
+
injectDefaultSystemPrompt
|
|
114
|
+
}),
|
|
115
|
+
permissionMode,
|
|
116
|
+
tools: resolvedConfig.tools,
|
|
117
|
+
skills: resolvedConfig.skills,
|
|
118
|
+
mcpServers: resolvedConfig.mcpServers,
|
|
119
|
+
promptAssetIds: resolvedConfig.promptAssetIds,
|
|
120
|
+
onEvent: (event: McpTaskOutputEvent) => {
|
|
121
|
+
this.handleEvent(taskId, event)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
// Store session for control
|
|
125
|
+
const task = this.tasks.get(taskId)
|
|
126
|
+
if (task) {
|
|
127
|
+
task.session = session
|
|
128
|
+
// Send initial prompt (description)
|
|
129
|
+
session.emit({
|
|
130
|
+
type: 'message',
|
|
131
|
+
content: [{ type: 'text', text: description }]
|
|
132
|
+
})
|
|
133
|
+
this.startServerPolling(taskId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!background) {
|
|
137
|
+
// Wait for completion
|
|
138
|
+
await new Promise<void>((resolve) => {
|
|
139
|
+
const task = this.tasks.get(taskId)
|
|
140
|
+
if (!task) {
|
|
141
|
+
resolve()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
// Check if already finished
|
|
145
|
+
if (task.status !== 'running') {
|
|
146
|
+
resolve()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
// Register callback
|
|
150
|
+
task.onStop = resolve
|
|
151
|
+
})
|
|
152
|
+
return { taskId, logs: taskInfo.logs }
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const task = this.tasks.get(taskId)
|
|
156
|
+
if (task) {
|
|
157
|
+
task.status = 'failed'
|
|
158
|
+
task.logs.push(`Failed to start task: ${err instanceof Error ? err.message : String(err)}`)
|
|
159
|
+
task.onStop?.()
|
|
160
|
+
}
|
|
161
|
+
throw err
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { taskId }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private handleEvent(taskId: string, event: McpTaskOutputEvent) {
|
|
168
|
+
const task = this.tasks.get(taskId)
|
|
169
|
+
if (!task) return
|
|
170
|
+
|
|
171
|
+
void this.syncEvent(task, event)
|
|
172
|
+
|
|
173
|
+
switch (event.type) {
|
|
174
|
+
case 'message': {
|
|
175
|
+
const message = event.data as ChatMessage
|
|
176
|
+
if (message?.id) {
|
|
177
|
+
task.serverSync?.seenMessageIds.add(message.id)
|
|
178
|
+
}
|
|
179
|
+
if (message?.role === 'assistant' && message.id) {
|
|
180
|
+
if (task.serverSync) {
|
|
181
|
+
task.serverSync.lastAssistantMessageId = message.id
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const content = event.data.content
|
|
185
|
+
let text = ''
|
|
186
|
+
if (typeof content === 'string') {
|
|
187
|
+
text = content
|
|
188
|
+
} else if (Array.isArray(content)) {
|
|
189
|
+
text = content.map(c => c.type === 'text' ? c.text : '').join('')
|
|
190
|
+
}
|
|
191
|
+
if (text) {
|
|
192
|
+
task.logs.push(text)
|
|
193
|
+
}
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
case 'error': {
|
|
197
|
+
task.logs.push(event.data.message)
|
|
198
|
+
if (event.data.fatal !== false) {
|
|
199
|
+
task.status = 'failed'
|
|
200
|
+
this.stopServerPolling(taskId)
|
|
201
|
+
task.onStop?.()
|
|
202
|
+
}
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
case 'stop': {
|
|
206
|
+
if (task.status === 'failed') {
|
|
207
|
+
this.stopServerPolling(taskId)
|
|
208
|
+
task.onStop?.()
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
task.status = 'completed'
|
|
212
|
+
this.stopServerPolling(taskId)
|
|
213
|
+
task.onStop?.()
|
|
214
|
+
break
|
|
215
|
+
}
|
|
216
|
+
case 'exit':
|
|
217
|
+
task.status = event.data.exitCode === 0 ? 'completed' : 'failed'
|
|
218
|
+
task.exitCode = event.data.exitCode ?? undefined
|
|
219
|
+
task.logs.push(`Process exited with code ${event.data.exitCode}`)
|
|
220
|
+
this.stopServerPolling(taskId)
|
|
221
|
+
task.onStop?.()
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private startServerPolling(taskId: string) {
|
|
227
|
+
const task = this.tasks.get(taskId)
|
|
228
|
+
if (!task?.serverSync) return
|
|
229
|
+
if (task.serverSync.poller) return
|
|
230
|
+
|
|
231
|
+
const poll = async () => {
|
|
232
|
+
const current = this.tasks.get(taskId)
|
|
233
|
+
if (!current?.serverSync || !current.session) return
|
|
234
|
+
try {
|
|
235
|
+
const events = await fetchSessionMessages(current.serverSync.sessionId)
|
|
236
|
+
const startIndex = current.serverSync.lastEventIndex
|
|
237
|
+
const newEvents = events.slice(startIndex)
|
|
238
|
+
current.serverSync.lastEventIndex = events.length
|
|
239
|
+
|
|
240
|
+
for (const ev of newEvents) {
|
|
241
|
+
if (ev.type !== 'message') continue
|
|
242
|
+
if (ev.message.role !== 'user') continue
|
|
243
|
+
if (ev.message.id && current.serverSync.seenMessageIds.has(ev.message.id)) {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (ev.message.id) {
|
|
247
|
+
current.serverSync.seenMessageIds.add(ev.message.id)
|
|
248
|
+
}
|
|
249
|
+
const text = extractTextFromMessage(ev.message).trim()
|
|
250
|
+
if (text === '') continue
|
|
251
|
+
current.session.emit({
|
|
252
|
+
type: 'message',
|
|
253
|
+
content: [{ type: 'text', text }],
|
|
254
|
+
parentUuid: current.serverSync.lastAssistantMessageId
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
task.serverSync.poller = setInterval(() => {
|
|
261
|
+
void poll()
|
|
262
|
+
}, 1000)
|
|
263
|
+
void poll()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private stopServerPolling(taskId: string) {
|
|
267
|
+
const task = this.tasks.get(taskId)
|
|
268
|
+
if (task?.serverSync?.poller) {
|
|
269
|
+
clearInterval(task.serverSync.poller)
|
|
270
|
+
task.serverSync.poller = undefined
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async syncEvent(task: TaskInfo, event: McpTaskOutputEvent) {
|
|
275
|
+
if (!task.serverSync) return
|
|
276
|
+
try {
|
|
277
|
+
await postSessionEvent(task.serverSync.sessionId, event as unknown as Record<string, unknown>)
|
|
278
|
+
} catch (err) {
|
|
279
|
+
task.logs.push(`Sync event failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
public getTask(taskId: string): TaskInfo | undefined {
|
|
284
|
+
return this.tasks.get(taskId)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
public getAllTasks(): TaskInfo[] {
|
|
288
|
+
return Array.from(this.tasks.values())
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
public stopTask(taskId: string): boolean {
|
|
292
|
+
const task = this.tasks.get(taskId)
|
|
293
|
+
if (task && task.session) {
|
|
294
|
+
task.session.kill()
|
|
295
|
+
task.logs.push('Task stopped by user')
|
|
296
|
+
task.status = 'failed' // or 'stopped' if we had that status
|
|
297
|
+
this.stopServerPolling(taskId)
|
|
298
|
+
if (task.onStop) task.onStop()
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
|
|
3
|
+
export interface Register {
|
|
4
|
+
(
|
|
5
|
+
server: {
|
|
6
|
+
registerTool: McpServer['registerTool']
|
|
7
|
+
registerResource: McpServer['registerResource']
|
|
8
|
+
registerPrompt: McpServer['registerPrompt']
|
|
9
|
+
}
|
|
10
|
+
): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const defineRegister = (register: Register) => register
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
McpManagedTaskInput,
|
|
3
|
+
McpResolvedTaskQueryOptions,
|
|
4
|
+
McpSelectionFilter,
|
|
5
|
+
McpTaskBindings,
|
|
6
|
+
McpTaskDefinitionType,
|
|
7
|
+
McpTaskHookInputs,
|
|
8
|
+
McpTaskOutputEvent,
|
|
9
|
+
McpTaskQueryOptions,
|
|
10
|
+
McpTaskRunOptions,
|
|
11
|
+
McpTaskSession
|
|
12
|
+
} from '@vibe-forge/types'
|
|
13
|
+
|
|
14
|
+
export interface McpOptions {
|
|
15
|
+
includeTools?: string
|
|
16
|
+
excludeTools?: string
|
|
17
|
+
includePrompts?: string
|
|
18
|
+
excludePrompts?: string
|
|
19
|
+
includeResources?: string
|
|
20
|
+
excludeResources?: string
|
|
21
|
+
includeCategory?: string
|
|
22
|
+
excludeCategory?: string
|
|
23
|
+
}
|