build: build the plugin-side client that dials out to the new endpoint and keeps the connection alive #5

Merged
Joel merged 1 commit from build/78726254 into main 2026-09-02 23:42:28 +00:00
Owner

build the plugin-side client that dials out to the new endpoint and keeps the connection alive

This task is one step of my plan: replace the loopback MCP dial-out with a persistent outbound connection from the vault

Result of earlier step 1 (read: read the current connection model in connor/service.py, connor/obsidian.py, and ):
Done reading. Writing up the notes and the spec now.

Reading notes: the connection model today

The one-line diagnosis. Every live vault operation in the service ends in an outbound HTTP dial to an address the plugin minted for its own loopback, and Obsidian runs on Joel's Mac, so that dial can never succeed from the fedora box. My earlier evidence still stands: curl to port 27125 from this box is refused, and the 503 text in chat is exactly the _connect rewrite. Nothing in the read path differs from the write path.

Service side, connor/obsidian.py.

  • mcp_url() at connor/obsidian.py:181-188 resolves the address from the credential vault key obsidian.mcp_url.<name>, then the single key, then the env var. set_mcp_url() at 191-214 is the owner's self-serve writer behind POST /vault/connect.
  • Client at 217-286 is a streamable-HTTP MCP client. Each call is a fresh initialize POST plus notifications/initialized plus tools/call, over urllib, 20-second timeout from MCP_TIMEOUT at line 49. Any network error becomes VaultOffline at 243-245.
  • _connect(name, client=None) at 289-306 is the single chokepoint all five operations use. It is also the test

Result of earlier step 2 (backend: build the service-side persistent-connection endpoint per the spec from the read):
Done and green: the full suite went from 919 to 930 tests, OK, with all eleven new ones red first and green after. Here are my finishing notes to you.

The design I built, and the one assumption in it

My read step's notes survived in memory but the spec text itself did not, so I built to the constraints the notes established and I'm stating the call here for you to overrule. Stdlib has no websocket server and the gate contract forbids adding one, so the link is the shape /events already proves through the edge. The plugin opens GET /vault/link?vault=<name> and holds it as a server-sent-events stream. Every tool call Connor makes becomes one call frame down that stream, carrying an id, the tool name, and the arguments. The plugin answers each frame with a short POST /vault/link/reply carrying the vault, the id, and the MCP-shaped tools/call result. Same five tools, same result shape, so the plugin's tool handlers from the PR #80 wire contract can be reused verbatim. Only the direction of the first packet changed. If you'd rather have a real websocket, the registry and Link class don't care how the bytes travel, and only the two route bodies in service.py would chang


Built by Connor's frontend backburner (dispatch 78726254) in an isolated clone; shipped deterministically by ship.py. The full build notes were spoken in conversation and stored in memory (agent-dispatch:78726254). Review is the gate.

build the plugin-side client that dials out to the new endpoint and keeps the connection alive This task is one step of my plan: replace the loopback MCP dial-out with a persistent outbound connection from the vault Result of earlier step 1 (read: read the current connection model in connor/service.py, connor/obsidian.py, and ): Done reading. Writing up the notes and the spec now. ## Reading notes: the connection model today **The one-line diagnosis.** Every live vault operation in the service ends in an outbound HTTP dial to an address the plugin minted for its own loopback, and Obsidian runs on Joel's Mac, so that dial can never succeed from the fedora box. My earlier evidence still stands: curl to port 27125 from this box is refused, and the 503 text in chat is exactly the `_connect` rewrite. Nothing in the read path differs from the write path. **Service side, connor/obsidian.py.** - `mcp_url()` at `connor/obsidian.py:181-188` resolves the address from the credential vault key `obsidian.mcp_url.<name>`, then the single key, then the env var. `set_mcp_url()` at 191-214 is the owner's self-serve writer behind `POST /vault/connect`. - `Client` at 217-286 is a streamable-HTTP MCP client. Each call is a fresh `initialize` POST plus `notifications/initialized` plus `tools/call`, over `urllib`, 20-second timeout from `MCP_TIMEOUT` at line 49. Any network error becomes `VaultOffline` at 243-245. - `_connect(name, client=None)` at 289-306 is the single chokepoint all five operations use. It is also the test Result of earlier step 2 (backend: build the service-side persistent-connection endpoint per the spec from the read): Done and green: the full suite went from 919 to 930 tests, OK, with all eleven new ones red first and green after. Here are my finishing notes to you. **The design I built, and the one assumption in it** My read step's notes survived in memory but the spec text itself did not, so I built to the constraints the notes established and I'm stating the call here for you to overrule. Stdlib has no websocket server and the gate contract forbids adding one, so the link is the shape `/events` already proves through the edge. The plugin opens `GET /vault/link?vault=<name>` and holds it as a server-sent-events stream. Every tool call Connor makes becomes one `call` frame down that stream, carrying an id, the tool name, and the arguments. The plugin answers each frame with a short `POST /vault/link/reply` carrying the vault, the id, and the MCP-shaped `tools/call` result. Same five tools, same result shape, so the plugin's tool handlers from the PR #80 wire contract can be reused verbatim. Only the direction of the first packet changed. If you'd rather have a real websocket, the registry and `Link` class don't care how the bytes travel, and only the two route bodies in service.py would chang --- Built by Connor's frontend backburner (dispatch `78726254`) in an isolated clone; shipped deterministically by `ship.py`. The full build notes were spoken in conversation and stored in memory (`agent-dispatch:78726254`). Review is the gate.
build the plugin-side client that dials out to the new endpoint and keeps the connection alive

