Skip to content

Commit 1f2cf74

Browse files
committed
harness-mcp (squashed)
1 parent 50a9372 commit 1f2cf74

11 files changed

Lines changed: 566 additions & 6 deletions

File tree

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
"asarUnpack": [
4747
"**/node_modules/node-pty/**/*"
4848
],
49+
"extraResources": [
50+
{
51+
"from": "resources/mcp-bridge.js",
52+
"to": "mcp-bridge.js"
53+
}
54+
],
4955
"mac": {
5056
"category": "public.app-category.developer-tools",
5157
"icon": "resources/icon.icns",

resources/mcp-bridge.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#!/usr/bin/env node
2+
// Harness MCP bridge — minimal MCP stdio server that forwards tool calls
3+
// to the Harness control HTTP server running inside the Electron main process.
4+
// Spawned by Claude Code via `ELECTRON_RUN_AS_NODE=1 <electron-binary> <this>`.
5+
6+
const http = require('http')
7+
const readline = require('readline')
8+
9+
const PORT = process.env.HARNESS_PORT
10+
const TOKEN = process.env.HARNESS_TOKEN
11+
const TERMINAL_ID = process.env.HARNESS_TERMINAL_ID || ''
12+
13+
if (!PORT || !TOKEN) {
14+
process.stderr.write('harness-mcp: HARNESS_PORT and HARNESS_TOKEN required\n')
15+
process.exit(1)
16+
}
17+
18+
function send(msg) {
19+
process.stdout.write(JSON.stringify(msg) + '\n')
20+
}
21+
22+
function logErr(...args) {
23+
process.stderr.write('[harness-mcp] ' + args.join(' ') + '\n')
24+
}
25+
26+
function callControl(method, path, body) {
27+
return new Promise((resolve, reject) => {
28+
const data = body ? JSON.stringify(body) : undefined
29+
const req = http.request(
30+
{
31+
host: '127.0.0.1',
32+
port: Number(PORT),
33+
path,
34+
method,
35+
headers: {
36+
Authorization: 'Bearer ' + TOKEN,
37+
'Content-Type': 'application/json',
38+
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {})
39+
}
40+
},
41+
(res) => {
42+
let chunks = ''
43+
res.on('data', (c) => (chunks += c))
44+
res.on('end', () => {
45+
if (res.statusCode >= 200 && res.statusCode < 300) {
46+
try {
47+
resolve(chunks ? JSON.parse(chunks) : {})
48+
} catch (e) {
49+
reject(new Error('bad json from harness: ' + chunks))
50+
}
51+
} else {
52+
reject(new Error('harness HTTP ' + res.statusCode + ': ' + chunks))
53+
}
54+
})
55+
}
56+
)
57+
req.on('error', reject)
58+
if (data) req.write(data)
59+
req.end()
60+
})
61+
}
62+
63+
const TOOLS = [
64+
{
65+
name: 'create_worktree',
66+
description:
67+
'Create a new git worktree in a Harness-managed repo. Harness will open a new Claude chat tab inside the new worktree automatically.',
68+
inputSchema: {
69+
type: 'object',
70+
properties: {
71+
branchName: {
72+
type: 'string',
73+
description: 'Name of the new branch to create for the worktree.'
74+
},
75+
repoRoot: {
76+
type: 'string',
77+
description:
78+
'Absolute path to the repo root. Optional when only one repo is open in Harness.'
79+
},
80+
baseBranch: {
81+
type: 'string',
82+
description:
83+
"Branch to fork the new worktree from. Defaults to the repo's configured base."
84+
}
85+
},
86+
required: ['branchName']
87+
}
88+
},
89+
{
90+
name: 'list_worktrees',
91+
description: 'List git worktrees currently managed by Harness.',
92+
inputSchema: {
93+
type: 'object',
94+
properties: {
95+
repoRoot: {
96+
type: 'string',
97+
description: 'Optional repo root to filter by.'
98+
}
99+
}
100+
}
101+
},
102+
{
103+
name: 'list_repos',
104+
description: 'List the repo roots currently open in Harness.',
105+
inputSchema: { type: 'object', properties: {} }
106+
}
107+
]
108+
109+
async function handleToolCall(name, args) {
110+
if (name === 'create_worktree') {
111+
if (!args || !args.branchName) throw new Error('branchName is required')
112+
const r = await callControl('POST', '/worktrees', {
113+
terminalId: TERMINAL_ID,
114+
repoRoot: args.repoRoot,
115+
branchName: args.branchName,
116+
baseBranch: args.baseBranch
117+
})
118+
return (
119+
'Created worktree ' +
120+
r.path +
121+
' on branch ' +
122+
r.branch +
123+
'. Harness will open a new Claude chat tab in it.'
124+
)
125+
}
126+
if (name === 'list_worktrees') {
127+
const q =
128+
args && args.repoRoot ? '?repoRoot=' + encodeURIComponent(args.repoRoot) : ''
129+
const r = await callControl('GET', '/worktrees' + q)
130+
return JSON.stringify(r, null, 2)
131+
}
132+
if (name === 'list_repos') {
133+
const r = await callControl('GET', '/repos')
134+
return JSON.stringify(r, null, 2)
135+
}
136+
throw new Error('unknown tool: ' + name)
137+
}
138+
139+
async function handle(msg) {
140+
const { id, method, params } = msg
141+
try {
142+
if (method === 'initialize') {
143+
return {
144+
jsonrpc: '2.0',
145+
id,
146+
result: {
147+
protocolVersion: '2024-11-05',
148+
capabilities: { tools: {} },
149+
serverInfo: { name: 'harness-control', version: '1.0.0' }
150+
}
151+
}
152+
}
153+
if (method === 'notifications/initialized' || method === 'initialized') {
154+
return null
155+
}
156+
if (method === 'tools/list') {
157+
return { jsonrpc: '2.0', id, result: { tools: TOOLS } }
158+
}
159+
if (method === 'tools/call') {
160+
const text = await handleToolCall(
161+
params && params.name,
162+
(params && params.arguments) || {}
163+
)
164+
return {
165+
jsonrpc: '2.0',
166+
id,
167+
result: { content: [{ type: 'text', text }] }
168+
}
169+
}
170+
return {
171+
jsonrpc: '2.0',
172+
id,
173+
error: { code: -32601, message: 'Method not found: ' + method }
174+
}
175+
} catch (err) {
176+
const message = (err && err.message) || String(err)
177+
logErr('error', method, message)
178+
if (method === 'tools/call') {
179+
return {
180+
jsonrpc: '2.0',
181+
id,
182+
result: {
183+
content: [{ type: 'text', text: message }],
184+
isError: true
185+
}
186+
}
187+
}
188+
return {
189+
jsonrpc: '2.0',
190+
id,
191+
error: { code: -32603, message }
192+
}
193+
}
194+
}
195+
196+
const rl = readline.createInterface({ input: process.stdin })
197+
rl.on('line', async (line) => {
198+
if (!line.trim()) return
199+
let msg
200+
try {
201+
msg = JSON.parse(line)
202+
} catch {
203+
return
204+
}
205+
const response = await handle(msg)
206+
if (response) send(response)
207+
})
208+
rl.on('close', () => process.exit(0))

