@korso/shepherd 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/README.md +203 -0
- package/dist/index.js +438 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# @korso/shepherd — Shepherd MCP Server
|
|
2
|
+
|
|
3
|
+
Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, etc.) five advisory coordination tools backed by the shared hub: `join`, `work`, `done`, `announce`, and `sync`.
|
|
4
|
+
|
|
5
|
+
> **New here?** The [developer quickstart](https://github.com/Korsoai/shepherd/blob/main/docs/shepherd-mcp-quickstart.md) is the fastest path. TL;DR: `npx -y @korso/shepherd` with the env vars below.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## CRITICAL: WORKSPACE must match the hub exactly
|
|
10
|
+
|
|
11
|
+
> **Everyone must set `WORKSPACE` to the identical string, and that string must equal the hub's `ALLOWED_WORKSPACE` env var.**
|
|
12
|
+
|
|
13
|
+
If `WORKSPACE` does not match, the server's `join` call to the hub returns HTTP 400 immediately. The error is loud on purpose — you will see it in stderr the moment the first tool is called. This is the most common silent onboarding mistake; the guard turns it into a loud, immediate failure.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
The server is published to npm and runs via `npx` — no clone or build required
|
|
20
|
+
(Node 18+):
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npx -y @korso/shepherd
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
You won't normally run that by hand; you put it in your MCP client config (below)
|
|
27
|
+
with the required env vars. `npx` caches the package, so startup is fast after the
|
|
28
|
+
first fetch, and `@korso/shepherd@latest` picks up updates automatically.
|
|
29
|
+
|
|
30
|
+
> Hacking on the server itself? See **[Develop from source](#develop-from-source)**
|
|
31
|
+
> at the bottom.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. Required environment variables
|
|
36
|
+
|
|
37
|
+
Every instance of the MCP server needs these eight env vars set in its client config:
|
|
38
|
+
|
|
39
|
+
| Variable | Description | Example |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `HUB_URL` | Base URL of the deployed hub | `https://shepherd.example.com` |
|
|
42
|
+
| `TEAM_TOKEN` | Shared bearer token accepted by the hub | `tok_abc123` |
|
|
43
|
+
| `WORKSPACE` | **Must match hub's `ALLOWED_WORKSPACE` exactly** | `shepherd` |
|
|
44
|
+
| `REPO` | Repository slug (used for scoping claims) | `shepherd` |
|
|
45
|
+
| `BRANCH` | Git branch name | `main` |
|
|
46
|
+
| `HUMAN` | Founder name — identifies you in the presence feed | `daichi` |
|
|
47
|
+
| `PROGRAM` | Agent program name | `claude-code` |
|
|
48
|
+
| `MODEL` | Model ID being used | `claude-sonnet-4-6` |
|
|
49
|
+
|
|
50
|
+
All eight are required. Missing any one causes an immediate startup failure with a clear error on stderr listing which vars are absent.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 3. MCP client configuration
|
|
55
|
+
|
|
56
|
+
### Claude Code
|
|
57
|
+
|
|
58
|
+
> **Do not use `~/.claude/mcp.json` — Claude Code does not read it** (a config
|
|
59
|
+
> there loads silently into nothing). Use `claude mcp add` (user scope, applies
|
|
60
|
+
> everywhere) or a project-root `.mcp.json`. Confirm with `claude mcp list`,
|
|
61
|
+
> which should show `shepherd … ✔ Connected`.
|
|
62
|
+
|
|
63
|
+
Recommended — register once at user scope (works identically on Windows/macOS/Linux):
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
claude mcp add shepherd -s user \
|
|
67
|
+
-e HUB_URL=https://shepherd.example.com \
|
|
68
|
+
-e TEAM_TOKEN=tok_abc123 \
|
|
69
|
+
-e WORKSPACE=shepherd \
|
|
70
|
+
-e REPO=shepherd -e BRANCH=main -e HUMAN=daichi \
|
|
71
|
+
-e PROGRAM=claude-code -e MODEL=claude-sonnet-4-6 \
|
|
72
|
+
-- npx -y @korso/shepherd
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Alternative — a `.mcp.json` at the **root of the repo you're working in**:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"shepherd": {
|
|
81
|
+
"command": "npx",
|
|
82
|
+
"args": ["-y", "@korso/shepherd"],
|
|
83
|
+
"env": {
|
|
84
|
+
"HUB_URL": "https://shepherd.example.com",
|
|
85
|
+
"TEAM_TOKEN": "tok_abc123",
|
|
86
|
+
"WORKSPACE": "shepherd",
|
|
87
|
+
"REPO": "shepherd",
|
|
88
|
+
"BRANCH": "main",
|
|
89
|
+
"HUMAN": "daichi",
|
|
90
|
+
"PROGRAM": "claude-code",
|
|
91
|
+
"MODEL": "claude-sonnet-4-6"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> Windows note: the server is a thin stdio client to the Linux-hosted hub, and
|
|
99
|
+
> `npx` works the same on every OS — no file paths to escape. The hub itself runs
|
|
100
|
+
> on Linux (Postgres), so the Windows-native durability concerns from the spike
|
|
101
|
+
> don't apply to clients.
|
|
102
|
+
|
|
103
|
+
### Codex (`~/.codex/config.json` or `codex.json`)
|
|
104
|
+
|
|
105
|
+
Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"mcpServers": {
|
|
110
|
+
"shepherd": {
|
|
111
|
+
"command": "npx",
|
|
112
|
+
"args": ["-y", "@korso/shepherd"],
|
|
113
|
+
"env": {
|
|
114
|
+
"HUB_URL": "https://shepherd.example.com",
|
|
115
|
+
"TEAM_TOKEN": "tok_abc123",
|
|
116
|
+
"WORKSPACE": "shepherd",
|
|
117
|
+
"REPO": "shepherd",
|
|
118
|
+
"BRANCH": "main",
|
|
119
|
+
"HUMAN": "alex",
|
|
120
|
+
"PROGRAM": "codex",
|
|
121
|
+
"MODEL": "o4-mini"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 4. Verify the server starts (quick smoke test)
|
|
131
|
+
|
|
132
|
+
Run with all env vars set to confirm it connects and idles on stdin:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
HUB_URL=https://shepherd.example.com \
|
|
136
|
+
TEAM_TOKEN=tok_abc123 \
|
|
137
|
+
WORKSPACE=shepherd \
|
|
138
|
+
REPO=shepherd \
|
|
139
|
+
BRANCH=main \
|
|
140
|
+
HUMAN=daichi \
|
|
141
|
+
PROGRAM=claude-code \
|
|
142
|
+
MODEL=claude-sonnet-4-6 \
|
|
143
|
+
npx -y @korso/shepherd
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to exit.
|
|
147
|
+
|
|
148
|
+
**Missing env vars:** if you deliberately omit a var, you will see:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
[shepherd] Configuration error — missing or invalid env vars:
|
|
152
|
+
HUB_URL: HUB_URL is required
|
|
153
|
+
TEAM_TOKEN: TEAM_TOKEN is required
|
|
154
|
+
...
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
and the process exits 1 immediately. This is by design.
|
|
158
|
+
|
|
159
|
+
**Wrong WORKSPACE:** the server starts and connects, but the first tool call (`work`, `sync`, etc.) returns a 400 from the hub. Check that your `WORKSPACE` value exactly matches the hub's `ALLOWED_WORKSPACE`.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Develop from source
|
|
164
|
+
|
|
165
|
+
Only needed if you're changing the MCP server itself. Clone the monorepo and
|
|
166
|
+
point your client at a local build instead of npx:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
git clone https://github.com/Korsoai/shepherd.git
|
|
170
|
+
cd shepherd
|
|
171
|
+
npm install
|
|
172
|
+
npm run build # tsc -b — compiles the workspace for dev + tests
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For an exact preview of the published artifact (a single self-contained bundle
|
|
176
|
+
with `@shepherd/shared` inlined), build the package directly:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
npm run build --workspace=@korso/shepherd # runs tsup → packages/mcp-server/dist/index.js
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Then use `node /absolute/path/to/shepherd/packages/mcp-server/dist/index.js` as
|
|
183
|
+
the `command` in your MCP config (Windows: escape backslashes in JSON).
|
|
184
|
+
|
|
185
|
+
### Publishing a new version
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
# bump "version" in packages/mcp-server/package.json, then:
|
|
189
|
+
npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatically
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`publishConfig.access` is `public`, so the scoped package publishes publicly.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Troubleshooting
|
|
197
|
+
|
|
198
|
+
| Symptom | Likely cause | Fix |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| `Configuration error — missing or invalid env vars` | One or more of the 8 env vars is absent | Add the missing vars to your client's `env` block |
|
|
201
|
+
| Hub returns 400 on first tool call | `WORKSPACE` mismatch between client and hub | Set `WORKSPACE` to exactly match the hub's `ALLOWED_WORKSPACE` |
|
|
202
|
+
| `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
|
|
203
|
+
| Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
var ConfigSchema = z.object({
|
|
10
|
+
HUB_URL: z.string().min(1, "HUB_URL is required"),
|
|
11
|
+
TEAM_TOKEN: z.string().min(1, "TEAM_TOKEN is required"),
|
|
12
|
+
WORKSPACE: z.string().min(1, "WORKSPACE is required"),
|
|
13
|
+
REPO: z.string().min(1, "REPO is required"),
|
|
14
|
+
BRANCH: z.string().min(1, "BRANCH is required"),
|
|
15
|
+
HUMAN: z.string().min(1, "HUMAN is required"),
|
|
16
|
+
PROGRAM: z.string().min(1, "PROGRAM is required"),
|
|
17
|
+
MODEL: z.string().min(1, "MODEL is required")
|
|
18
|
+
});
|
|
19
|
+
function parseConfig(env) {
|
|
20
|
+
return ConfigSchema.parse({
|
|
21
|
+
HUB_URL: env["HUB_URL"],
|
|
22
|
+
TEAM_TOKEN: env["TEAM_TOKEN"],
|
|
23
|
+
WORKSPACE: env["WORKSPACE"],
|
|
24
|
+
REPO: env["REPO"],
|
|
25
|
+
BRANCH: env["BRANCH"],
|
|
26
|
+
HUMAN: env["HUMAN"],
|
|
27
|
+
PROGRAM: env["PROGRAM"],
|
|
28
|
+
MODEL: env["MODEL"]
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function loadConfig(env = process.env) {
|
|
32
|
+
try {
|
|
33
|
+
return parseConfig(env);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof z.ZodError) {
|
|
36
|
+
const messages = err.issues.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
|
|
37
|
+
process.stderr.write(`[shepherd] Configuration error \u2014 missing or invalid env vars:
|
|
38
|
+
${messages}
|
|
39
|
+
`);
|
|
40
|
+
} else {
|
|
41
|
+
process.stderr.write(`[shepherd] Unexpected configuration error: ${String(err)}
|
|
42
|
+
`);
|
|
43
|
+
}
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/hubClient.ts
|
|
49
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
50
|
+
var HubUnreachable = class extends Error {
|
|
51
|
+
constructor(message, cause) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "HubUnreachable";
|
|
54
|
+
if (cause !== void 0) {
|
|
55
|
+
this.cause = cause;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var HubRequestError = class extends Error {
|
|
60
|
+
status;
|
|
61
|
+
constructor(status, message) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "HubRequestError";
|
|
64
|
+
this.status = status;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
function createHubClient({
|
|
68
|
+
hubUrl,
|
|
69
|
+
teamToken,
|
|
70
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
71
|
+
}) {
|
|
72
|
+
const baseUrl = hubUrl.replace(/\/$/, "");
|
|
73
|
+
return {
|
|
74
|
+
async post(path, body) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
77
|
+
let response;
|
|
78
|
+
try {
|
|
79
|
+
response = await fetch(`${baseUrl}${path}`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
"Authorization": `Bearer ${teamToken}`,
|
|
83
|
+
"Content-Type": "application/json"
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
signal: controller.signal
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path})` : `Hub unreachable at ${baseUrl}${path}: ${String(err)}`;
|
|
91
|
+
throw new HubUnreachable(message, err);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new HubRequestError(
|
|
97
|
+
response.status,
|
|
98
|
+
`Hub returned HTTP ${response.status} for ${path}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return response.json();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ../shared/dist/contract.js
|
|
107
|
+
import { z as z2 } from "zod";
|
|
108
|
+
var IsoTimestamp = z2.string();
|
|
109
|
+
var DbId = z2.number();
|
|
110
|
+
var Claim = z2.object({
|
|
111
|
+
workItemId: z2.string().uuid(),
|
|
112
|
+
agentName: z2.string(),
|
|
113
|
+
human: z2.string().min(1),
|
|
114
|
+
intent: z2.string().min(1).max(2048),
|
|
115
|
+
pathGlobs: z2.array(z2.string().min(1).max(512)).min(1).max(64),
|
|
116
|
+
// ISO timestamp string; see IsoTimestamp note above
|
|
117
|
+
expiresAt: IsoTimestamp
|
|
118
|
+
});
|
|
119
|
+
var Announcement = z2.object({
|
|
120
|
+
// bigint PK serialised as number; see DbId note above
|
|
121
|
+
id: DbId,
|
|
122
|
+
fromAgentName: z2.string(),
|
|
123
|
+
fromHuman: z2.string().min(1),
|
|
124
|
+
body: z2.string().min(1).max(8192),
|
|
125
|
+
targetAgentName: z2.string().nullable(),
|
|
126
|
+
// ISO timestamp string; see IsoTimestamp note above
|
|
127
|
+
createdAt: IsoTimestamp
|
|
128
|
+
});
|
|
129
|
+
var Landscape = z2.object({
|
|
130
|
+
conflicts: z2.array(Claim),
|
|
131
|
+
activeClaims: z2.array(Claim),
|
|
132
|
+
// The caller's OWN active claims. `activeClaims` deliberately excludes the
|
|
133
|
+
// caller's session, so without this an agent has no way to confirm its own
|
|
134
|
+
// claim is live. Optional with a default so an older client talking to a
|
|
135
|
+
// newer hub (or vice-versa) never fails validation on its absence.
|
|
136
|
+
yourClaims: z2.array(Claim).default([]),
|
|
137
|
+
announcements: z2.array(Announcement)
|
|
138
|
+
});
|
|
139
|
+
var JoinRequest = z2.object({
|
|
140
|
+
workspace: z2.string().min(1),
|
|
141
|
+
repo: z2.string().min(1),
|
|
142
|
+
branch: z2.string().min(1),
|
|
143
|
+
human: z2.string().min(1),
|
|
144
|
+
program: z2.string().min(1),
|
|
145
|
+
model: z2.string().min(1)
|
|
146
|
+
});
|
|
147
|
+
var JoinResponse = z2.object({
|
|
148
|
+
agentName: z2.string(),
|
|
149
|
+
sessionId: z2.string().uuid()
|
|
150
|
+
});
|
|
151
|
+
var WorkRequest = z2.object({
|
|
152
|
+
sessionId: z2.string().uuid(),
|
|
153
|
+
intent: z2.string().min(1).max(2048),
|
|
154
|
+
pathGlobs: z2.array(z2.string().min(1).max(512)).min(1).max(64),
|
|
155
|
+
ttlSeconds: z2.number().int().positive().optional()
|
|
156
|
+
});
|
|
157
|
+
var WorkResponse = z2.object({
|
|
158
|
+
workItemId: z2.string().uuid(),
|
|
159
|
+
landscape: Landscape
|
|
160
|
+
});
|
|
161
|
+
var DoneRequest = z2.object({
|
|
162
|
+
sessionId: z2.string().uuid(),
|
|
163
|
+
workItemId: z2.string().uuid()
|
|
164
|
+
});
|
|
165
|
+
var DoneResponse = z2.object({
|
|
166
|
+
ok: z2.literal(true)
|
|
167
|
+
});
|
|
168
|
+
var AnnounceRequest = z2.object({
|
|
169
|
+
sessionId: z2.string().uuid(),
|
|
170
|
+
body: z2.string().min(1).max(8192),
|
|
171
|
+
// absent or null => broadcast to all agents in the workspace
|
|
172
|
+
targetAgentName: z2.string().nullable().optional()
|
|
173
|
+
});
|
|
174
|
+
var AnnounceResponse = z2.object({
|
|
175
|
+
ok: z2.literal(true),
|
|
176
|
+
// bigint PK serialised as number; see DbId note above
|
|
177
|
+
announcementId: DbId
|
|
178
|
+
});
|
|
179
|
+
var SyncRequest = z2.object({
|
|
180
|
+
sessionId: z2.string().uuid()
|
|
181
|
+
});
|
|
182
|
+
var SyncResponse = z2.object({
|
|
183
|
+
landscape: Landscape
|
|
184
|
+
});
|
|
185
|
+
var WorkAgentInput = WorkRequest.omit({ sessionId: true });
|
|
186
|
+
var AnnounceAgentInput = AnnounceRequest.omit({ sessionId: true });
|
|
187
|
+
var DoneAgentInput = DoneRequest.omit({ sessionId: true });
|
|
188
|
+
var JoinAgentInput = z2.object({});
|
|
189
|
+
var SyncAgentInput = z2.object({});
|
|
190
|
+
|
|
191
|
+
// src/tools.ts
|
|
192
|
+
function formatLandscape(landscape) {
|
|
193
|
+
const lines = [];
|
|
194
|
+
if (landscape.conflicts.length > 0) {
|
|
195
|
+
lines.push("CONFLICTS (files overlapping with your claim):");
|
|
196
|
+
for (const c of landscape.conflicts) {
|
|
197
|
+
lines.push(
|
|
198
|
+
` [${c.agentName} / ${c.human}] "${c.intent}" \u2014 globs: ${c.pathGlobs.join(", ")}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
lines.push("CONFLICTS: none");
|
|
203
|
+
}
|
|
204
|
+
if (landscape.activeClaims.length > 0) {
|
|
205
|
+
lines.push("ACTIVE CLAIMS (other agents currently working):");
|
|
206
|
+
for (const c of landscape.activeClaims) {
|
|
207
|
+
lines.push(
|
|
208
|
+
` [${c.agentName} / ${c.human}] "${c.intent}" \u2014 globs: ${c.pathGlobs.join(", ")}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
lines.push("ACTIVE CLAIMS: none");
|
|
213
|
+
}
|
|
214
|
+
const yourClaims = landscape.yourClaims ?? [];
|
|
215
|
+
if (yourClaims.length > 0) {
|
|
216
|
+
lines.push("YOUR ACTIVE CLAIMS:");
|
|
217
|
+
for (const c of yourClaims) {
|
|
218
|
+
lines.push(
|
|
219
|
+
` "${c.intent}" \u2014 globs: ${c.pathGlobs.join(", ")} (workItemId: ${c.workItemId})`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
lines.push("YOUR ACTIVE CLAIMS: none");
|
|
224
|
+
}
|
|
225
|
+
if (landscape.announcements.length > 0) {
|
|
226
|
+
lines.push("ANNOUNCEMENTS:");
|
|
227
|
+
for (const a of landscape.announcements) {
|
|
228
|
+
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
229
|
+
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
lines.push("ANNOUNCEMENTS: none");
|
|
233
|
+
}
|
|
234
|
+
return lines.join("\n");
|
|
235
|
+
}
|
|
236
|
+
function degradedResult(err) {
|
|
237
|
+
const detail = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
|
|
238
|
+
return {
|
|
239
|
+
content: [
|
|
240
|
+
{
|
|
241
|
+
type: "text",
|
|
242
|
+
text: `Coordination hub unreachable \u2014 proceeding uncoordinated. ${detail}`
|
|
243
|
+
}
|
|
244
|
+
]
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function registerTools(server, deps) {
|
|
248
|
+
const { hubClient, config } = deps;
|
|
249
|
+
let sessionId = null;
|
|
250
|
+
let joinInFlight = null;
|
|
251
|
+
async function awaitJoin() {
|
|
252
|
+
if (joinInFlight) await joinInFlight;
|
|
253
|
+
}
|
|
254
|
+
server.registerTool(
|
|
255
|
+
"join",
|
|
256
|
+
{
|
|
257
|
+
title: "Join coordination hub",
|
|
258
|
+
description: "Register yourself with the team coordination hub at the start of a session. Call once before any other coordination tool. Returns your assigned agent name and session ID that the other tools use automatically.",
|
|
259
|
+
inputSchema: JoinAgentInput.shape
|
|
260
|
+
},
|
|
261
|
+
async (_args) => {
|
|
262
|
+
try {
|
|
263
|
+
const body = {
|
|
264
|
+
workspace: config.WORKSPACE,
|
|
265
|
+
repo: config.REPO,
|
|
266
|
+
branch: config.BRANCH,
|
|
267
|
+
human: config.HUMAN,
|
|
268
|
+
program: config.PROGRAM,
|
|
269
|
+
model: config.MODEL
|
|
270
|
+
};
|
|
271
|
+
const pending = hubClient.post("/join", body);
|
|
272
|
+
joinInFlight = pending.then((r) => {
|
|
273
|
+
sessionId = r.sessionId;
|
|
274
|
+
}).catch(() => {
|
|
275
|
+
});
|
|
276
|
+
const result = await pending;
|
|
277
|
+
sessionId = result.sessionId;
|
|
278
|
+
return {
|
|
279
|
+
content: [
|
|
280
|
+
{
|
|
281
|
+
type: "text",
|
|
282
|
+
text: `Joined as ${result.agentName}.`
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
};
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
288
|
+
return degradedResult(err);
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
server.registerTool(
|
|
295
|
+
"work",
|
|
296
|
+
{
|
|
297
|
+
title: "Claim a unit of work",
|
|
298
|
+
description: "Call this BEFORE you start a unit of work. It atomically checks whether any teammate's agent is already touching the same files and claims the work for you, returning any conflicts and what others are working on. Always call this before editing files.",
|
|
299
|
+
inputSchema: WorkAgentInput.shape
|
|
300
|
+
},
|
|
301
|
+
async (args) => {
|
|
302
|
+
await awaitJoin();
|
|
303
|
+
if (sessionId === null) {
|
|
304
|
+
return {
|
|
305
|
+
isError: true,
|
|
306
|
+
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const body = { sessionId, ...args };
|
|
311
|
+
const result = await hubClient.post("/work", body);
|
|
312
|
+
const text = `Work claimed (workItemId: ${result.workItemId})
|
|
313
|
+
|
|
314
|
+
` + formatLandscape(result.landscape);
|
|
315
|
+
return { content: [{ type: "text", text }] };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
318
|
+
return degradedResult(err);
|
|
319
|
+
}
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
);
|
|
324
|
+
server.registerTool(
|
|
325
|
+
"done",
|
|
326
|
+
{
|
|
327
|
+
title: "Release a work claim",
|
|
328
|
+
description: "Call when you finish a unit of work to release your claim so teammates know the files are free. Pass the workItemId returned by the work tool.",
|
|
329
|
+
inputSchema: DoneAgentInput.shape
|
|
330
|
+
},
|
|
331
|
+
async (args) => {
|
|
332
|
+
await awaitJoin();
|
|
333
|
+
if (sessionId === null) {
|
|
334
|
+
return {
|
|
335
|
+
isError: true,
|
|
336
|
+
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const body = { sessionId, ...args };
|
|
341
|
+
await hubClient.post("/done", body);
|
|
342
|
+
return {
|
|
343
|
+
content: [
|
|
344
|
+
{
|
|
345
|
+
type: "text",
|
|
346
|
+
text: "Work item released."
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
};
|
|
350
|
+
} catch (err) {
|
|
351
|
+
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
352
|
+
return degradedResult(err);
|
|
353
|
+
}
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
server.registerTool(
|
|
359
|
+
"announce",
|
|
360
|
+
{
|
|
361
|
+
title: "Broadcast a message to teammates",
|
|
362
|
+
description: "Broadcast a heads-up to the other agents (or hand a specific agent a finding). This is awareness only \u2014 not a task assignment. Omit targetAgentName to broadcast to everyone in the workspace.",
|
|
363
|
+
inputSchema: AnnounceAgentInput.shape
|
|
364
|
+
},
|
|
365
|
+
async (args) => {
|
|
366
|
+
await awaitJoin();
|
|
367
|
+
if (sessionId === null) {
|
|
368
|
+
return {
|
|
369
|
+
isError: true,
|
|
370
|
+
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const body = { sessionId, ...args };
|
|
375
|
+
const result = await hubClient.post("/announce", body);
|
|
376
|
+
return {
|
|
377
|
+
content: [
|
|
378
|
+
{
|
|
379
|
+
type: "text",
|
|
380
|
+
text: `Announcement sent (id: ${result.announcementId}).`
|
|
381
|
+
}
|
|
382
|
+
]
|
|
383
|
+
};
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
386
|
+
return degradedResult(err);
|
|
387
|
+
}
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
server.registerTool(
|
|
393
|
+
"sync",
|
|
394
|
+
{
|
|
395
|
+
title: "Sync team landscape",
|
|
396
|
+
description: "Optional periodic pull of the latest team landscape (who's working on what, any messages for you). Also keeps your claims alive. Call this if you want to check for teammate activity without starting new work.",
|
|
397
|
+
inputSchema: SyncAgentInput.shape
|
|
398
|
+
},
|
|
399
|
+
async (_args) => {
|
|
400
|
+
await awaitJoin();
|
|
401
|
+
if (sessionId === null) {
|
|
402
|
+
return {
|
|
403
|
+
isError: true,
|
|
404
|
+
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const body = { sessionId };
|
|
409
|
+
const result = await hubClient.post("/sync", body);
|
|
410
|
+
const text = formatLandscape(result.landscape);
|
|
411
|
+
return { content: [{ type: "text", text }] };
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
414
|
+
return degradedResult(err);
|
|
415
|
+
}
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/index.ts
|
|
423
|
+
async function main() {
|
|
424
|
+
const config = loadConfig();
|
|
425
|
+
const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
|
|
426
|
+
const server = new McpServer({ name: "shepherd", version: "0.1.0" });
|
|
427
|
+
registerTools(server, { hubClient, config });
|
|
428
|
+
const transport = new StdioServerTransport();
|
|
429
|
+
await server.connect(transport);
|
|
430
|
+
}
|
|
431
|
+
main().catch((err) => {
|
|
432
|
+
process.stderr.write(`[shepherd] Fatal boot error: ${String(err)}
|
|
433
|
+
`);
|
|
434
|
+
if (err instanceof Error && err.stack) {
|
|
435
|
+
process.stderr.write(err.stack + "\n");
|
|
436
|
+
}
|
|
437
|
+
process.exit(1);
|
|
438
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@korso/shepherd",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) five advisory cross-session coordination tools (join/work/done/announce/sync) backed by the shared Shepherd hub.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"shepherd-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Korsoai/shepherd.git",
|
|
21
|
+
"directory": "packages/mcp-server"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"model-context-protocol",
|
|
26
|
+
"claude",
|
|
27
|
+
"agent",
|
|
28
|
+
"coordination"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup",
|
|
35
|
+
"start": "node dist/index.js",
|
|
36
|
+
"prepublishOnly": "tsup"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29",
|
|
40
|
+
"zod": "^3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@shepherd/shared": "*",
|
|
44
|
+
"tsup": "^8"
|
|
45
|
+
}
|
|
46
|
+
}
|