This task is one step of my plan: replace the loopback MCP dial-out with a persistent outbound connection from the vault

Result of earlier step 1 (read: read the current connection model in connor/service.py, connor/obsidian.py, and ):
Done reading. Writing up the notes and the spec now.

## Reading notes: the connection model today

**The one-line diagnosis.** Every live vault operation in the service ends in an outbound HTTP dial to an address the plugin minted for its own loopback, and Obsidian runs on Joel's Mac, so that dial can never succeed from the fedora box. My earlier evidence still stands: curl to port 27125 from this box is refused, and the 503 text in chat is exactly the `_connect` rewrite. Nothing in the read path differs from the write path.

**Service side, connor/obsidian.py.**
- `mcp_url()` at `connor/obsidian.py:181-188` resolves the address from the credential vault key `obsidian.mcp_url.<name>`, then the single key, then the env var. `set_mcp_url()` at 191-214 is the owner's self-serve writer behind `POST /vault/connect`.
- `Client` at 217-286 is a streamable-HTTP MCP client. Each call is a fresh `initialize` POST plus `notifications/initialized` plus `tools/call`, over `urllib`, 20-second timeout from `MCP_TIMEOUT` at line 49. Any network error becomes `VaultOffline` at 243-245.
- `_connect(name, client=None)` at 289-306 is the single chokepoint all five operations use. It is also the test

Result of earlier step 2 (backend: build the service-side persistent-connection endpoint per the spec from the read):
Done and green: the full suite went from 919 to 930 tests, OK, with all eleven new ones red first and green after. Here are my finishing notes to you.

**The design I built, and the one assumption in it**

My read step's notes survived in memory but the spec text itself did not, so I built to the constraints the notes established and I'm stating the call here for you to overrule. Stdlib has no websocket server and the gate contract forbids adding one, so the link is the shape `/events` already proves through the edge. The plugin opens `GET /vault/link?vault=<name>` and holds it as a server-sent-events stream. Every tool call Connor makes becomes one `call` frame down that stream, carrying an id, the tool name, and the arguments. The plugin answers each frame with a short `POST /vault/link/reply` carrying the vault, the id, and the MCP-shaped `tools/call` result. Same five tools, same result shape, so the plugin's tool handlers from the PR #80 wire contract can be reused verbatim. Only the direction of the first packet changed. If you'd rather have a real websocket, the registry and `Link` class don't care how the bytes travel, and only the two route bodies in service.py would chang

Built by my frontend backburner (dispatch 78726254), diff verified by git; shipped by ship.py. Nothing merges without review.
Joel approved these changes 2026-09-02 22:16:07 +00:00
Dismissed
svc-remy force-pushed build/78726254 from cfae4e50b7 to 2747811778 2026-09-02 23:38:09 +00:00 Compare
Joel approved these changes 2026-09-02 23:42:23 +00:00
Joel merged commit 72fc4b8ab6 into main 2026-09-02 23:42:28 +00:00
Joel deleted branch build/78726254 2026-09-02 23:42:28 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
ZSDev/obsidian-connor!5
No description provided.