src/main/control-server.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { createServer, IncomingMessage, ServerResponse } from 'http'
2+
import { randomBytes } from 'crypto'
3+
import { addWorktree, listWorktrees, defaultWorktreeDir, WorktreeInfo } from './worktree'
4+
import { log } from './debug'
5+
6+
export interface ControlServerDeps {
7+
getRepoRoots: () => string[]
8+
getWorktreeBase: () => 'remote' | 'local'
9+
broadcast: (channel: string, ...args: unknown[]) => void
10+
}
11+
12+
let serverInfo: { port: number; token: string } | null = null
13+
14+
export function getControlServerInfo(): { port: number; token: string } | null {
15+
return serverInfo
16+
}
17+
18+
export function startControlServer(deps: ControlServerDeps): Promise<void> {
19+
return new Promise((resolve, reject) => {
20+
const token = randomBytes(32).toString('hex')
21+
22+
const server = createServer((req, res) => {
23+
handleRequest(req, res, token, deps).catch((err) => {
24+
log('control', 'handler threw', err instanceof Error ? err.message : String(err))
25+
if (!res.headersSent) {
26+
res.writeHead(500, { 'Content-Type': 'application/json' })
27+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
28+
}
29+
})
30+
})
31+
32+
server.listen(0, '127.0.0.1', () => {
33+
const addr = server.address()
34+
if (addr && typeof addr === 'object') {
35+
serverInfo = { port: addr.port, token }
36+
log('control', `listening on 127.0.0.1:${addr.port}`)
37+
resolve()
38+
} else {
39+
reject(new Error('failed to bind control server'))
40+
}
41+
})
42+
server.on('error', (err) => {
43+
log('control', 'server error', err.message)
44+
})
45+
})
46+
}
47+
48+
async function handleRequest(
49+
req: IncomingMessage,
50+
res: ServerResponse,
51+
token: string,
52+
deps: ControlServerDeps
53+
): Promise<void> {
54+
const auth = req.headers.authorization
55+
if (auth !== 'Bearer ' + token) {
56+
res.writeHead(401)
57+
res.end('unauthorized')
58+
return
59+
}
60+
61+
const url = new URL(req.url || '/', 'http://127.0.0.1')
62+
const path = url.pathname
63+
64+
if (req.method === 'GET' && path === '/health') {
65+
return sendJson(res, 200, { ok: true })
66+
}
67+
68+
if (req.method === 'GET' && path === '/repos') {
69+
return sendJson(res, 200, { repoRoots: deps.getRepoRoots() })
70+
}
71+
72+
if (req.method === 'GET' && path === '/worktrees') {
73+
const repoRoot = url.searchParams.get('repoRoot')
74+
const roots = repoRoot ? [repoRoot] : deps.getRepoRoots()
75+
const all: WorktreeInfo[] = []
76+
for (const r of roots) {
77+
try {
78+
all.push(...(await listWorktrees(r)))
79+
} catch (e) {
80+
log('control', `list worktrees failed for ${r}`, e instanceof Error ? e.message : e)
81+
}
82+
}
83+
return sendJson(res, 200, all)
84+
}
85+
86+
if (req.method === 'POST' && path === '/worktrees') {
87+
const body = await readJson(req)
88+
let repoRoot = typeof body.repoRoot === 'string' ? body.repoRoot : undefined
89+
if (!repoRoot) {
90+
const roots = deps.getRepoRoots()
91+
if (roots.length === 1) {
92+
repoRoot = roots[0]
93+
} else if (roots.length === 0) {
94+
return sendJson(res, 400, { error: 'no repos open in Harness' })
95+
} else {
96+
return sendJson(res, 400, {
97+
error: 'repoRoot required when multiple repos are open',
98+
repoRoots: roots
99+
})
100+
}
101+
}
102+
const branchName = String(body.branchName || '').trim()
103+
if (!branchName) {
104+
return sendJson(res, 400, { error: 'branchName required' })
105+
}
106+
const wtDir = defaultWorktreeDir(repoRoot)
107+
const mode = deps.getWorktreeBase()
108+
const created = await addWorktree(repoRoot, wtDir, branchName, {
109+
baseBranch: typeof body.baseBranch === 'string' ? body.baseBranch : undefined,
110+
fetchRemote: !body.baseBranch && mode === 'remote'
111+
})
112+
deps.broadcast('worktrees:externalCreate', { repoRoot, worktree: created })
113+
return sendJson(res, 200, created)
114+
}
115+
116+
res.writeHead(404)
117+
res.end('not found')
118+
}
119+
120+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
121+
res.writeHead(status, { 'Content-Type': 'application/json' })
122+
res.end(JSON.stringify(body))
123+
}
124+
125+
function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
126+
return new Promise((resolve, reject) => {
127+
const chunks: Buffer[] = []
128+
req.on('data', (c: Buffer) => chunks.push(c))
129+
req.on('end', () => {
130+
if (chunks.length === 0) return resolve({})
131+
try {
132+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')))
133+
} catch (e) {
134+
reject(e)
135+
}
136+
})
137+
req.on('error', reject)
138+
})
139+
}

0 commit comments

Comments
 (0)