-
Notifications
You must be signed in to change notification settings - Fork 352
feat(mcp): built-in MCP server so AI agents can create Emdash tasks #1642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ubuntudroid
wants to merge
12
commits into
generalaction:main
Choose a base branch
from
ubuntudroid:sven/feat-basic-emdash-mcp-9j1
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 8 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
a3b4a14
feat(mcp): add built-in MCP HTTP server to main process
ubuntudroid 8226be4
feat(mcp): wire MCP task queue through IPC to renderer
ubuntudroid 8eaa5fd
feat(mcp): add mcp.enabled/port settings with conditional server startup
ubuntudroid 4a3a8cb
feat(mcp): add MCP server settings card to Integrations tab
ubuntudroid a0b0070
feat(mcp): add Emdash to the MCP server catalog
ubuntudroid b642aaa
feat(mcp): add standalone mcp/ stdio bridge package
ubuntudroid 55a5856
docs(mcp): document the built-in Emdash MCP server endpoint
ubuntudroid ac8a0c5
test(mcp): add tests for McpTaskServer, settings normalization, setti…
ubuntudroid bb60827
fix(mcp): update lockfile to match mcp/package.json sdk version
ubuntudroid 92cc155
fix(test): avoid TS2556 spread-into-mock in settingsIpc test
ubuntudroid a998f7d
fix(mcp): address CodeRabbit review comments
ubuntudroid 6958244
fix(mcp): handle mcpGetServerInfo() rejection in McpSettingsCard
ubuntudroid File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
| "name": "@emdash/mcp", | ||
| "version": "0.1.0", | ||
| "description": "Standalone MCP server for Emdash (alternative to the built-in HTTP endpoint)", | ||
| "type": "module", | ||
| "bin": { | ||
| "emdash-mcp": "./src/index.ts" | ||
| }, | ||
| "scripts": { | ||
| "start": "tsx src/index.ts", | ||
| "type-check": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "tsx": "^4.19.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^22.0.0", | ||
| "typescript": "^5.3.3" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,297 @@ | ||
| #!/usr/bin/env tsx | ||
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | ||
| import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { readFileSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { homedir } from 'os'; | ||
| import http from 'http'; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Config file resolution | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| function getEmdashUserDataPath(): string { | ||
| const platform = process.platform; | ||
| if (platform === 'darwin') { | ||
| return join(homedir(), 'Library', 'Application Support', 'Emdash'); | ||
| } else if (platform === 'win32') { | ||
| return join(process.env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming'), 'Emdash'); | ||
| } else { | ||
| // Linux / other | ||
| return join(process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config'), 'Emdash'); | ||
| } | ||
| } | ||
|
|
||
| interface McpTaskServerConfig { | ||
| port: number; | ||
| token: string; | ||
| } | ||
|
|
||
| function loadConfig(): McpTaskServerConfig { | ||
| const configPath = join(getEmdashUserDataPath(), 'mcp-task-server.json'); | ||
| try { | ||
| const raw = readFileSync(configPath, 'utf-8'); | ||
| const parsed = JSON.parse(raw) as unknown; | ||
| if ( | ||
| typeof parsed === 'object' && | ||
| parsed !== null && | ||
| typeof (parsed as Record<string, unknown>).port === 'number' && | ||
| typeof (parsed as Record<string, unknown>).token === 'string' | ||
| ) { | ||
| return parsed as McpTaskServerConfig; | ||
| } | ||
| throw new Error('Invalid config format'); | ||
| } catch (err) { | ||
| throw new Error( | ||
| `Failed to load Emdash MCP config from ${configPath}. ` + | ||
| `Make sure the Emdash desktop app is running. Error: ${err instanceof Error ? err.message : String(err)}` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // HTTP client helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| function httpRequest( | ||
| options: http.RequestOptions, | ||
| body?: string | ||
| ): Promise<{ statusCode: number; body: string }> { | ||
| return new Promise((resolve, reject) => { | ||
| const req = http.request(options, (res) => { | ||
| let data = ''; | ||
| res.on('data', (chunk: Buffer) => { | ||
| data += chunk.toString(); | ||
| }); | ||
| res.on('end', () => { | ||
| resolve({ statusCode: res.statusCode ?? 0, body: data }); | ||
| }); | ||
| }); | ||
| req.on('error', reject); | ||
| if (body) req.write(body); | ||
| req.end(); | ||
| }); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| async function getProjects( | ||
| config: McpTaskServerConfig | ||
| ): Promise<Array<{ id: string; name: string; path: string; isRemote: boolean }>> { | ||
| const result = await httpRequest({ | ||
| hostname: '127.0.0.1', | ||
| port: config.port, | ||
| path: '/api/projects', | ||
| method: 'GET', | ||
| headers: { 'x-emdash-token': config.token }, | ||
| }); | ||
|
|
||
| if (result.statusCode !== 200) { | ||
| throw new Error(`Failed to list projects: HTTP ${result.statusCode}`); | ||
| } | ||
|
|
||
| const parsed = JSON.parse(result.body) as { | ||
| projects: Array<{ id: string; name: string; path: string; isRemote: boolean }>; | ||
| }; | ||
| return parsed.projects; | ||
| } | ||
|
|
||
| async function createTask( | ||
| config: McpTaskServerConfig, | ||
| params: { projectId: string; prompt: string; taskName?: string; agentId?: string } | ||
| ): Promise<{ taskRequestId: string }> { | ||
| const body = JSON.stringify(params); | ||
| const result = await httpRequest( | ||
| { | ||
| hostname: '127.0.0.1', | ||
| port: config.port, | ||
| path: '/api/tasks', | ||
| method: 'POST', | ||
| headers: { | ||
| 'x-emdash-token': config.token, | ||
| 'Content-Type': 'application/json', | ||
| 'Content-Length': Buffer.byteLength(body), | ||
| }, | ||
| }, | ||
| body | ||
| ); | ||
|
|
||
| if (result.statusCode !== 202) { | ||
| let errorMsg = `HTTP ${result.statusCode}`; | ||
| try { | ||
| const parsed = JSON.parse(result.body) as { error?: string }; | ||
| if (parsed.error) errorMsg = parsed.error; | ||
| } catch {} | ||
| throw new Error(`Failed to create task: ${errorMsg}`); | ||
| } | ||
|
|
||
| return JSON.parse(result.body) as { taskRequestId: string }; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // MCP server | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const server = new Server( | ||
| { name: 'emdash', version: '0.1.0' }, | ||
| { | ||
| capabilities: { tools: {} }, | ||
| instructions: | ||
| 'Use list_projects to discover available project IDs, then create_task to queue ' + | ||
| 'an AI agent task in a project. Tasks run asynchronously inside the Emdash desktop app.', | ||
| } | ||
| ); | ||
|
|
||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
| tools: [ | ||
| { | ||
| name: 'list_projects', | ||
| description: | ||
| 'List all projects configured in the local Emdash desktop app. ' + | ||
| 'Call this first to get valid project IDs before calling create_task. ' + | ||
| "Returns each project's id, name, path, and whether it is remote.", | ||
| inputSchema: { | ||
| type: 'object' as const, | ||
| properties: {}, | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'create_task', | ||
| description: | ||
| 'Queue a new task in an existing Emdash project. Emdash will create a git worktree, ' + | ||
| 'save the task, and start the AI agent automatically — the call returns as soon as the ' + | ||
| 'task is queued, before the agent begins. Use list_projects first to find the project_id.', | ||
| inputSchema: { | ||
| type: 'object' as const, | ||
| properties: { | ||
| project_id: { | ||
| type: 'string', | ||
| description: 'ID of the project to run the task in. Obtain from list_projects.', | ||
| }, | ||
| prompt: { | ||
| type: 'string', | ||
| description: 'Instructions for the AI agent.', | ||
| }, | ||
| task_name: { | ||
| type: 'string', | ||
| description: | ||
| 'Human-readable task name shown in the Emdash UI. Auto-generated if omitted.', | ||
| }, | ||
| agent_id: { | ||
| type: 'string', | ||
| description: | ||
| 'Agent to use, e.g. "claude" or "codex". Defaults to "claude" when omitted.', | ||
| }, | ||
| }, | ||
| required: ['project_id', 'prompt'], | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| ], | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| })); | ||
|
|
||
| server.setRequestHandler(CallToolRequestSchema, async (request) => { | ||
| const { name, arguments: args } = request.params; | ||
|
|
||
| let config: McpTaskServerConfig; | ||
| try { | ||
| config = loadConfig(); | ||
| } catch (err) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text' as const, | ||
| text: `Error: ${err instanceof Error ? err.message : String(err)}`, | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
|
|
||
| if (name === 'list_projects') { | ||
| try { | ||
| const projects = await getProjects(config); | ||
| if (projects.length === 0) { | ||
| return { | ||
| content: [{ type: 'text' as const, text: 'No projects found in Emdash.' }], | ||
| }; | ||
| } | ||
| const lines = projects.map( | ||
| (p) => `• ${p.name} (id: ${p.id})${p.isRemote ? ' [remote]' : ''}\n path: ${p.path}` | ||
| ); | ||
| return { | ||
| content: [{ type: 'text' as const, text: lines.join('\n') }], | ||
| }; | ||
| } catch (err) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text' as const, | ||
| text: `Error listing projects: ${err instanceof Error ? err.message : String(err)}`, | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| if (name === 'create_task') { | ||
| const typedArgs = args as Record<string, unknown>; | ||
| const projectId = typedArgs['project_id']; | ||
| const prompt = typedArgs['prompt']; | ||
| const taskName = typedArgs['task_name']; | ||
| const agentId = typedArgs['agent_id']; | ||
|
|
||
| if (typeof projectId !== 'string' || !projectId) { | ||
| return { | ||
| content: [{ type: 'text' as const, text: 'Error: project_id is required' }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| if (typeof prompt !== 'string' || !prompt) { | ||
| return { | ||
| content: [{ type: 'text' as const, text: 'Error: prompt is required' }], | ||
| isError: true, | ||
| }; | ||
| } | ||
|
|
||
| try { | ||
| const result = await createTask(config, { | ||
| projectId, | ||
| prompt, | ||
| taskName: typeof taskName === 'string' ? taskName : undefined, | ||
| agentId: typeof agentId === 'string' ? agentId : undefined, | ||
| }); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text' as const, | ||
| text: `Task queued successfully (request ID: ${result.taskRequestId}). The Emdash app will start the agent shortly.`, | ||
| }, | ||
| ], | ||
| }; | ||
| } catch (err) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text' as const, | ||
| text: `Error creating task: ${err instanceof Error ? err.message : String(err)}`, | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }], | ||
| isError: true, | ||
| }; | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Start | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2022", | ||
| "module": "Node16", | ||
| "moduleResolution": "Node16", | ||
| "outDir": "./dist", | ||
| "rootDir": "./src", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "declaration": true | ||
| }, | ||
| "include": ["src/**/*"], | ||
| "exclude": ["node_modules", "dist"] | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.