dsh-a2a-groups 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Dependencies for the MCP server: everything the group tools need, wired for a process
3
+ * that has no DSH, no Session, and no local group store.
4
+ *
5
+ * THE SHAPE OF THE PROBLEM
6
+ *
7
+ * In DSH the group tools are wired to a plugin context: a local store for groups this
8
+ * instance hosts, a session service, an inbox. An MCP client has none of that. It has one
9
+ * thing: an address for a remote group host, and a credential to reach it.
10
+ *
11
+ * That is not a limitation, it is the normal case. A colleague's DSH instance talks to the
12
+ * shared host over A2A; this process does exactly the same, just without a DSH around it.
13
+ * So `store` is empty (this instance hosts nothing), `sessions` is absent (there are no
14
+ * Sessions to deliver into), and `bridge` does all the real work.
15
+ *
16
+ * WHAT IS REUSED
17
+ *
18
+ * `bridge.js`, `registry.js`, `memberships.js` and `config.js` are the same modules the
19
+ * plugin uses. That is why the tools behave identically in both hosts: the transport
20
+ * differs, the machinery does not.
21
+ *
22
+ * @module dsh-a2a/mcp-deps
23
+ */
24
+
25
+ import { createBridge } from '../core/bridge.js';
26
+ import { createMembershipLedger } from '../core/memberships.js';
27
+ import { createRegistry } from '../core/registry.js';
28
+ import { normalizeConfig } from '../core/config.js';
29
+
30
+ /** A logger that writes to stderr: stdout is the MCP channel and must stay clean. */
31
+ function stderrLogger(verbose) {
32
+ const stamp = () => new Date().toISOString();
33
+ return {
34
+ info: (line) => process.stderr.write(`${stamp()} info ${line}\n`),
35
+ warn: (line) => process.stderr.write(`${stamp()} warn ${line}\n`),
36
+ error: (line) => process.stderr.write(`${stamp()} error ${line}\n`),
37
+ debug: (line) => {
38
+ if (verbose) process.stderr.write(`${stamp()} debug ${line}\n`);
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * A store that refuses to pretend.
45
+ *
46
+ * The group mutators branch on `store.has(groupId)` to choose between a local call and an
47
+ * A2A call. In an MCP process nothing is hosted locally, so `has` is always false and the
48
+ * A2A path is always taken. Every other method throws rather than returning a plausible
49
+ * empty value: a silent empty answer from `list()` would look like "you are in no groups",
50
+ * which is a lie about a remote state this process cannot see.
51
+ */
52
+ function remoteOnlyStore() {
53
+ const refuse = () => {
54
+ throw new Error('this process hosts no groups; pass `agent` so the call goes to a remote host over A2A');
55
+ };
56
+ return {
57
+ has: () => false,
58
+ list: () => [],
59
+ enabled: () => false,
60
+ create: refuse,
61
+ invite: refuse,
62
+ join: refuse,
63
+ post: refuse,
64
+ read: refuse,
65
+ members: refuse,
66
+ leave: refuse,
67
+ snapshot: refuse,
68
+ authenticate: refuse,
69
+ requestJoin: refuse,
70
+ listRequests: refuse,
71
+ decideRequest: refuse,
72
+ claimJoin: refuse,
73
+ listDirectory: refuse,
74
+ revokeInvite: refuse,
75
+ setProfile: refuse,
76
+ memberView: refuse,
77
+ lastEntry: refuse,
78
+ subscribe: () => () => {},
79
+ flush: async () => {},
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Build the dependency bundle from the environment.
85
+ *
86
+ * Recognised variables, mirroring the host's own naming so one deployment can use the
87
+ * same words on both sides:
88
+ *
89
+ * AGENT_GROUPS_GATEWAY gateway base, e.g. http://gw:3000/a2a
90
+ * AGENT_GROUPS_AGENT the host's agent name in that gateway, e.g. agent-groups
91
+ * AGENT_GROUPS_TOKEN bearer token for the host
92
+ * AGENT_GROUPS_DATA directory for memberships.json (default ~/.agent-groups)
93
+ * AGENT_GROUPS_VERBOSE "1" for debug logging on stderr
94
+ *
95
+ * @param {object} env - usually `process.env`.
96
+ * @returns {object} the deps object `createToolRuntime` expects.
97
+ */
98
+ export function createHostDepsFromEnv(env = process.env) {
99
+ const gatewayBase = typeof env.AGENT_GROUPS_GATEWAY === 'string' ? env.AGENT_GROUPS_GATEWAY.trim() : '';
100
+ const agentName = typeof env.AGENT_GROUPS_AGENT === 'string' ? env.AGENT_GROUPS_AGENT.trim() : '';
101
+ const token = typeof env.AGENT_GROUPS_TOKEN === 'string' ? env.AGENT_GROUPS_TOKEN.trim() : '';
102
+ const dataDir = typeof env.AGENT_GROUPS_DATA === 'string' && env.AGENT_GROUPS_DATA !== ''
103
+ ? env.AGENT_GROUPS_DATA
104
+ : `${process.cwd()}/.agent-groups`;
105
+ const verbose = env.AGENT_GROUPS_VERBOSE === '1';
106
+
107
+ const logger = stderrLogger(verbose);
108
+ const config = normalizeConfig({
109
+ groups: {
110
+ gatewayBase,
111
+ agentName,
112
+ home: dataDir,
113
+ },
114
+ client: {
115
+ apiKey: token,
116
+ },
117
+ });
118
+
119
+ const clock = () => Date.now();
120
+ const bridge = createBridge({ logger, config, clock });
121
+ const registry = createRegistry({ home: dataDir, logger, clock });
122
+ const identities = createMembershipLedger({ home: dataDir, logger, clock });
123
+
124
+ if (gatewayBase === '' || agentName === '') {
125
+ logger.warn(
126
+ 'AGENT_GROUPS_GATEWAY and AGENT_GROUPS_AGENT are not set: tools that need the host will fail. '
127
+ + 'Set both, e.g. AGENT_GROUPS_GATEWAY=http://gw:3000/a2a AGENT_GROUPS_AGENT=agent-groups',
128
+ );
129
+ }
130
+
131
+ return {
132
+ config,
133
+ registry,
134
+ bridge,
135
+ // Nothing is hosted in this process; every group operation goes over A2A.
136
+ store: remoteOnlyStore(),
137
+ identities,
138
+ logger,
139
+ // No Sessions exist here. The group tools that need one pass their own connection id,
140
+ // and the ones that would deliver into a Session are simply never used.
141
+ sessions: undefined,
142
+ server: undefined,
143
+ inbox: undefined,
144
+ };
145
+ }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * An MCP server for collaboration groups, over stdio.
4
+ *
5
+ * WHAT THIS IS FOR
6
+ *
7
+ * The DSH plugin covers people who use DSH. Everyone else — CodeBuddy, Claude Code,
8
+ * Cursor, any MCP client — could not reach these groups at all. MCP is the lingua franca
9
+ * for "an agent with tools", so exposing the same nineteen tools here makes the groups
10
+ * usable from any of them.
11
+ *
12
+ * WHAT IT IS NOT
13
+ *
14
+ * There is no UI. MCP has no concept of panels or buttons; it has tools, resources and
15
+ * prompts. So the member panel, the tabs and the @ button exist only in the DSH plugin.
16
+ * This server offers the tools and nothing else, which is the right shape: an agent that
17
+ * can call `a2a_group_post` does not need to see a roster widget.
18
+ *
19
+ * WHY STDIO
20
+ *
21
+ * The host is a remote A2A service; MCP is only how a local agent reaches the tools. stdio
22
+ * means the client spawns this process, so there is no port to open, no TLS to arrange,
23
+ * and no credential to distribute beyond the one this process uses to talk to the host.
24
+ * An HTTP transport can be added later without touching anything below, because the
25
+ * request handling is transport-neutral.
26
+ *
27
+ * PROTOCOL
28
+ *
29
+ * JSON-RPC 2.0, one message per line on stdin, one response per line on stdout. Newline
30
+ * framing only: MCP's stdio transport forbids embedded newlines in a message, which is
31
+ * why every response is serialized compactly.
32
+ *
33
+ * @module dsh-a2a/mcp-server
34
+ */
35
+
36
+ import { createInterface } from 'node:readline';
37
+
38
+ import { allSpecs, specOf } from './shared/tool-specs.js';
39
+ import { createToolRuntime } from './shared/tool-runtime.js';
40
+
41
+ /** The MCP revision this server implements. */
42
+ export const MCP_PROTOCOL_VERSION = '2024-11-05';
43
+
44
+ /** Tools that need a Session id; on MCP the connection supplies one stable id. */
45
+ const CONNECTION_SESSION_ID = 'mcp';
46
+
47
+ /**
48
+ * Build the request handler.
49
+ *
50
+ * Separated from the transport so a test — or a future HTTP transport — can drive it
51
+ * directly without spawning a child process.
52
+ *
53
+ * @param {object} options - `{ deps, sessionId, version }`.
54
+ * @returns {{handle: (request: object) => Promise<object|null>, listTools: () => object[]}} the handler.
55
+ */
56
+ export function createMcpHandler({ deps = {}, sessionId = CONNECTION_SESSION_ID, version = '0.1.0' } = {}) {
57
+ const runtime = createToolRuntime(deps);
58
+
59
+ /**
60
+ * The tool list MCP clients see.
61
+ *
62
+ * `inputSchema` is the same JSON Schema the DSH plugin registers, which is the whole
63
+ * point of splitting the specs out: a model that learned the tool in one host meets the
64
+ * identical contract in the other.
65
+ */
66
+ function listTools() {
67
+ return allSpecs().map((spec) => ({
68
+ name: spec.name,
69
+ description: spec.description,
70
+ inputSchema: spec.parameters,
71
+ }));
72
+ }
73
+
74
+ /**
75
+ * Run one tool and shape the result as MCP content.
76
+ *
77
+ * The spec's own `render` produces the human-readable parts, so the text an MCP client
78
+ * shows is the same text a DSH user sees in a tool card — not a JSON dump that happens
79
+ * to be in the same shape.
80
+ */
81
+ async function callTool(params) {
82
+ const name = typeof params?.name === 'string' ? params.name : '';
83
+ const spec = specOf(name);
84
+ if (spec === undefined) {
85
+ return {
86
+ isError: true,
87
+ content: [{ type: 'text', text: `unknown tool: ${name}` }],
88
+ };
89
+ }
90
+ const args = params?.arguments;
91
+ try {
92
+ const value = await runtime.call(name, args ?? {}, { sessionId });
93
+ // `render` is the spec's own presenter; using it keeps the two hosts consistent.
94
+ const parts = spec.output.render(args ?? {}, value);
95
+ return {
96
+ content: parts.map((part) => ({
97
+ type: 'text',
98
+ text: typeof part?.text === 'string' ? part.text : JSON.stringify(value),
99
+ })),
100
+ // The structured value rides along for a client that wants to act on it rather
101
+ // than display it. Non-standard but additive: clients that ignore it lose nothing.
102
+ structuredContent: value,
103
+ };
104
+ } catch (error) {
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ const code = error?.code === undefined ? '' : `[${error.code}] `;
107
+ return {
108
+ isError: true,
109
+ content: [{ type: 'text', text: `${code}${message}` }],
110
+ };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Dispatch one JSON-RPC request.
116
+ *
117
+ * @param {object} request - the parsed message.
118
+ * @returns {Promise<object|null>} the response, or null for a notification.
119
+ */
120
+ async function handle(request) {
121
+ if (request === null || typeof request !== 'object') {
122
+ return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'invalid request' } };
123
+ }
124
+ const { id, method, params } = request;
125
+ // A request without an id is a notification: answer nothing, not even an error.
126
+ const isNotification = id === undefined || id === null;
127
+
128
+ try {
129
+ switch (method) {
130
+ case 'initialize':
131
+ return {
132
+ jsonrpc: '2.0',
133
+ id,
134
+ result: {
135
+ protocolVersion: MCP_PROTOCOL_VERSION,
136
+ capabilities: { tools: { listChanged: false } },
137
+ serverInfo: { name: 'agent-groups', version },
138
+ // This preamble is the only place a model learns that it has to ASK. MCP notifications cannot reach
139
+ // it — they land in the client program, and no mainstream client turns one into a turn — so without
140
+ // this sentence a colleague's message sits unread forever and the model has no reason to suspect it
141
+ // exists.
142
+ instructions:
143
+ 'Collaboration groups for this machine. THIS SERVER CANNOT REACH YOU: it cannot notify you when '
144
+ + 'a colleague posts or invites you, so checking is your job.\n\n'
145
+ + 'Call a2a_pending at the START of a turn, and again every few minutes during long work. It '
146
+ + 'returns unread group messages plus one-to-one invitations in one call. If it reports nothing, '
147
+ + 'say so briefly and carry on — do not silently skip it.\n\n'
148
+ + 'An invitation blocks progress: join with a2a_group_join (the message carries a ready link) or '
149
+ + 'decline it, then a2a_person_ack so the sender learns it arrived.\n\n'
150
+ + 'Find groups with a2a_group_directory; join without an invite via a2a_group_request then '
151
+ + 'a2a_group_claim once approved. Speak with a2a_group_post — add \`mentions\` to summon specific '
152
+ + 'people, omit it for ordinary progress that only raises an unread count.',
153
+ },
154
+ };
155
+ case 'notifications/initialized':
156
+ case 'initialized':
157
+ // Acknowledged by doing nothing: the client is telling us it is ready.
158
+ return null;
159
+ case 'ping':
160
+ return { jsonrpc: '2.0', id, result: {} };
161
+ case 'tools/list':
162
+ return { jsonrpc: '2.0', id, result: { tools: listTools() } };
163
+ case 'tools/call':
164
+ return { jsonrpc: '2.0', id, result: await callTool(params) };
165
+ case 'resources/list':
166
+ // No resources: this server exposes actions, not documents. Answering with an
167
+ // empty list is better than method-not-found, because a client that probes will
168
+ // stop asking once it sees the capability is present but empty.
169
+ return { jsonrpc: '2.0', id, result: { resources: [] } };
170
+ case 'prompts/list':
171
+ return { jsonrpc: '2.0', id, result: { prompts: [] } };
172
+ default:
173
+ if (isNotification) return null;
174
+ return {
175
+ jsonrpc: '2.0',
176
+ id,
177
+ error: { code: -32601, message: `method not found: ${String(method)}` },
178
+ };
179
+ }
180
+ } catch (error) {
181
+ if (isNotification) return null;
182
+ return {
183
+ jsonrpc: '2.0',
184
+ id,
185
+ error: {
186
+ code: -32603,
187
+ message: error instanceof Error ? error.message : String(error),
188
+ },
189
+ };
190
+ }
191
+ }
192
+
193
+ return { handle, listTools, runtime };
194
+ }
195
+
196
+ /**
197
+ * Serve MCP over stdio until stdin closes.
198
+ *
199
+ * @param {object} options - passed through to `createMcpHandler`.
200
+ * @returns {Promise<void>} resolves when the input stream ends.
201
+ */
202
+ export async function serveStdio(options = {}) {
203
+ const handler = createMcpHandler(options);
204
+
205
+ /** Write one response as a single line. A newline inside the JSON would break framing. */
206
+ const write = (message) => {
207
+ process.stdout.write(`${JSON.stringify(message)}\n`);
208
+ };
209
+
210
+ // Work through messages in order: tools may touch the store, and interleaving two calls
211
+ // that both mutate a group would be a race the group code does not expect.
212
+ let pending = Promise.resolve();
213
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
214
+
215
+ lines.on('line', (line) => {
216
+ const text = line.trim();
217
+ if (text === '') return;
218
+ pending = pending.then(async () => {
219
+ let request;
220
+ try {
221
+ request = JSON.parse(text);
222
+ } catch {
223
+ // A malformed line gets a parse error and the stream continues: killing the
224
+ // server over one bad byte would take down every later call.
225
+ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
226
+ return;
227
+ }
228
+ const response = await handler.handle(request);
229
+ if (response !== null) write(response);
230
+ }).catch((error) => {
231
+ write({
232
+ jsonrpc: '2.0',
233
+ id: null,
234
+ error: { code: -32603, message: error instanceof Error ? error.message : String(error) },
235
+ });
236
+ });
237
+ });
238
+
239
+ await new Promise((resolve) => {
240
+ lines.on('close', resolve);
241
+ });
242
+ await pending;
243
+ }
244
+
245
+ // Only run when invoked directly: importing this file must have no side effects, because
246
+ // the tests import the handler.
247
+ const invokedDirectly = process.argv[1] !== undefined
248
+ && /mcp-server\.js$/.test(process.argv[1].replace(/\\/g, '/'));
249
+
250
+ if (invokedDirectly) {
251
+ // The dependencies a real run needs. `deps` is intentionally empty here: the group tools
252
+ // reach a REMOTE host over A2A, so the only state this process needs is its own
253
+ // configuration, which arrives through the environment.
254
+ // Dynamic because importing the deps would construct a bridge, a registry and a ledger
255
+ // at module load — side effects a test that only wants the handler must not inherit.
256
+ const { createHostDepsFromEnv } = await import('./mcp/mcp-deps.js');
257
+ serveStdio({ deps: createHostDepsFromEnv(process.env) }).catch((error) => {
258
+ process.stderr.write(`agent-groups-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
259
+ process.exitCode = 1;
260
+ });
261
+ }