@stage-labs/metro 0.1.0-beta.305 → 0.1.0-beta.307

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.
Files changed (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/memory.js +2 -3
  3. package/dist/runtime-install.js +3 -11
  4. package/dist/voice.js +1 -1
  5. package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
  6. package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
  7. package/package.json +4 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
  9. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
  10. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
  11. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
  12. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +15 -10
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +37 -30
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +0 -2
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
  33. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  34. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
  35. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
  36. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
  37. package/runtime/runtime.json +1 -1
  38. package/runtime/sdk-runner/runner.json +1 -1
  39. package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
  40. package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
  41. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -16
  42. package/runtime/marketplace/plugin/.mcp.json +0 -1
  43. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -24
  44. package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
  45. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -100
  47. /package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +0 -0
  48. /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
  49. /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
@@ -24,6 +24,7 @@ export interface McpLinkDeps {
24
24
  key: () => AgentKey | null;
25
25
  dir: () => string;
26
26
  hold: HoldApproval;
27
+ registered?: () => void;
27
28
  fetch?: AgentFetch;
28
29
  timing?: Partial<McpTiming>;
29
30
  }
@@ -61,6 +62,10 @@ export class McpServerLink {
61
62
  await this.running;
62
63
  }
63
64
 
65
+ reregister(): void {
66
+ this.registered = null;
67
+ }
68
+
64
69
  async answer(id: string, decision: McpDecision): Promise<AnswerOutcome> {
65
70
  for (let attempt = 0; !this.stopped; attempt += 1) {
66
71
  if (attempt > 0) await this.pause(this.delay(attempt));
@@ -124,6 +129,7 @@ export class McpServerLink {
124
129
  await agentCall(this.api, key, MCP_PATHS.token, { tokenHash: token.hash }, { signal: this.abort.signal });
125
130
  this.registered = binding;
126
131
  log.info({ server: this.deps.url, keyId: key.keyId }, 'mcp-server: the MCP token is registered');
132
+ this.deps.registered?.();
127
133
  }
128
134
 
129
135
  private async poll(key: AgentKey): Promise<void> {
@@ -168,17 +174,22 @@ let active: McpServerLink | null = null;
168
174
  export interface McpServerWatch {
169
175
  owner: () => string | null;
170
176
  hold: HoldApproval;
177
+ registered?: () => void;
171
178
  }
172
179
 
173
180
  export function watchMcpServer(watch: McpServerWatch, env: NodeJS.ProcessEnv = process.env): boolean {
174
181
  const url = mcpServerUrl(env);
175
182
  if (url === null || active !== null) return false;
176
- active = new McpServerLink({ url, owner: watch.owner, key: () => readAgentKey(), dir: agentsDir, hold: watch.hold });
183
+ active = new McpServerLink({ url, owner: watch.owner, key: () => readAgentKey(), dir: agentsDir, hold: watch.hold, ...(watch.registered === undefined ? {} : { registered: watch.registered }) });
177
184
  active.start();
178
185
  log.info({ server: url }, 'mcp-server: connector approvals relay from this server once the agent is enrolled');
179
186
  return true;
180
187
  }
181
188
 
189
+ export function reregisterMcpToken(): void {
190
+ active?.reregister();
191
+ }
192
+
182
193
  export async function stopMcpServer(): Promise<void> {
183
194
  const link = active;
184
195
  active = null;
@@ -0,0 +1,67 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { errMsg } from '@metro-labs/core/log';
3
+ import { agentsDir } from '../agents/files.js';
4
+ import { mcpServerUrl } from './config.js';
5
+ import { reregisterMcpToken } from './link.js';
6
+ import { readMcpToken } from './token.js';
7
+
8
+ export interface ServerDeps {
9
+ url?: string | null;
10
+ token?: string | null;
11
+ fetch?: (url: string, init: RequestInit) => Promise<Response>;
12
+ reregister?: () => void;
13
+ }
14
+
15
+ export type ServerAnswer =
16
+ | { ok: true; result: unknown }
17
+ | { ok: false; reason: 'unset' }
18
+ | { ok: false; reason: 'unavailable'; detail: string }
19
+ | { ok: false; reason: 'refused'; message: string };
20
+
21
+ let nextId = 1;
22
+
23
+ const unavailable = (detail: string): ServerAnswer => ({ ok: false, reason: 'unavailable', detail });
24
+
25
+ async function bodyOf(res: Response): Promise<ServerAnswer> {
26
+ let body: unknown;
27
+ try {
28
+ body = JSON.parse(await res.text());
29
+ } catch (err) {
30
+ return unavailable(`unreadable answer: ${errMsg(err)}`);
31
+ }
32
+ if (isRecord(body) && isRecord(body.error))
33
+ return { ok: false, reason: 'refused', message: typeof body.error.message === 'string' ? body.error.message.slice(0, 500) : 'refused' };
34
+ return isRecord(body) && Object.hasOwn(body, 'result') ? { ok: true, result: body.result } : unavailable('no JSON-RPC result');
35
+ }
36
+
37
+ interface Target {
38
+ url: string;
39
+ token: string;
40
+ }
41
+
42
+ function targetOf(deps: ServerDeps): Target | null {
43
+ const url = deps.url === undefined ? mcpServerUrl() : deps.url;
44
+ const token = deps.token === undefined ? readMcpToken(agentsDir()) : deps.token;
45
+ return url === null || token === null ? null : { url, token };
46
+ }
47
+
48
+ export async function askServer(method: string, params: Record<string, unknown>, timeoutMs: number, deps: ServerDeps = {}): Promise<ServerAnswer> {
49
+ const target = targetOf(deps);
50
+ if (target === null) return { ok: false, reason: 'unset' };
51
+ const { url, token } = target;
52
+ let res: Response;
53
+ try {
54
+ res = await (deps.fetch ?? fetch)(`${url}/`, {
55
+ method: 'POST',
56
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
57
+ body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method, params }),
58
+ redirect: 'manual',
59
+ signal: AbortSignal.timeout(timeoutMs),
60
+ });
61
+ } catch (err) {
62
+ return unavailable(errMsg(err));
63
+ }
64
+ if (res.status === 401) (deps.reregister ?? reregisterMcpToken)();
65
+ if (!res.ok) return unavailable(`HTTP ${String(res.status)}`);
66
+ return bodyOf(res);
67
+ }
@@ -24,7 +24,8 @@
24
24
  "./connector-input": "./src/connector-input.ts",
25
25
  "./connector-policy": "./src/connector-policy.ts",
26
26
  "./connector-grant": "./src/connector-grant.ts",
27
- "./mcp-server-protocol": "./src/mcp-server-protocol.ts"
27
+ "./mcp-server-protocol": "./src/mcp-server-protocol.ts",
28
+ "./mcp-server-tools": "./src/mcp-server-tools.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "tsc",
@@ -1,4 +1,4 @@
1
- import { createHash, createPublicKey, diffieHellman, generateKeyPairSync, randomBytes, sign, verify, type KeyObject } from 'node:crypto';
1
+ import { createHash, createPublicKey, randomBytes, sign, verify, type KeyObject } from 'node:crypto';
2
2
  import { strongEd25519 } from './ed25519.js';
3
3
 
4
4
  export const AGENT_SCHEME = 'MetroAgent';
@@ -25,40 +25,25 @@ export interface AgentProof {
25
25
  signature: string;
26
26
  }
27
27
 
28
- type Curve = 'Ed25519' | 'X25519';
29
-
30
28
  const digest = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('base64url');
31
29
 
32
30
  export const agentKeyId = (signingKey: string): string => digest(Buffer.from(signingKey, 'base64url'));
33
31
 
34
- function publicKeyOf(raw: string, curve: Curve): KeyObject | null {
32
+ function publicKeyOf(raw: string): KeyObject | null {
35
33
  if (!RAW_KEY_RE.test(raw) || Buffer.from(raw, 'base64url').toString('base64url') !== raw) return null;
36
34
  try {
37
- const key = createPublicKey({ key: { kty: 'OKP', crv: curve, x: raw }, format: 'jwk' });
38
- return key.asymmetricKeyType === curve.toLowerCase() ? key : null;
35
+ const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw }, format: 'jwk' });
36
+ return key.asymmetricKeyType === 'ed25519' ? key : null;
39
37
  } catch {
40
38
  return null;
41
39
  }
42
40
  }
43
41
 
44
42
  export function signingKeyOf(raw: string): KeyObject | null {
45
- const key = publicKeyOf(raw, 'Ed25519');
43
+ const key = publicKeyOf(raw);
46
44
  return key !== null && strongEd25519(Buffer.from(raw, 'base64url')) ? key : null;
47
45
  }
48
46
 
49
- function contributes(key: KeyObject): boolean {
50
- try {
51
- return diffieHellman({ privateKey: generateKeyPairSync('x25519').privateKey, publicKey: key }).some((byte) => byte !== 0);
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
- export function sealingKeyOf(raw: string): KeyObject | null {
58
- const key = publicKeyOf(raw, 'X25519');
59
- return key !== null && contributes(key) ? key : null;
60
- }
61
-
62
47
  export function rawPublicKey(key: KeyObject): string {
63
48
  const { x } = key.export({ format: 'jwk' });
64
49
  if (typeof x !== 'string') throw new Error('not an OKP public key');
@@ -7,6 +7,11 @@ export const MCP_CALLS_MAX = 10;
7
7
  export const MCP_QUERY_MAX = 500;
8
8
  export const MCP_APPROVAL_WAIT_MS = 25_000;
9
9
  export const MCP_APPROVAL_ID_RE = /^[a-km-z]{5}$/;
10
+ export const TOOL_SEARCH = 'tool_search';
11
+ export const TOOL_CALL = 'tool_call';
12
+ export const TOOL_CALL_EXAMPLE = '{"calls":[{"tool":"notion.search","arguments":{"query":"roadmap"}}]}';
13
+
14
+ const EXPECTED = ` Expected: ${TOOL_CALL_EXAMPLE}, with names as tool_search returns them.`;
10
15
  export const MCP_TOKEN_RE = /^mcp_[A-Za-z0-9_-]{43}$/;
11
16
 
12
17
  export type McpErrorCode = 'blocked' | 'needs_approval' | 'denied' | 'not_found' | 'signin' | 'unavailable' | 'vendor_error' | 'invalid';
@@ -40,23 +45,23 @@ export function splitMcpTool(name: string): { connector: string; tool: string }
40
45
  }
41
46
 
42
47
  export function mcpFindRequestOf(raw: unknown): McpFindRequest {
43
- if (!isRecord(raw) || typeof raw.query !== 'string' || raw.query.trim() === '' || raw.query.length > MCP_QUERY_MAX) throw new Error('find_tools needs a query of 1 to 500 characters.');
48
+ if (!isRecord(raw) || typeof raw.query !== 'string' || raw.query.trim() === '' || raw.query.length > MCP_QUERY_MAX) throw new Error('tool_search needs a query of 1 to 500 characters.');
44
49
  const wanted = raw.max_results ?? MCP_FIND_DEFAULT;
45
50
  if (typeof wanted !== 'number' || !Number.isInteger(wanted) || wanted < 1) throw new Error('max_results must be a whole number from 1.');
46
51
  return { query: raw.query.trim(), max_results: Math.min(wanted, MCP_FIND_MAX) };
47
52
  }
48
53
 
49
54
  function callOf(raw: unknown): McpCall {
50
- if (!isRecord(raw) || typeof raw.tool !== 'string' || splitMcpTool(raw.tool) === null) throw new Error('Each call needs a tool named <connector>.<tool>, as find_tools returns it.');
55
+ if (!isRecord(raw) || typeof raw.tool !== 'string' || splitMcpTool(raw.tool) === null) throw new Error(`Each call needs a tool named <connector>.<tool>, as tool_search returns it.${EXPECTED}`);
51
56
  const args = raw.arguments ?? {};
52
- if (!isRecord(args)) throw new Error(`The arguments of ${raw.tool} must be an object.`);
57
+ if (!isRecord(args)) throw new Error(`The arguments of ${raw.tool} must be an object.${EXPECTED}`);
53
58
  if (raw.approval !== undefined && (typeof raw.approval !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.approval))) throw new Error('approval must be the 5-letter id the approval request gave.');
54
59
  return { tool: raw.tool, arguments: args, ...(typeof raw.approval === 'string' ? { approval: raw.approval } : {}) };
55
60
  }
56
61
 
57
62
  export function mcpCallsOf(raw: unknown): McpCall[] {
58
63
  const calls = isRecord(raw) ? raw.calls : undefined;
59
- if (!Array.isArray(calls) || calls.length === 0 || calls.length > MCP_CALLS_MAX) throw new Error(`call_tool needs 1 to ${String(MCP_CALLS_MAX)} calls.`);
64
+ if (!Array.isArray(calls) || calls.length === 0 || calls.length > MCP_CALLS_MAX) throw new Error(`tool_call needs 1 to ${String(MCP_CALLS_MAX)} calls in "calls".${EXPECTED}`);
60
65
  return calls.map(callOf);
61
66
  }
62
67
 
@@ -0,0 +1,93 @@
1
+ import { MCP_CALLS_MAX, MCP_FIND_DEFAULT, MCP_FIND_MAX, TOOL_CALL, TOOL_CALL_EXAMPLE, TOOL_SEARCH } from './mcp-server-protocol.js';
2
+
3
+ export interface McpServerTool {
4
+ name: string;
5
+ title: string;
6
+ description: string;
7
+ inputSchema: Record<string, unknown>;
8
+ annotations: Record<string, unknown>;
9
+ }
10
+
11
+ const searchSchema = {
12
+ type: 'object',
13
+ properties: {
14
+ query: {
15
+ type: 'string',
16
+ description: 'What to look for: "select:<connector>.<tool>" (comma separated) loads exact tools, keywords rank tools, "+word" requires that word in the tool name.',
17
+ },
18
+ max_results: { type: 'number', description: `Maximum number of results to return (default: ${String(MCP_FIND_DEFAULT)}, at most ${String(MCP_FIND_MAX)})`, default: MCP_FIND_DEFAULT },
19
+ },
20
+ required: ['query'],
21
+ additionalProperties: false,
22
+ };
23
+
24
+ const callSchema = {
25
+ type: 'object',
26
+ properties: {
27
+ calls: {
28
+ type: 'array',
29
+ minItems: 1,
30
+ maxItems: MCP_CALLS_MAX,
31
+ description: `1 to ${String(MCP_CALLS_MAX)} independent calls. They run in parallel and are answered in order.`,
32
+ items: {
33
+ type: 'object',
34
+ properties: {
35
+ tool: { type: 'string', description: 'The tool name exactly as tool_search returned it: <connector>.<tool>.' },
36
+ arguments: { type: 'object', description: 'The tool\'s arguments, matching the parameters tool_search returned.', additionalProperties: true },
37
+ approval: { type: 'string', pattern: '^[a-km-z]{5}$', description: 'The 5-letter approval id, only when repeating a call the owner approved.' },
38
+ },
39
+ required: ['tool'],
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ },
44
+ required: ['calls'],
45
+ additionalProperties: false,
46
+ };
47
+
48
+ function connectorsLine(keys: string[] | null): string {
49
+ if (keys === null) return 'The connectors of this agent are named here once Metro reaches mcp.metro.box.';
50
+ return keys.length === 0 ? 'This agent has no connectors it may use yet.' : `This agent's connectors: ${keys.join(', ')}.`;
51
+ }
52
+
53
+ export const toolSearchDescription = (keys: string[] | null): string =>
54
+ [
55
+ 'Search the tools of every connector assigned to this agent: third-party services such as Notion, n8n, Linear or Gmail, run by mcp.metro.box. Same design as Claude Code\'s ToolSearch, for connectors.',
56
+ connectorsLine(keys),
57
+ 'Three query forms, one example each:',
58
+ '- "select:notion.search" loads exact tools by name (comma separated for several).',
59
+ '- "notion create page" ranks tools by the words in their name and description.',
60
+ '- "+linear issue" requires "linear" in the tool name and ranks by the other words.',
61
+ 'Results are full definitions (name, description and the JSON schema of the arguments) in a <functions> block. Each name is <connector>.<tool>: pass it exactly as "tool" to tool_call. Tools the owner blocked are never returned.',
62
+ ].join('\n');
63
+
64
+ export const TOOL_CALL_DESCRIPTION = [
65
+ 'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
66
+ `Shape: ${TOOL_CALL_EXAMPLE}`,
67
+ `Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
68
+ '- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
69
+ '- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
70
+ '- denied or blocked: the owner refused or blocked this call. Do not retry it.',
71
+ '- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
72
+ '- unavailable: the connector or Metro could not be reached. Try again later.',
73
+ '- vendor_error: the connector answered with its own error, shown as it came.',
74
+ ].join('\n');
75
+
76
+ export function mcpServerTools(keys: string[] | null): McpServerTool[] {
77
+ return [
78
+ {
79
+ name: TOOL_SEARCH,
80
+ title: 'Search connector tools',
81
+ description: toolSearchDescription(keys),
82
+ inputSchema: searchSchema,
83
+ annotations: { title: 'Search connector tools', readOnlyHint: true, openWorldHint: false },
84
+ },
85
+ {
86
+ name: TOOL_CALL,
87
+ title: 'Call connector tools',
88
+ description: TOOL_CALL_DESCRIPTION,
89
+ inputSchema: callSchema,
90
+ annotations: { title: 'Call connector tools', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
91
+ },
92
+ ];
93
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.305"
2
+ "version": "0.1.0-beta.307"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.305",
2
+ "version": "0.1.0-beta.307",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: metro
3
- description: How this box's agent works on metro. Acknowledge, delegate, relay, answer over chat, never block on the terminal. Loaded at every session start by the metro plugin; edit it here to change the rules on this box.
3
+ description: How this box's agent works on metro. Acknowledge, delegate, relay, answer over chat, never block on the terminal. Loaded at every session start by a metro hook; edit it here to change the rules on this box.
4
4
  ---
5
5
 
6
- You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard in the metro plugin denies every other tool on this thread, so this is the shape of the work, not a preference.
6
+ You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard metro installs denies every other tool on this thread, so this is the shape of the work, not a preference.
7
7
 
8
8
  **Inbound.** A message arrives as a `<channel source="metro" …>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (👀, then 👍 or ✅ once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim. Exception: when the person asks you to close something ("mark as done"), skip the reaction and the acknowledgement: close it directly and confirm once when it is finished.
9
9
 
@@ -15,7 +15,12 @@ You are the main thread of a Claude Code session that is connected to chat throu
15
15
 
16
16
  **Calls that need the owner's approval run in a worker.** The owner can make a metro tool on a channel wait for approval. On the main thread such a call is refused, because waiting for the answer would stop the whole session: hand that exact call to a background worker (`run_in_background: true`) and keep answering. The worker waits for the owner's answer on its own.
17
17
 
18
- **Connectors through the `mcp` server.** Every connector the organization gave this agent sits behind the `mcp` server's two tools. Find what you need with `find_tools`: `select:notion.search,linear.create_issue` for exact names, keywords (`notion page update`) to rank, or `+slack send` to require `slack` in the tool name. Have a worker run them with `call_tool`, up to 10 independent calls in one go. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker call `call_tool` again with the same tool and arguments and `approval: "<id>"`.
18
+ **Connectors.** Third-party tools the organization gave this agent (Notion, n8n, Linear, Gmail and the like) are reached only through two metro tools, in two steps:
19
+
20
+ 1. `tool_search` finds a tool and returns its full definition: `select:notion.search` for an exact name, words (`notion create page`) to rank, or `+linear issue` to require `linear` in the name. Each name is `<connector>.<tool>`.
21
+ 2. `tool_call` runs it, with the name exactly as found and arguments that match its schema. After `tool_search` with `select:notion.search` returned `notion.search`: `{"calls": [{"tool": "notion.search", "arguments": {"query": "roadmap"}}]}`.
22
+
23
+ Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. `tool_search` may run on this thread, but never call `tool_call` here: delegate it to a worker, as all other work. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
19
24
 
20
25
  **Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
21
26
 
@@ -1,12 +0,0 @@
1
- {
2
- "name": "metro",
3
- "description": "Metro — connector relay and chat bridge for agents",
4
- "owner": { "name": "Bonustrack", "url": "https://metro.box" },
5
- "plugins": [
6
- {
7
- "name": "metro",
8
- "source": "./plugin",
9
- "description": "Every connector on this box as its own MCP server through metro's local relay, the orchestrator guard, and the standing rules loaded at session start."
10
- }
11
- ]
12
- }
@@ -1,16 +0,0 @@
1
- {
2
- "name": "metro",
3
- "version": "0.1.0-beta.305",
4
- "description": "Metro for Claude Code, installed by the metro daemon on this machine: one `mcp` server on mcp.metro.box for the organization connectors (find_tools, call_tool), the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever reaches this machine.",
5
- "author": {
6
- "name": "Bonustrack",
7
- "url": "https://metro.box"
8
- },
9
- "repository": "https://github.com/bonustrack/metro",
10
- "license": "MIT",
11
- "keywords": [
12
- "metro",
13
- "mcp",
14
- "connectors"
15
- ]
16
- }
@@ -1 +0,0 @@
1
- {}
@@ -1,24 +0,0 @@
1
- #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
7
- const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
8
-
9
- function readMcpToken() {
10
- try {
11
- const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
12
- return MCP_TOKEN.test(token) ? token : null;
13
- } catch {
14
- return null;
15
- }
16
- }
17
-
18
- if (process.argv[2] !== 'mcp-headers') {
19
- process.stderr.write('usage: metro-plugin.mjs mcp-headers\n');
20
- process.exit(1);
21
- }
22
-
23
- const token = readMcpToken();
24
- if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
@@ -1,25 +0,0 @@
1
- {
2
- "hooks": {
3
- "PreToolUse": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/guard.mjs\"",
9
- "statusMessage": "Checking the orchestrator boundary"
10
- }
11
- ]
12
- }
13
- ],
14
- "SessionStart": [
15
- {
16
- "hooks": [
17
- {
18
- "type": "command",
19
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/session-start.mjs\""
20
- }
21
- ]
22
- }
23
- ]
24
- }
25
- }
@@ -1,149 +0,0 @@
1
- import { execFile } from 'node:child_process';
2
- import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { isRecord } from '@metro-labs/core/is-record';
6
- import { log } from '@metro-labs/core/log';
7
- import { agentMarketplaceDir, agentUser, asAgent, claudeBin } from '../agent-user/user.js';
8
-
9
- const MARKETPLACE = 'metro';
10
- const PLUGIN = 'metro@metro';
11
- const STEP_MS = 120_000;
12
- const SAID_MAX = 300;
13
-
14
- export interface Answer {
15
- status: number;
16
- stdout: string;
17
- stderr: string;
18
- }
19
-
20
- export type Run = (args: string[]) => Promise<Answer>;
21
-
22
- export interface PluginInstallDeps {
23
- run: Run | null;
24
- marketplaceDir: string | null;
25
- }
26
-
27
- export type PluginOutcome = 'skipped' | 'unchanged' | 'installed' | 'updated' | 'failed';
28
-
29
- function storeMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
30
- const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
31
- if (store === '') return null;
32
- const dir = join(store, 'marketplace');
33
- return existsSync(join(dir, '.claude-plugin', 'marketplace.json')) ? dir : null;
34
- }
35
-
36
- export function stagedMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
37
- const store = storeMarketplaceDir(env);
38
- const user = agentUser();
39
- if (store === null || user === null) return store;
40
- const copy = agentMarketplaceDir(user);
41
- return existsSync(join(copy, '.claude-plugin', 'marketplace.json')) ? copy : null;
42
- }
43
-
44
- export function stagedPluginVersion(dir: string): string | null {
45
- try {
46
- const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
47
- return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : null;
48
- } catch {
49
- return null;
50
- }
51
- }
52
-
53
- const runner =
54
- (bin: string): Run =>
55
- (args) =>
56
- new Promise((resolve) => {
57
- execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
58
- const status = err === null ? 0 : typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 1;
59
- resolve({ status, stdout, stderr });
60
- });
61
- });
62
-
63
- async function findClaude(candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()]): Promise<Run | null> {
64
- for (const bin of candidates) {
65
- const run = runner(bin);
66
- if ((await run(['--version'])).status === 0) return run;
67
- }
68
- return null;
69
- }
70
-
71
- function listOf(stdout: string): Record<string, unknown>[] {
72
- try {
73
- const parsed: unknown = JSON.parse(stdout);
74
- return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
75
- } catch {
76
- return [];
77
- }
78
- }
79
-
80
- const realish = (path: string): string => {
81
- try {
82
- return realpathSync(path);
83
- } catch {
84
- return path;
85
- }
86
- };
87
-
88
- const samePath = (a: string | null, b: string): boolean => a !== null && realish(a) === realish(b);
89
-
90
- async function step(run: Run, args: string[]): Promise<boolean> {
91
- const answer = await run(args);
92
- if (answer.status === 0) {
93
- log.info({ args }, 'plugin: claude');
94
- return true;
95
- }
96
- log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'plugin: claude refused');
97
- return false;
98
- }
99
-
100
- async function marketplacePath(run: Run): Promise<string | null | undefined> {
101
- const mine = listOf((await run(['plugin', 'marketplace', 'list', '--json'])).stdout).find((m) => m.name === MARKETPLACE);
102
- if (mine === undefined) return undefined;
103
- const at = mine.installLocation ?? mine.path;
104
- return typeof at === 'string' ? at : null;
105
- }
106
-
107
- async function ensureMarketplace(run: Run, dir: string): Promise<boolean> {
108
- const at = await marketplacePath(run);
109
- if (at !== undefined && samePath(at, dir)) return true;
110
- if (at !== undefined) await step(run, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
111
- return step(run, ['plugin', 'marketplace', 'add', dir]);
112
- }
113
-
114
- async function installedVersion(run: Run): Promise<string | null> {
115
- const mine = listOf((await run(['plugin', 'list', '--json'])).stdout).find((p) => p.id === PLUGIN);
116
- return mine !== undefined && typeof mine.version === 'string' ? mine.version : null;
117
- }
118
-
119
- async function ensureInstalled(run: Run, wanted: string | null): Promise<PluginOutcome> {
120
- const have = await installedVersion(run);
121
- if (have === null) return (await step(run, ['plugin', 'install', PLUGIN, '-y', '--scope', 'user'])) ? 'installed' : 'failed';
122
- if (wanted === null || have === wanted) return 'unchanged';
123
- await step(run, ['plugin', 'marketplace', 'update', MARKETPLACE]);
124
- return (await step(run, ['plugin', 'update', PLUGIN, '-y'])) ? 'updated' : 'failed';
125
- }
126
-
127
- function explainMissingMarketplace(env: NodeJS.ProcessEnv = process.env): void {
128
- const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
129
- if (store === '') return;
130
- log.warn(
131
- { store },
132
- 'plugin: the runtime store holds no plugin marketplace, so metro cannot install its Claude Code plugin; restart the metro service once so `metro serve` stages it again',
133
- );
134
- }
135
-
136
- export async function ensureMetroPlugin(deps?: Partial<PluginInstallDeps>): Promise<PluginOutcome> {
137
- const dir = deps?.marketplaceDir === undefined ? stagedMarketplaceDir() : deps.marketplaceDir;
138
- if (dir === null) {
139
- if (deps?.marketplaceDir === undefined) explainMissingMarketplace();
140
- return 'skipped';
141
- }
142
- const run = deps?.run === undefined ? await findClaude() : deps.run;
143
- if (run === null) {
144
- log.info('plugin: claude is not on this machine, so metro leaves its Claude Code plugin alone');
145
- return 'skipped';
146
- }
147
- if (!(await ensureMarketplace(run, dir))) return 'failed';
148
- return ensureInstalled(run, stagedPluginVersion(dir));
149
- }