Skip to content

Commit 90fcb55

Browse files
apocryphxclaude
andcommitted
v1.2.0 — drop file IO, hardcode localhost:59123
The bridge no longer reads any file outside its own bundle. That eliminates every macOS TCC prompt we've been chasing: no cross-container reads, no schema-cache indirection, no ad-hoc-signature identity churn on each .mcpb release. Design changes: - main.m: removed DiscoverServerURL, DiscoverServerURLWithPolling, HOST_BUNDLE_ID, gServerVersion, and the entire server.plist code path. Replaced with a single kServerURL constant pointing at http://localhost:59123/mcp. Main loop is now forward-or-degraded with a gHostReachable flag for clean log-transition messages. - Port 59123: IANA dynamic/private range, not a common service. Replaces 5000 which is occupied by macOS AirPlay Receiver on most Macs. - Degraded mode unchanged in shape: stub initialize + one es_memory_setup tool + isError tools/call, auto-recovers on the next successful forward. Requires ES Memory v1.0.5+ which binds to port 59123 (see host repo commit 49b8210). README updated with new "How it works" section and port rationale. Info.plist + pbxproj: bundle ID restored to com.elarity.es-memory-mcp (same as host). Xcode project uses $(PRODUCT_BUNDLE_IDENTIFIER) macro. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee2c774 commit 90fcb55

5 files changed

Lines changed: 73 additions & 155 deletions

File tree

ES-Memory-Bridge.xcodeproj/project.pbxproj

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,6 @@
1818
};
1919
/* End PBXCopyFilesBuildPhase section */
2020

21-
/* Begin PBXShellScriptBuildPhase section */
22-
15B0789A2F91D80000C19CBA /* Package MCPB */ = {
23-
isa = PBXShellScriptBuildPhase;
24-
buildActionMask = 2147483647;
25-
files = (
26-
);
27-
inputPaths = (
28-
"$(BUILT_PRODUCTS_DIR)/$(PRODUCT_NAME)",
29-
"$(SRCROOT)/bundle/manifest.json",
30-
"$(SRCROOT)/bundle/icon.png",
31-
);
32-
name = "Package MCPB";
33-
outputPaths = (
34-
"$(SRCROOT)/ES-Memory-Bridge.mcpb",
35-
);
36-
runOnlyForDeploymentPostprocessing = 0;
37-
shellPath = /bin/zsh;
38-
shellScript = "# Copy built binary into bundle\nmkdir -p \"${SRCROOT}/bundle/server\"\ncp \"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}\" \"${SRCROOT}/bundle/server/${PRODUCT_NAME}\"\n\n# Re-sign for distribution (codesign is stripped by cp in some contexts)\ncodesign --force --sign - -o runtime \"${SRCROOT}/bundle/server/${PRODUCT_NAME}\"\n\n# Pack MCPB\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nnpx @anthropic-ai/mcpb pack \"${SRCROOT}/bundle/\" \"${SRCROOT}/ES-Memory-Bridge.mcpb\"\n";
39-
};
40-
/* End PBXShellScriptBuildPhase section */
41-
4221
/* Begin PBXFileReference section */
4322
15B0787D2F91D67F00C19CBA /* ES-Memory-Bridge */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "ES-Memory-Bridge"; sourceTree = BUILT_PRODUCTS_DIR; };
4423
/* End PBXFileReference section */
@@ -137,6 +116,27 @@
137116
};
138117
/* End PBXProject section */
139118

