@going-haywire/farmhand4claude 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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/index.js +231 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 going-haywire
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @going-haywire/farmhand4claude
|
|
2
|
+
|
|
3
|
+
The **Farmhand** MCP proxy — a stdio↔HTTP bridge that connects Claude Code (or
|
|
4
|
+
any MCP client) to a running [Haywire](https://github.com/going-haywire/haywire)
|
|
5
|
+
studio, so the studio's tools appear mid-session with no reconnect.
|
|
6
|
+
|
|
7
|
+
> This npm package is just the proxy binary. The full beginner experience —
|
|
8
|
+
> onboarding skills that take you from a bare machine to a running studio — ships
|
|
9
|
+
> as the **`farmhand4claude` Claude Code plugin**, which runs this proxy for you.
|
|
10
|
+
> See the [plugin repo](https://github.com/going-haywire/farmhand4claude).
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
Claude Code spawns this over stdio at session start (it always starts, even when
|
|
15
|
+
no studio is running). Behind it, the proxy dials the Haywire studio's
|
|
16
|
+
streamable-HTTP `/mcp` endpoint:
|
|
17
|
+
|
|
18
|
+
- **Studio down** → exposes a single sentinel tool, `farmhand_studio_status`.
|
|
19
|
+
- **Studio comes up mid-session** → the proxy forwards the studio's tools and
|
|
20
|
+
resources and re-emits `list_changed`, so they appear with **no reconnect**.
|
|
21
|
+
- **Studio dies** → a liveness poll returns the proxy to down-mode and re-emits
|
|
22
|
+
`list_changed` again.
|
|
23
|
+
|
|
24
|
+
All logs go to stderr; stdout carries only MCP frames.
|
|
25
|
+
|
|
26
|
+
## Usage (as a Claude Code plugin)
|
|
27
|
+
|
|
28
|
+
You normally don't run this directly — install the plugin and it runs the proxy:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
/plugin marketplace add going-haywire/farmhand4claude
|
|
32
|
+
/plugin install farmhand4claude
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration (environment variables)
|
|
36
|
+
|
|
37
|
+
| Variable | Default | Purpose |
|
|
38
|
+
| --------------------- | ------------------------------------ | -------------------------------------------------- |
|
|
39
|
+
| `FARMHAND_URL` | `http://127.0.0.1:8082/mcp` | The studio's streamable-HTTP MCP endpoint. |
|
|
40
|
+
| `FARMHAND_TOKEN_PATH` | *(derived from the workspace)* | Explicit path to the bearer-token file. |
|
|
41
|
+
| `HAYWIRE_WORKSPACE` | — | Workspace root; token read from `<ws>/.haywire/farmhand_token`. |
|
|
42
|
+
| `CLAUDE_PROJECT_DIR` | *(set by Claude Code)* | Fallback workspace root when the plugin runs it. |
|
|
43
|
+
| `FARMHAND_POLL_MS` | `2000` | Studio discovery / liveness poll interval. |
|
|
44
|
+
|
|
45
|
+
The bearer token is read **lazily** — the file does not exist until the studio
|
|
46
|
+
has run once.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Farmhand stdio<->HTTP MCP proxy.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code connects to THIS over stdio at session start (always succeeds,
|
|
6
|
+
* because this process is trivially up). Behind it we dial the Haywire studio's
|
|
7
|
+
* streamable-HTTP /mcp endpoint, which may not exist yet. When the studio comes
|
|
8
|
+
* up mid-session we forward its tool/resource lists and re-emit list_changed so
|
|
9
|
+
* the tools appear in Claude Code with no reconnect. When the studio dies, the
|
|
10
|
+
* poll's liveness probe returns us to down-mode and re-emits list_changed.
|
|
11
|
+
*
|
|
12
|
+
* Facts pinned to primary sources (@modelcontextprotocol/sdk@1.29.0):
|
|
13
|
+
* - StreamableHTTPClientTransport(url, { requestInit: { headers } }) — src/client/streamableHttp.ts:100,149
|
|
14
|
+
* - low-level Server.sendToolListChanged()/sendResourceListChanged() — src/server/index.ts:656,662
|
|
15
|
+
* - client.setNotificationHandler(ToolListChangedNotificationSchema) — example simpleStreamableHttp.ts
|
|
16
|
+
* - stdio MUST NOT write non-MCP data to stdout (spec/transports) — hence all logs -> stderr
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
24
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
25
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
26
|
+
// ---- config (env-overridable; sensible Haywire defaults) --------------------
|
|
27
|
+
const UPSTREAM_URL = process.env.FARMHAND_URL ?? "http://127.0.0.1:8082/mcp";
|
|
28
|
+
const POLL_MS = Number(process.env.FARMHAND_POLL_MS ?? 2000);
|
|
29
|
+
/**
|
|
30
|
+
* Candidate token paths, in priority order. The token lives at
|
|
31
|
+
* `<workspace>/.haywire/farmhand_token`, so the whole game is knowing the
|
|
32
|
+
* workspace. Claude Code does NOT run the MCP server with cwd = workspace
|
|
33
|
+
* (it inherits the launch dir / $HOME — see CC issues #17565, #75266), so we
|
|
34
|
+
* do NOT trust process.cwd(). Instead we read the workspace from env vars that
|
|
35
|
+
* CC exports to the MCP subprocess and interpolates in plugin.json's env block:
|
|
36
|
+
* FARMHAND_TOKEN_PATH (explicit override) > HAYWIRE_WORKSPACE >
|
|
37
|
+
* CLAUDE_PROJECT_DIR. homedir() is a last resort for manual/standalone runs.
|
|
38
|
+
*/
|
|
39
|
+
function tokenCandidates() {
|
|
40
|
+
const paths = [];
|
|
41
|
+
const add = (base) => {
|
|
42
|
+
if (base)
|
|
43
|
+
paths.push(resolve(`${base}/.haywire/farmhand_token`));
|
|
44
|
+
};
|
|
45
|
+
if (process.env.FARMHAND_TOKEN_PATH)
|
|
46
|
+
paths.push(resolve(process.env.FARMHAND_TOKEN_PATH));
|
|
47
|
+
add(process.env.HAYWIRE_WORKSPACE);
|
|
48
|
+
add(process.env.CLAUDE_PROJECT_DIR);
|
|
49
|
+
add(homedir());
|
|
50
|
+
return paths;
|
|
51
|
+
}
|
|
52
|
+
const TOKEN_PATHS = tokenCandidates();
|
|
53
|
+
const log = (...a) => console.error("[farmhand-proxy]", ...a); // stderr only
|
|
54
|
+
// ---- upstream connection state ----------------------------------------------
|
|
55
|
+
let upstream = null;
|
|
56
|
+
let upstreamTools = [];
|
|
57
|
+
let upstreamResources = [];
|
|
58
|
+
/**
|
|
59
|
+
* Read the bearer token LAZILY — the file doesn't exist until the studio has
|
|
60
|
+
* run once. Try each candidate path and return the first non-empty token; also
|
|
61
|
+
* return which path it came from (for diagnostics).
|
|
62
|
+
*/
|
|
63
|
+
function readTokenFrom() {
|
|
64
|
+
for (const p of TOKEN_PATHS) {
|
|
65
|
+
try {
|
|
66
|
+
const t = readFileSync(p, "utf8").trim();
|
|
67
|
+
if (t.length > 0)
|
|
68
|
+
return { token: t, path: p };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* try the next candidate */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function readToken() {
|
|
77
|
+
return readTokenFrom()?.token ?? null;
|
|
78
|
+
}
|
|
79
|
+
/** The stdio-facing server Claude Code talks to. Created first, always up. */
|
|
80
|
+
const proxy = new Server({ name: "farmhand-mcp-server", version: "0.1.0" }, { capabilities: { tools: { listChanged: true }, resources: { listChanged: true } } });
|
|
81
|
+
// Sentinel so the model has an affordance while the studio is down.
|
|
82
|
+
const STATUS_TOOL = {
|
|
83
|
+
name: "farmhand_studio_status",
|
|
84
|
+
description: "Report whether the Haywire studio (Farmhand MCP server) is reachable. " +
|
|
85
|
+
"Always available, even when the studio is down.",
|
|
86
|
+
inputSchema: { type: "object", properties: {} },
|
|
87
|
+
};
|
|
88
|
+
// ---- request handlers: forward to upstream, or answer locally when down ------
|
|
89
|
+
proxy.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
90
|
+
return { tools: upstream ? upstreamTools : [STATUS_TOOL] };
|
|
91
|
+
});
|
|
92
|
+
proxy.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
93
|
+
return { resources: upstream ? upstreamResources : [] };
|
|
94
|
+
});
|
|
95
|
+
proxy.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
96
|
+
if (!upstream)
|
|
97
|
+
throw new Error("Haywire studio not running — cannot read resources yet.");
|
|
98
|
+
return upstream.readResource({ uri: req.params.uri });
|
|
99
|
+
});
|
|
100
|
+
proxy.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
101
|
+
const { name, arguments: args } = req.params;
|
|
102
|
+
if (name === "farmhand_studio_status") {
|
|
103
|
+
const up = upstream !== null;
|
|
104
|
+
const found = readTokenFrom();
|
|
105
|
+
const text = up
|
|
106
|
+
? `Haywire studio reachable at ${UPSTREAM_URL}; ${upstreamTools.length} tools available.`
|
|
107
|
+
: `Haywire studio not running (no connection to ${UPSTREAM_URL}). ` +
|
|
108
|
+
(found
|
|
109
|
+
? `Token present (${found.path}).`
|
|
110
|
+
: `No token found. Looked in: ${TOKEN_PATHS.join(", ")}.`);
|
|
111
|
+
return { content: [{ type: "text", text }], structuredContent: { up, url: UPSTREAM_URL } };
|
|
112
|
+
}
|
|
113
|
+
if (!upstream) {
|
|
114
|
+
return {
|
|
115
|
+
isError: true,
|
|
116
|
+
content: [
|
|
117
|
+
{ type: "text", text: `Studio not running — cannot call '${name}'. Start it, then retry.` },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Straight passthrough. Upstream's own errors flow back as-is.
|
|
122
|
+
return upstream.callTool({ name, arguments: args ?? {} });
|
|
123
|
+
});
|
|
124
|
+
// ---- upstream lifecycle: connect when studio appears, drop when it dies ------
|
|
125
|
+
async function tryConnectUpstream() {
|
|
126
|
+
if (upstream)
|
|
127
|
+
return; // already connected
|
|
128
|
+
const token = readToken();
|
|
129
|
+
const transport = new StreamableHTTPClientTransport(new URL(UPSTREAM_URL), {
|
|
130
|
+
requestInit: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
|
|
131
|
+
});
|
|
132
|
+
const client = new Client({ name: "farmhand-proxy-client", version: "0.1.0" });
|
|
133
|
+
// Re-emit upstream list_changed across the stdio boundary — the whole point.
|
|
134
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
135
|
+
await refreshTools();
|
|
136
|
+
await proxy.sendToolListChanged();
|
|
137
|
+
});
|
|
138
|
+
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
|
|
139
|
+
await refreshResources();
|
|
140
|
+
await proxy.sendResourceListChanged();
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
await client.connect(transport);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
// Studio still down / token wrong. Stay in "down" mode; poll will retry.
|
|
147
|
+
await transport.close().catch(() => { });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
upstream = client;
|
|
151
|
+
// Fast-path teardown for the streaming case: if the transport carries a
|
|
152
|
+
// held-open stream, onclose fires the moment it drops. (For a stateless
|
|
153
|
+
// studio there is no such stream — the poll's liveness probe catches it.)
|
|
154
|
+
client.onclose = () => {
|
|
155
|
+
if (upstream !== client)
|
|
156
|
+
return; // already dropped by the probe
|
|
157
|
+
log("upstream closed — back to down mode");
|
|
158
|
+
dropUpstream();
|
|
159
|
+
};
|
|
160
|
+
await refreshTools();
|
|
161
|
+
await refreshResources();
|
|
162
|
+
log(`connected upstream: ${upstreamTools.length} tools, ${upstreamResources.length} resources`);
|
|
163
|
+
// Tell Claude Code the real tools are here now — no reconnect needed.
|
|
164
|
+
await proxy.sendToolListChanged();
|
|
165
|
+
await proxy.sendResourceListChanged();
|
|
166
|
+
}
|
|
167
|
+
/** Return to down-mode: forget upstream, clear caches, re-emit list_changed. */
|
|
168
|
+
function dropUpstream() {
|
|
169
|
+
const c = upstream;
|
|
170
|
+
upstream = null;
|
|
171
|
+
upstreamTools = [];
|
|
172
|
+
upstreamResources = [];
|
|
173
|
+
if (c)
|
|
174
|
+
void c.close().catch(() => { });
|
|
175
|
+
void proxy.sendToolListChanged();
|
|
176
|
+
void proxy.sendResourceListChanged();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Poll authority for BOTH directions. When down, try to connect. When up,
|
|
180
|
+
* probe liveness (a cheap listTools) — a stateless HTTP studio has no
|
|
181
|
+
* held-open stream to trigger onclose, so the probe is what notices it died.
|
|
182
|
+
*/
|
|
183
|
+
async function pollUpstream() {
|
|
184
|
+
if (!upstream) {
|
|
185
|
+
await tryConnectUpstream();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
upstreamTools = (await upstream.listTools()).tools;
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
log("upstream probe failed — back to down mode:", e instanceof Error ? e.message : e);
|
|
193
|
+
dropUpstream();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function refreshTools() {
|
|
197
|
+
if (!upstream)
|
|
198
|
+
return;
|
|
199
|
+
upstreamTools = (await upstream.listTools()).tools;
|
|
200
|
+
}
|
|
201
|
+
async function refreshResources() {
|
|
202
|
+
if (!upstream)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
upstreamResources = (await upstream.listResources()).resources;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
upstreamResources = []; // upstream may not advertise resources
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// ---- main -------------------------------------------------------------------
|
|
212
|
+
async function main() {
|
|
213
|
+
await proxy.connect(new StdioServerTransport());
|
|
214
|
+
log(`up. bridging stdio -> ${UPSTREAM_URL} (poll ${POLL_MS}ms). token candidates: ${TOKEN_PATHS.join(", ")}`);
|
|
215
|
+
// Poll so tools auto-appear the moment the studio comes up mid-session,
|
|
216
|
+
// and disappear the moment it dies.
|
|
217
|
+
const tick = async () => {
|
|
218
|
+
try {
|
|
219
|
+
await pollUpstream();
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
log("poll error:", e instanceof Error ? e.message : e);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
await tick();
|
|
226
|
+
setInterval(() => void tick(), POLL_MS).unref();
|
|
227
|
+
}
|
|
228
|
+
main().catch((e) => {
|
|
229
|
+
log("fatal:", e);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@going-haywire/farmhand4claude",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Farmhand — the AI harness for Haywire. A stdio↔HTTP MCP proxy that bridges Claude Code to a running Haywire studio, so studio tools appear mid-session with no reconnect.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"farmhand4claude": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"build:test": "tsc -p tsconfig.test.json",
|
|
16
|
+
"test": "npm run build && npm run build:test && node dist-test/harness.test.js",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"claude-code",
|
|
28
|
+
"haywire",
|
|
29
|
+
"farmhand",
|
|
30
|
+
"proxy"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/going-haywire/farmhand4claude.git"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.10.0",
|
|
42
|
+
"typescript": "^5.7.2",
|
|
43
|
+
"zod": "^3.25.76"
|
|
44
|
+
}
|
|
45
|
+
}
|