Skip to content

Commit 9ded4fd

Browse files
committed
feat: add the mcp:start command for A4D
1 parent 9bc0afc commit 9ded4fd

File tree

4 files changed

+833
-16
lines changed

4 files changed

+833
-16
lines changed

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@heroku/eventsource": "^1.0.7",
1616
"@heroku/heroku-cli-util": "^9.0.2",
1717
"@heroku/http-call": "^5.4.0",
18+
"@heroku/mcp-server": "1.0.7-alpha.1",
1819
"@heroku/plugin-ai": "^1.0.1",
1920
"@inquirer/prompts": "^5.0.5",
2021
"@oclif/core": "^2.16.0",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {Command, flags} from '@heroku-cli/command'
2+
import {spawn} from 'child_process'
3+
import {join} from 'path'
4+
5+
export default class MCPStart extends Command {
6+
static description = 'starts the Heroku platform MCP server in stdio mode'
7+
static hidden = true
8+
9+
static flags = {
10+
help: flags.help({char: 'h'}),
11+
}
12+
13+
public async run() {
14+
const serverPath = join(require.resolve('@heroku/mcp-server'), '../../bin/heroku-mcp-server.mjs')
15+
const server = spawn('node', [serverPath], {
16+
stdio: ['pipe', 'pipe', 'pipe'],
17+
shell: true,
18+
})
19+
20+
// Pipe all stdio streams
21+
process.stdin.pipe(server.stdin)
22+
server.stdout.pipe(process.stdout)
23+
server.stderr.pipe(process.stderr)
24+
25+
// Handle process termination
26+
process.on('SIGINT', () => {
27+
server.kill('SIGINT')
28+
})
29+
process.on('SIGTERM', () => {
30+
server.kill('SIGTERM')
31+
})
32+
33+
return server
34+
}
35+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {expect} from 'chai'
2+
import {stderr, stdout} from 'stdout-stderr'
3+
import runCommand from '../../../helpers/runCommand'
4+
import MCPStart from '../../../../src/commands/mcp/start'
5+
import * as child_process from 'child_process'
6+
7+
// Mock child_process.spawn
8+
const mockPipe: (...args: any[]) => any = function (this: any) {
9+
return this
10+
}
11+
12+
const mockOn: (...args: any[]) => any = function (this: any) {
13+
return this
14+
}
15+
16+
const mockOnce: (...args: any[]) => any = function (this: any) {
17+
return this
18+
}
19+
20+
const mockStream: any = {
21+
pipe: function (this: any, ..._args: any[]) {
22+
return this
23+
},
24+
on: function (this: any, ..._args: any[]) {
25+
return this
26+
},
27+
once: function (this: any, ..._args: any[]) {
28+
return this
29+
},
30+
unpipe: function (this: any, ..._args: any[]) {
31+
return this
32+
},
33+
removeListener: function (this: any, ..._args: any[]) {
34+
return this
35+
},
36+
addListener: function (this: any, ..._args: any[]) {
37+
return this
38+
},
39+
emit: function (this: any, ..._args: any[]) {
40+
return true
41+
},
42+
}
43+
44+
const mockKill = function () {
45+
return true
46+
}
47+
48+
const mockServer: any = {
49+
stdin: Object.create(mockStream),
50+
stdout: Object.create(mockStream),
51+
stderr: Object.create(mockStream),
52+
kill: mockKill,
53+
}
54+
55+
describe('mcp:start', function () {
56+
let origSpawn: any
57+
let origOn: any
58+
let signalHandlers: Record<string, (...args: any[]) => any> = {}
59+
60+
beforeEach(function () {
61+
origSpawn = child_process.spawn
62+
Object.defineProperty(child_process, 'spawn', {
63+
value: (...args: any[]) => mockServer,
64+
configurable: true,
65+
writable: true,
66+
})
67+
origOn = process.on
68+
// @ts-expect-error override for test: process.on is not assignable
69+
process.on = (sig: string, handler: (...args: any[]) => any) => {
70+
signalHandlers[sig] = handler
71+
}
72+
73+
signalHandlers = {}
74+
stdout.start()
75+
stderr.start()
76+
})
77+
78+
afterEach(function () {
79+
Object.defineProperty(child_process, 'spawn', {
80+
value: origSpawn,
81+
configurable: true,
82+
writable: true,
83+
})
84+
process.on = origOn
85+
stdout.stop()
86+
stderr.stop()
87+
})
88+
89+
it('spawns the server with correct arguments and pipes stdio', async function () {
90+
let calledArgs: any[] = []
91+
Object.defineProperty(child_process, 'spawn', {
92+
value: (...args: any[]) => {
93+
calledArgs = args
94+
return mockServer
95+
},
96+
configurable: true,
97+
writable: true,
98+
})
99+
const result = await runCommand(MCPStart, [])
100+
expect(calledArgs[0]).to.equal('node')
101+
expect(calledArgs[1][0]).to.include('heroku-mcp-server.mjs')
102+
expect(calledArgs[2]).to.include({shell: true})
103+
expect(calledArgs[2].stdio).to.eql(['pipe', 'pipe', 'pipe'])
104+
// Check that piping was called (mockPipe is a no-op, but no error means it was called)
105+
expect(result).to.equal(mockServer)
106+
})
107+
108+
it('handles SIGINT and SIGTERM signals', async function () {
109+
await runCommand(MCPStart, [])
110+
expect(signalHandlers).to.have.property('SIGINT')
111+
expect(signalHandlers).to.have.property('SIGTERM')
112+
})
113+
114+
it('returns the server process', async function () {
115+
const result = await runCommand(MCPStart, [])
116+
expect(result).to.equal(mockServer)
117+
})
118+
})

0 commit comments

Comments
 (0)