119+
/* Begin PBXShellScriptBuildPhase section */
120+
15B0789A2F91D80000C19CBA /* Package MCPB */ = {
121+
isa = PBXShellScriptBuildPhase;
122+
buildActionMask = 2147483647;
123+
files = (
124+
);
125+
inputPaths = (
126+
"$(BUILT_PRODUCTS_DIR)/$(PRODUCT_NAME)",
127+
"$(SRCROOT)/bundle/manifest.json",
128+
"$(SRCROOT)/bundle/icon.png",
129+
);
130+
name = "Package MCPB";
131+
outputPaths = (
132+
"$(SRCROOT)/ES-Memory-Bridge.mcpb",
133+
);
134+
runOnlyForDeploymentPostprocessing = 0;
135+
shellPath = /bin/zsh;
136+
shellScript = "# Copy built binary into bundle\nmkdir -p \"${SRCROOT}/bundle/server\"\ncp \"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}\" \"${SRCROOT}/bundle/server/${PRODUCT_NAME}\"\n\n# Re-sign for distribution (codesign is stripped by cp in some contexts)\ncodesign --force --sign - -o runtime \"${SRCROOT}/bundle/server/${PRODUCT_NAME}\"\n\n# Pack MCPB\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nnpx @anthropic-ai/mcpb pack \"${SRCROOT}/bundle/\" \"${SRCROOT}/ES-Memory-Bridge.mcpb\"\n";
137+
};
138+
/* End PBXShellScriptBuildPhase section */
139+
140140
/* Begin PBXSourcesBuildPhase section */
141141
15B078792F91D67F00C19CBA /* Sources */ = {
142142
isa = PBXSourcesBuildPhase;
@@ -271,9 +271,10 @@
271271
CODE_SIGN_STYLE = Automatic;
272272
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
273273
DEVELOPMENT_TEAM = 2PYWYF3C55;
274+
ENABLE_APP_SANDBOX = NO;
274275
ENABLE_HARDENED_RUNTIME = YES;
275276
INFOPLIST_FILE = "ES-Memory-Bridge/Info.plist";
276-
PRODUCT_BUNDLE_IDENTIFIER = "com.elarity.es-memory-bridge";
277+
PRODUCT_BUNDLE_IDENTIFIER = "com.elarity.es-memory-mcp";
277278
PRODUCT_NAME = "$(TARGET_NAME)";
278279
};
279280
name = Debug;
@@ -284,9 +285,10 @@
284285
CODE_SIGN_STYLE = Automatic;
285286
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
286287
DEVELOPMENT_TEAM = 2PYWYF3C55;
288+
ENABLE_APP_SANDBOX = NO;
287289
ENABLE_HARDENED_RUNTIME = YES;
288290
INFOPLIST_FILE = "ES-Memory-Bridge/Info.plist";
289-
PRODUCT_BUNDLE_IDENTIFIER = "com.elarity.es-memory-bridge";
291+
PRODUCT_BUNDLE_IDENTIFIER = "com.elarity.es-memory-mcp";
290292
PRODUCT_NAME = "$(TARGET_NAME)";
291293
};
292294
name = Release;

ES-Memory-Bridge/Info.plist

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
<plist version="1.0">
44
<dict>
55
<key>CFBundleIdentifier</key>
6-
<string>com.elarity.es-memory-bridge</string>
6+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
77
<key>CFBundleName</key>
88
<string>ES Memory Bridge</string>
9-
<key>CFBundleVersion</key>
10-
<string>1</string>
119
<key>CFBundleShortVersionString</key>
1210
<string>1.0</string>
11+
<key>CFBundleVersion</key>
12+
<string>1</string>
1313
</dict>
1414
</plist>

ES-Memory-Bridge/main.m

