phistruct-mcp 0.1.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.
Files changed (2) hide show
  1. package/cli.mjs +149 -0
  2. package/package.json +25 -0
package/cli.mjs ADDED
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, execFileSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { createConnection } from "node:net";
5
+ import { homedir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ export const BUNDLE_ID = "com.phistruct.app";
10
+ export const BINARY_NAME = "PhiStruct";
11
+ export const MCP_PORT_FIRST = 17321;
12
+ export const MCP_PORT_LAST = 17340;
13
+
14
+ export function isStdioMcpCommand(command) {
15
+ return /(?:^|\s)(?:--mcp|-mcp)(?:\s|$)/.test(command);
16
+ }
17
+
18
+ export function appBinary(appPath) {
19
+ return join(appPath, "Contents", "MacOS", BINARY_NAME);
20
+ }
21
+
22
+ export function defaultAppCandidates(home = homedir()) {
23
+ return [
24
+ "/Applications/PhiStruct.app",
25
+ join(home, "Applications", "PhiStruct.app"),
26
+ ];
27
+ }
28
+
29
+ export function pickExisting(paths, exists = existsSync) {
30
+ for (const path of paths) {
31
+ if (exists(path)) return path;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function parseMdfind(output) {
37
+ return output
38
+ .split("\n")
39
+ .map((line) => line.trim())
40
+ .filter((line) => line.endsWith(".app"));
41
+ }
42
+
43
+ export function findApp({
44
+ env = process.env,
45
+ exists = existsSync,
46
+ home = homedir(),
47
+ mdfind = runMdfind,
48
+ } = {}) {
49
+ if (env.PHISTRUCT_BIN && exists(env.PHISTRUCT_BIN)) return env.PHISTRUCT_BIN;
50
+ if (env.PHISTRUCT_APP) {
51
+ const bin = appBinary(env.PHISTRUCT_APP);
52
+ if (exists(bin)) return bin;
53
+ }
54
+ const listed = pickExisting(defaultAppCandidates(home), (path) => exists(appBinary(path)));
55
+ if (listed) return appBinary(listed);
56
+ for (const app of parseMdfind(mdfind())) {
57
+ const bin = appBinary(app);
58
+ if (exists(bin)) return bin;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function runMdfind() {
64
+ try {
65
+ return execFileSync("mdfind", [`kMDItemCFBundleIdentifier == "${BUNDLE_ID}"`], {
66
+ encoding: "utf8",
67
+ timeout: 4000,
68
+ });
69
+ } catch {
70
+ return "";
71
+ }
72
+ }
73
+
74
+ export function guiIsRunning(bin, listCommands = psCommands) {
75
+ return listCommands().some((command) => command.includes(bin) && !isStdioMcpCommand(command));
76
+ }
77
+
78
+ function psCommands() {
79
+ try {
80
+ return execFileSync("ps", ["-ax", "-o", "args="], { encoding: "utf8" }).split("\n");
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ export function tcpOpen(port, host = "127.0.0.1", timeoutMs = 250) {
87
+ return new Promise((resolveOpen) => {
88
+ const socket = createConnection({ host, port });
89
+ const done = (ok) => {
90
+ socket.removeAllListeners();
91
+ socket.destroy();
92
+ resolveOpen(ok);
93
+ };
94
+ socket.setTimeout(timeoutMs);
95
+ socket.once("connect", () => done(true));
96
+ socket.once("timeout", () => done(false));
97
+ socket.once("error", () => done(false));
98
+ });
99
+ }
100
+
101
+ export async function waitForLiveMcp({
102
+ first = MCP_PORT_FIRST,
103
+ last = MCP_PORT_LAST,
104
+ timeoutMs = 25_000,
105
+ probe = tcpOpen,
106
+ now = Date.now,
107
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
108
+ } = {}) {
109
+ const start = now();
110
+ while (now() - start < timeoutMs) {
111
+ for (let port = first; port <= last; port++) {
112
+ if (await probe(port)) return port;
113
+ }
114
+ await sleep(200);
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function fail(message) {
120
+ process.stderr.write(`${message}\n`);
121
+ process.exit(1);
122
+ }
123
+
124
+ async function main() {
125
+ if (process.platform !== "darwin") {
126
+ fail("PhiStruct MCP opens PhiStruct.app and only runs on macOS.");
127
+ }
128
+ const bin = findApp();
129
+ if (!bin) {
130
+ fail("PhiStruct.app not found. Install PhiStruct, then try again.");
131
+ }
132
+ if (!guiIsRunning(bin)) {
133
+ spawn(bin, [], { detached: true, stdio: "ignore" }).unref();
134
+ }
135
+ const port = await waitForLiveMcp();
136
+ if (port == null) {
137
+ fail("PhiStruct opened but MCP did not listen on 127.0.0.1:17321–17340.");
138
+ }
139
+ const child = spawn(bin, ["--mcp"], { stdio: "inherit" });
140
+ child.on("exit", (code, signal) => {
141
+ if (signal) process.kill(process.pid, signal);
142
+ process.exit(code ?? 1);
143
+ });
144
+ }
145
+
146
+ const entry = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
147
+ if (import.meta.url === entry) {
148
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
149
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "phistruct-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "MCP stdio bridge that opens PhiStruct.app for Cursor, Claude, and other IDEs",
6
+ "bin": {
7
+ "phistruct-mcp": "./cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs"
11
+ ],
12
+ "scripts": {
13
+ "test": "node --test test/*.test.mjs"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "phistruct"
24
+ ]
25
+ }