@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 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
@@ -0,0 +1,3 @@
1
+ require('@vibe-forge/cli-helper/entry').runCliPackageEntrypoint({
2
+ packageDir: __dirname
3
+ })
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
+ }