Lines changed: 29 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -6,90 +6,28 @@
66
//
77
// Claude Desktop launches this CLT (packaged inside an .mcpb bundle) as a
88
// subprocess. It reads JSON-RPC messages from stdin, forwards each to the
9-
// ES Memory app's locally-running HTTP server, and writes the response to
10-
// stdout.
9+
// ES Memory app's locally-running HTTP server, and writes the response
10+
// back to stdout.
1111
//
12-
// Discovery: reads server.plist from the host's sandbox container at
13-
// ~/Library/Containers/<HOST_BUNDLE_ID>/Data/Library/Application Support/ES-Memory/server.plist
14-
// The plist contains the full MCP endpoint URL and the host's version.
12+
// The bridge reads zero files. The host is expected to listen at a fixed
13+
// URL (localhost:59123/mcp) — an exotic, IANA-dynamic-range port chosen to
14+
// avoid conflicts with common local services (AirPlay sits on 5000, etc.).
1515
//
16-
// If the host isn't running on startup, the bridge polls for ~5s, then enters
17-
// a degraded mode that responds to MCP requests locally with a setup-help
18-
// message instead of failing silently. It re-attempts discovery on every
19-
// request, so it auto-recovers when the host comes up.
16+
// If the host isn't reachable, the bridge responds locally to MCP requests
17+
// with a setup-help message so Claude can surface a clear error in the
18+
// conversation. It auto-recovers on the next successful forward.
19+
//
20+
// No file IO = no TCC prompts, ever. That's the whole point of this
21+
// revision.
2022
//
2123

2224
#import <Foundation/Foundation.h>
2325
#include <signal.h>
2426

25-
// The host app's bundle ID. The bridge reads server.plist from the host's
26-
// sandbox container at this ID. Change this and the bridge points at a
27-
// different host app.
28-
#define HOST_BUNDLE_ID @"com.elarity.es-memory-mcp"
27+
static NSString *const kServerURL = @"http://localhost:59123/mcp";
2928

3029
static NSURL *gServerURL = nil;
31-
static NSString *gServerVersion = nil;
32-
33-
#pragma mark - Server Discovery
34-
35-
/// Read server.plist from the host's sandbox container.
36-
/// quiet=YES suppresses stderr output (used during polling so we don't spam logs).
37-
static NSURL *DiscoverServerURL(BOOL quiet) {
38-
NSString *plistPath = [NSString stringWithFormat:
39-
@"%@/Library/Containers/%@/Data/Library/Application Support/ES-Memory/server.plist",
40-
NSHomeDirectory(), HOST_BUNDLE_ID];
41-
42-
NSDictionary *info = [NSDictionary dictionaryWithContentsOfFile:plistPath];
43-
if (!info) {
44-
if (!quiet) {
45-
fprintf(stderr, "[es-bridge] server.plist not found at %s\n", plistPath.UTF8String);
46-
fprintf(stderr, "[es-bridge] Is ES Memory MCP running?\n");
47-
}
48-
return nil;
49-
}
50-
51-
NSString *urlString = info[@"url"];
52-
if (urlString.length == 0) {
53-
if (!quiet) fprintf(stderr, "[es-bridge] server.plist missing 'url' key\n");
54-
return nil;
55-
}
56-
57-
NSURL *url = [NSURL URLWithString:urlString];
58-
if (!url) {
59-
if (!quiet) fprintf(stderr, "[es-bridge] invalid URL in server.plist: %s\n",
60-
urlString.UTF8String);
61-
return nil;
62-
}
63-
64-
NSString *version = info[@"version"];
65-
if (version.length > 0) gServerVersion = version;
66-
67-
if (!quiet) {
68-
fprintf(stderr, "[es-bridge] using %s\n", plistPath.UTF8String);
69-
if (gServerVersion) {
70-
fprintf(stderr, "[es-bridge] ES Memory v%s\n", gServerVersion.UTF8String);
71-
}
72-
}
73-
return url;
74-
}
75-
76-
/// Try discovery once verbosely; if that misses, poll every 500ms for up to 5s
77-
/// (quiet, so logs aren't spammed). On a polled hit, emit the verbose diagnostic.
78-
static NSURL *DiscoverServerURLWithPolling(void) {
79-
NSURL *url = DiscoverServerURL(NO);
80-
if (url) return url;
81-
fprintf(stderr, "[es-bridge] waiting up to 5s for ES Memory...\n");
82-
for (int i = 0; i < 10; i++) {
83-
[NSThread sleepForTimeInterval:0.5];
84-
url = DiscoverServerURL(YES);
85-
if (url) {
86-
(void)DiscoverServerURL(NO); // re-emit the path + version diagnostic
87-
fprintf(stderr, "[es-bridge] connected after %dms\n", (i + 1) * 500);
88-
return url;
89-
}
90-
}
91-
return nil;
92-
}
30+
static BOOL gHostReachable = YES; // optimism; flipped on first forward failure
9331

