Skip to content

Commit d17f34d

Browse files
committed
Migrate MCP setup from the desktop into the server
I think desktop inclusion is going to be very short lived, moving instead to the server gives us more control, and makes it usable standalone which is helpful especially for testing & local dev.
1 parent 2260a05 commit d17f34d

File tree

13 files changed

+1659
-28
lines changed

13 files changed

+1659
-28
lines changed

bin/run

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
11
#!/usr/bin/env node
22

3+
// The ctl and mcp commands handle --help themselves with dynamic content.
4+
// Oclif's Main class intercepts --help before dispatching to any command,
5+
// so we remap it to --show-help to bypass that interception.
6+
const cmd = process.argv[2];
7+
if (cmd === 'ctl' || cmd === 'mcp') {
8+
for (let i = 3; i < process.argv.length; i++) {
9+
if (process.argv[i] === '--help' || process.argv[i] === '-h') {
10+
process.argv[i] = '--show-help';
11+
}
12+
if (process.argv[i] === '--') break;
13+
}
14+
}
15+
316
require('@oclif/command').run()
4-
.catch(require('@oclif/errors/handle'))
17+
.catch(require('@oclif/errors/handle'))

package-lock.json

Lines changed: 9 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@
9898
"tmp": "0.2.4",
9999
"tslib": "^1.9.3",
100100
"usbmux-client": "^0.2.1",
101-
"win-version-info": "^5.0.1"
101+
"win-version-info": "^5.0.1",
102+
"ws": "^8.18.0"
102103
},
103104
"devDependencies": {
104105
"@oclif/dev-cli": "^1.19.4",
@@ -119,7 +120,7 @@
119120
"@types/node-forge": "^0.9.9",
120121
"@types/request-promise-native": "^1.0.15",
121122
"@types/rimraf": "^2.0.2",
122-
"@types/ws": "^6.0.1",
123+
"@types/ws": "^8.5.0",
123124
"axios": "^1.8.2",
124125
"bent": "^1.5.13",
125126
"chai": "^4.2.0",

src/api/api-model.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
14
import * as _ from 'lodash';
25
import * as os from 'os';
6+
import * as semver from 'semver';
37

48
import { ErrorLike, delay } from '@httptoolkit/util';
59
import { generateSPKIFingerprint } from 'mockttp';
610
import { getSystemProxy } from 'os-proxy-config';
711

8-
import { SERVER_VERSION } from "../constants";
12+
import { APP_ROOT, SERVER_VERSION } from "../constants";
913
import { logError, addBreadcrumb } from '../error-tracking';
1014

1115
import { HtkConfig } from "../config";
@@ -18,6 +22,50 @@ import { HttpClient } from '../client/http-client';
1822

1923
const INTERCEPTOR_TIMEOUT = 1000;
2024

25+
/**
26+
* Returns the command + args needed to invoke the ctl and mcp tools.
27+
*
28+
* If HTK_TOOLS_PATH is set (by the desktop app), wrapper scripts in that
29+
* directory are used. Otherwise the server's own binary is used with ctl/mcp
30+
* subcommands, stabilized via the oclif 'current' symlink when available.
31+
*/
32+
function getToolPaths(): { ctl: string[]; mcp: string[] } {
33+
// If we're running via a modern desktop app, we can use the path provided directly:
34+
const toolsPath = process.env.HTK_TOOLS_PATH;
35+
if (toolsPath) {
36+
const ext = process.platform === 'win32' ? '.cmd' : '';
37+
return {
38+
ctl: [path.join(toolsPath, `httptoolkit-ctl${ext}`)],
39+
mcp: [path.join(toolsPath, `httptoolkit-mcp${ext}`)]
40+
};
41+
}
42+
43+
// If not (old desktop, local dev) we need to use our own path directly:
44+
const serverBin = stabilizeServerBinPath();
45+
return {
46+
ctl: [serverBin, 'ctl'],
47+
mcp: [serverBin, 'mcp']
48+
};
49+
}
50+
51+
function stabilizeServerBinPath(): string {
52+
const binPath = process.env.HTTPTOOLKIT_SERVER_BINPATH
53+
?? path.join(APP_ROOT, 'bin', process.platform === 'win32' ? 'run.cmd' : 'run');
54+
55+
// If the server is running from a versioned oclif directory (e.g. .../client/1.25.0/...),
56+
// replace the version segment with 'current' for a path that survives updates.
57+
const appRootBase = path.basename(APP_ROOT);
58+
if (semver.valid(appRootBase) && binPath.startsWith(APP_ROOT)) {
59+
const currentDir = path.join(path.dirname(APP_ROOT), 'current');
60+
try {
61+
fs.accessSync(currentDir);
62+
return currentDir + binPath.slice(APP_ROOT.length);
63+
} catch {}
64+
}
65+
66+
return binPath;
67+
}
68+
2169
export class ApiModel {
2270

2371
constructor(
@@ -83,7 +131,9 @@ export class ApiModel {
83131
systemProxy,
84132
dnsServers,
85133

86-
ruleParameterKeys: this.getRuleParamKeys()
134+
ruleParameterKeys: this.getRuleParamKeys(),
135+
136+
toolPaths: getToolPaths()
87137
};
88138
}
89139

src/api/api-server.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import * as http from 'http';
12
import _ from 'lodash';
23
import * as events from 'events';
34
import express from 'express';
45
import cors from 'cors';
56
import corsGate from 'cors-gate';
7+
import * as WebSocket from 'ws';
68

79
import { HtkConfig } from '../config';
810
import { buildInterceptors } from '../interceptors';
@@ -13,6 +15,7 @@ import { ApiModel } from './api-model';
1315
import { exposeGraphQLAPI } from './graphql-api';
1416
import { exposeRestAPI } from './rest-api';
1517
import { HttpClient } from '../client/http-client';
18+
import { UiOperationBridge, getSocketPath } from './ui-operation-bridge';
1619

1720
/**
1821
* This file contains the core server API, used by the UI to query
@@ -39,6 +42,8 @@ import { HttpClient } from '../client/http-client';
3942
export class HttpToolkitServerApi extends events.EventEmitter {
4043

4144
private server: express.Application;
45+
private bridge: UiOperationBridge;
46+
private authToken: string | undefined;
4247

4348
constructor(
4449
config: HtkConfig,
@@ -47,6 +52,9 @@ export class HttpToolkitServerApi extends events.EventEmitter {
4752
) {
4853
super();
4954

55+
this.bridge = new UiOperationBridge();
56+
this.authToken = config.authToken;
57+
5058
const interceptors = buildInterceptors(config);
5159

5260
this.server = express();
@@ -134,8 +142,64 @@ export class HttpToolkitServerApi extends events.EventEmitter {
134142

135143
start() {
136144
return new Promise<void>((resolve, reject) => {
137-
this.server.listen(45457, '127.0.0.1', resolve); // Localhost only
138-
this.server.once('error', reject);
145+
const httpServer: http.Server = this.server.listen(45457, '127.0.0.1', resolve);
146+
httpServer.once('error', reject);
147+
148+
this.attachWebSocketBridge(httpServer);
149+
this.startBridgeApiServer();
150+
});
151+
}
152+
153+
private attachWebSocketBridge(httpServer: http.Server) {
154+
const wss = new WebSocket.Server({ noServer: true });
155+
156+
httpServer.on('upgrade', (req: http.IncomingMessage, socket, head) => {
157+
const url = new URL(req.url!, `http://localhost`);
158+
159+
if (url.pathname !== '/ui-operations') {
160+
socket.destroy();
161+
return;
162+
}
163+
164+
// Enforce the same origin restrictions as the REST/GraphQL API
165+
const origin = req.headers['origin'] || '';
166+
if (!ALLOWED_ORIGINS.some(pattern => pattern.test(origin))) {
167+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
168+
socket.destroy();
169+
return;
170+
}
171+
172+
// Check auth token if configured. Browser WebSocket can't send
173+
// headers, so accept token via query param or Authorization header.
174+
if (this.authToken) {
175+
const authHeader = req.headers['authorization'] || '';
176+
const tokenMatch = authHeader.match(/Bearer (\S+)/) || [];
177+
const headerToken = tokenMatch[1];
178+
const queryToken = url.searchParams.get('token');
179+
180+
if (headerToken !== this.authToken && queryToken !== this.authToken) {
181+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
182+
socket.destroy();
183+
return;
184+
}
185+
}
186+
187+
wss.handleUpgrade(req, socket as any, head, (ws) => {
188+
this.bridge.setWebSocket(ws as any);
189+
});
139190
});
140191
}
192+
193+
private startBridgeApiServer() {
194+
try {
195+
const socketPath = getSocketPath();
196+
this.bridge.startApiServer(socketPath);
197+
} catch (err: any) {
198+
console.warn(
199+
`Failed to start UI Bridge socket server: ${err.message}. ` +
200+
`MCP & remote control will not be available.`
201+
);
202+
}
203+
}
204+
141205
};

src/api/bridge-client.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import * as http from 'http';
2+
3+
import { getDeferred } from '@httptoolkit/util';
4+
5+
import { getSocketPath } from './ui-operation-bridge';
6+
7+
export function apiRequest(
8+
method: 'GET' | 'POST',
9+
urlPath: string,
10+
body?: any
11+
): Promise<any> {
12+
const result = getDeferred<any>();
13+
14+
const req = http.request({
15+
method,
16+
path: urlPath,
17+
socketPath: getSocketPath(),
18+
headers: {
19+
'Content-Type': 'application/json'
20+
}
21+
}, (res) => {
22+
const chunks: Buffer[] = [];
23+
res.on('error', (err) => result.reject(err));
24+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
25+
res.on('end', () => {
26+
const raw = Buffer.concat(chunks).toString('utf-8');
27+
if (res.statusCode && res.statusCode >= 400) {
28+
try {
29+
const err = JSON.parse(raw);
30+
result.reject(new Error(err.message || err.error || `HTTP ${res.statusCode}`));
31+
} catch {
32+
result.reject(new Error(`HTTP ${res.statusCode}: ${raw}`));
33+
}
34+
return;
35+
}
36+
try {
37+
result.resolve(JSON.parse(raw));
38+
} catch {
39+
result.reject(new Error(`Unparseable response: ${raw}`));
40+
}
41+
});
42+
});
43+
44+
req.on('error', (err: any) => {
45+
if (err.code === 'ECONNREFUSED' || err.code === 'ENOENT') {
46+
result.reject(new Error('HTTP Toolkit is not running. Start HTTP Toolkit first.'));
47+
} else {
48+
result.reject(err);
49+
}
50+
});
51+
52+
if (body) {
53+
req.write(JSON.stringify(body));
54+
}
55+
req.end();
56+
57+
return result;
58+
}

0 commit comments

Comments
 (0)