9432
#pragma mark - HTTP Forwarding
9533

@@ -201,14 +139,9 @@ int main(int argc, const char *argv[]) {
201139
@autoreleasepool {
202140
signal(SIGPIPE, SIG_IGN);
203141

204-
gServerURL = DiscoverServerURLWithPolling();
205-
if (gServerURL) {
206-
fprintf(stderr, "[es-bridge] connected to %s\n",
207-
gServerURL.absoluteString.UTF8String);
208-
} else {
209-
fprintf(stderr, "[es-bridge] entering degraded mode — will respond locally "
210-
"with setup help; auto-recovers if ES Memory starts.\n");
211-
}
142+
gServerURL = [NSURL URLWithString:kServerURL];
143+
fprintf(stderr, "[es-bridge] forwarding to %s (static URL, no discovery)\n",
144+
kServerURL.UTF8String);
212145

213146
NSFileHandle *stdinHandle = [NSFileHandle fileHandleWithStandardInput];
214147
NSFileHandle *stdoutHandle = [NSFileHandle fileHandleWithStandardOutput];
@@ -236,44 +169,29 @@ int main(int argc, const char *argv[]) {
236169
[NSCharacterSet whitespaceCharacterSet]];
237170
if (line.length == 0) continue;
238171

239-
// Lazy retry — the host may have just launched.
240-
if (!gServerURL) {
241-
gServerURL = DiscoverServerURL(YES);
242-
if (gServerURL) {
243-
fprintf(stderr, "[es-bridge] recovered: connected to %s\n",
244-
gServerURL.absoluteString.UTF8String);
245-
}
246-
}
247-
172+
NSError *error = nil;
173+
NSString *response = ForwardRequest(line, &error);
248174
NSString *output = nil;
249175

250-
if (gServerURL) {
251-
NSError *error = nil;
252-
NSString *response = ForwardRequest(line, &error);
253-
if (response) {
254-
output = response;
255-
} else if (error) {
256-
// Connection failed — host probably went down. Drop the
257-
// cached URL so the next request re-attempts discovery,
258-
// and respond from the degraded handler for this one.
259-
fprintf(stderr, "[es-bridge] forward error: %s — dropping cached URL\n",
176+
if (response) {
177+
if (!gHostReachable) {
178+
fprintf(stderr, "[es-bridge] host recovered — resuming forwarding\n");
179+
gHostReachable = YES;
180+
}
181+
output = response;
182+
} else if (error) {
183+
if (gHostReachable) {
184+
fprintf(stderr, "[es-bridge] host unreachable: %s — degraded mode\n",
260185
error.localizedDescription.UTF8String);
261-
gServerURL = nil;
262-
gServerVersion = nil;
263-
NSDictionary *msg = [NSJSONSerialization JSONObjectWithData:lineData
264-
options:0 error:nil];
265-
if ([msg isKindOfClass:[NSDictionary class]]) {
266-
output = DegradedResponseForRequest(msg);
267-
}
186+
gHostReachable = NO;
268187
}
269-
// response nil + no error → 202 ack, no output needed.
270-
} else {
271188
NSDictionary *msg = [NSJSONSerialization JSONObjectWithData:lineData
272189
options:0 error:nil];
273190
if ([msg isKindOfClass:[NSDictionary class]]) {
274191
output = DegradedResponseForRequest(msg);
275192
}
276193
}
194+
// response nil + no error → 202 ack from host, no output needed.
277195

278196
if (output) {
279197
[stdoutHandle writeData:[[output stringByAppendingString:@"\n"]

README.md

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,33 +44,31 @@ The build's "Package MCPB" run-script phase produces `ES-Memory-Bridge.mcpb` at
4444
## How it works
4545

4646
1. Claude Desktop launches the bridge from the unpacked `.mcpb`.
47-
2. The bridge reads `server.plist` from the host's sandbox container:
47+
2. The bridge forwards each JSON-RPC line from stdin to a fixed URL:
4848
```
49-
~/Library/Containers/com.elarity.es-memory-mcp/Data/Library/Application Support/ES-Memory/server.plist
49+
http://localhost:59123/mcp
5050
```
51-
The host's bundle ID is a compile-time constant in [main.m](ES-Memory-Bridge/main.m) — the bridge has its own distinct ID (`com.elarity.es-memory-bridge`).
52-
3. `server.plist` contains the full MCP endpoint URL (e.g. `http://localhost:5000/mcp`) and the host's version string.
53-
4. The bridge forwards each JSON-RPC line from stdin to that URL via POST and writes the response back to stdout.
51+
It writes the HTTP response back to stdout. That's the whole hot path.
5452

55-
### When ES Memory isn't running
53+
The bridge reads **zero files** — no config, no discovery, no shared state with the host. No file IO means no macOS TCC prompts, ever.
5654

57-
The bridge doesn't fail silently. Two safety nets:
55+
### The port choice
5856

59-
- **Startup polling** — if `server.plist` is missing on launch, the bridge polls every 500ms for up to 5 seconds. Handles the common "Claude Desktop launched before ES Memory finished starting" race.
60-
- **Degraded mode** — if polling still fails, the bridge stays alive and responds to `initialize` and `tools/list` with a stub containing a single `es_memory_setup` tool whose description tells the user to launch the ES Memory app. `tools/call` returns a human-readable error in the content. The bridge re-attempts discovery on every incoming request, so it auto-recovers once the host comes up.
57+
59123 is deliberately exotic: in the IANA dynamic/private range (49152-65535), not associated with any common service. Port 5000 (the common default for local HTTP dev servers) is used by macOS AirPlay Receiver, which was the motivation for moving off it.
6158

62-
This means the user sees a clear "launch ES Memory.app from /Applications" message inside Claude rather than a silent connection failure.
59+
### When ES Memory isn't running
6360

64-
## Requires
61+
The bridge doesn't fail silently. If the HTTP forward fails (host not running, or went down mid-session), the bridge enters degraded mode:
6562

66-
The bridge uses a contract written by the host into `server.plist`:
63+
- `initialize` returns a stub with `serverInfo.name: "ES Memory (offline)"` and an `instructions` field telling the user to launch the app.
64+
- `tools/list` returns one tool: `es_memory_setup`, whose description also tells the user to launch the app.
65+
- `tools/call` returns `isError: true` with human-readable setup text.
6766

68-
| Key | Value |
69-
|---|---|
70-
| `url` | Full MCP endpoint URL including `/mcp` path |
71-
| `version` | Host's `CFBundleShortVersionString` (logged on connect) |
67+
On every subsequent request the bridge attempts the forward again, so the moment the host comes up the bridge auto-recovers and resumes normal forwarding. The user sees a clear, actionable message inside Claude instead of a silent connection failure.
68+
69+
## Requires
7270

73-
The host also deletes `server.plist` on terminate so the bridge sees a clean "not running" state rather than a stale URL. Requires **ES Memory v1.0.4 or later**.
71+
**ES Memory v1.0.5 or later**, configured to bind to port 59123. (Earlier versions used dynamic port selection with discovery via `server.plist`; v1.0.5 binds to a fixed port and needs no discovery.)
7472

7573
## Project structure
7674

bundle/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": "0.3",
33
"name": "es-memory-bridge",
44
"display_name": "ES Memory",
5-
"version": "1.1.1",
5+
"version": "1.2.0",
66
"description": "Stdio bridge to the ES Memory MCP server. Provides Claude with persistent memory: store, retrieve, organize, and curate memories across sessions. Requires the ES Memory app to be running locally.",
77
"author": {
88
"name": "Kolja Wawrowsky"

0 commit comments

Comments
 (0)