@stage-labs/metro 0.1.0-beta.303 → 0.1.0-beta.305
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +3 -4
- package/runtime/marketplace/plugin/METRO.md +2 -0
- package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +9 -15
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -32
- package/runtime/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +11 -10
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -9
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
- package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -51
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +22 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +0 -14
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +108 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +31 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
- package/runtime/node_modules/@metro-labs/http/package.json +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
4
|
-
"description": "Metro for Claude Code, installed by the metro daemon on this machine:
|
|
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
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
7
7
|
"url": "https://metro.box"
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
"keywords": [
|
|
12
12
|
"metro",
|
|
13
13
|
"mcp",
|
|
14
|
-
"connectors"
|
|
15
|
-
"relay"
|
|
14
|
+
"connectors"
|
|
16
15
|
]
|
|
17
16
|
}
|
|
@@ -15,6 +15,8 @@ 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>"`.
|
|
19
|
+
|
|
18
20
|
**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.
|
|
19
21
|
|
|
20
22
|
The exception is a person describing a problem, asking a question or thinking out loud. There the answer IS the deliverable: reply, and change nothing until they ask.
|
|
@@ -23,6 +23,7 @@ const ORCHESTRATION = new Set([
|
|
|
23
23
|
const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
24
24
|
const MCP = /^mcp__/;
|
|
25
25
|
const METRO_MCP = /^mcp__metro__/;
|
|
26
|
+
const FIND_TOOLS = 'mcp__plugin_metro_mcp__find_tools';
|
|
26
27
|
const VOICE = process.env.METRO_SESSION === 'voice';
|
|
27
28
|
|
|
28
29
|
function answer(decision, reason) {
|
|
@@ -54,7 +55,7 @@ function verdict(payload, input) {
|
|
|
54
55
|
: 'A foreground subagent blinds the main thread: it cannot see or acknowledge inbound metro messages until the agent finishes. Re-issue the identical Agent call with run_in_background: true, then wait for the task notification.';
|
|
55
56
|
if (VOICE) return null;
|
|
56
57
|
if (ORCHESTRATION.has(tool)) return null;
|
|
57
|
-
if (METRO_MCP.test(tool)) return null;
|
|
58
|
+
if (METRO_MCP.test(tool) || tool === FIND_TOOLS) return null;
|
|
58
59
|
if (MCP.test(tool))
|
|
59
60
|
return `A connector call is work, not orchestration: ${tool} reads somebody's files, mail or records, and its answer can be long enough to crowd out the chat you are here to follow. Only metro's own tools run on this thread. Delegate it to a subagent with the Agent tool (run_in_background: true; the worker reaches every connector) and relay what it finds.`;
|
|
60
61
|
if (tool === 'Read' && typeof input.file_path === 'string' && IMAGE.test(input.file_path)) return null;
|
|
@@ -4,27 +4,21 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
|
|
7
|
+
const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
|
|
7
8
|
|
|
8
|
-
function
|
|
9
|
-
const path = join(agentsDir(), 'agent.json');
|
|
10
|
-
let parsed;
|
|
9
|
+
function readMcpToken() {
|
|
11
10
|
try {
|
|
12
|
-
|
|
11
|
+
const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
|
|
12
|
+
return MCP_TOKEN.test(token) ? token : null;
|
|
13
13
|
} catch {
|
|
14
|
-
|
|
14
|
+
return null;
|
|
15
15
|
}
|
|
16
|
-
if (typeof parsed?.key !== 'string') throw new Error(`no agent key in ${path}`);
|
|
17
|
-
return parsed;
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
if (process.argv[2] !== 'headers') {
|
|
21
|
-
process.stderr.write('usage: metro-plugin.mjs headers\n');
|
|
18
|
+
if (process.argv[2] !== 'mcp-headers') {
|
|
19
|
+
process.stderr.write('usage: metro-plugin.mjs mcp-headers\n');
|
|
22
20
|
process.exit(1);
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} catch (err) {
|
|
28
|
-
process.stderr.write(`metro: ${err.message}\n`);
|
|
29
|
-
process.stdout.write('{}\n');
|
|
30
|
-
}
|
|
23
|
+
const token = readMcpToken();
|
|
24
|
+
if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
|
|
@@ -56,42 +56,11 @@ function decide(policy, name, group) {
|
|
|
56
56
|
return access in RANK ? access : 'allow';
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
60
|
-
|
|
61
|
-
function connectorCall(snap, toolName) {
|
|
62
|
-
const entries = Object.entries(isRecord(snap.connectors) ? snap.connectors : {});
|
|
63
|
-
const legacy = entries.filter(([, entry]) => isRecord(entry) && !/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id)) && toolName.startsWith(`mcp__${normalized(`metro.box ${text(entry.name)}`)}__`));
|
|
64
|
-
if (legacy.length > 1) return { name: 'ambiguous connector name; use its instance server key', tool: toolName, group: 'write', policy: { write: 'deny' } };
|
|
65
|
-
for (const [server, entry] of entries) {
|
|
66
|
-
if (!isRecord(entry)) continue;
|
|
67
|
-
const name = text(entry.name);
|
|
68
|
-
const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
|
|
69
|
-
if (!/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id))) prefixes.push(`mcp__${normalized(`metro.box ${name}`)}__`);
|
|
70
|
-
const prefix = prefixes.find((p) => toolName.startsWith(p) && toolName.length > p.length);
|
|
71
|
-
if (prefix === undefined) continue;
|
|
72
|
-
const called = toolName.slice(prefix.length);
|
|
73
|
-
const tools = isRecord(entry.tools) ? entry.tools : {};
|
|
74
|
-
const policy = isRecord(entry.policy) ? entry.policy : {};
|
|
75
|
-
const known = [...Object.keys(tools), ...Object.keys(isRecord(policy.tools) ? policy.tools : {})];
|
|
76
|
-
const tool = known.find((k) => k === called) ?? known.find((k) => normalized(k) === called) ?? called;
|
|
77
|
-
return { name: name || server, tool, group: tools[tool] === 'read' ? 'read' : 'write', policy };
|
|
78
|
-
}
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function connectorVerdict(snap, toolName) {
|
|
83
|
-
const call = connectorCall(snap, toolName);
|
|
84
|
-
if (call === null) return null;
|
|
85
|
-
const access = decide(call.policy, call.tool, call.group);
|
|
86
|
-
return access === 'allow' ? null : { access, tool: call.tool, owner: call.name, where: `${call.tool} on ${call.name}` };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
59
|
export function policyVerdict(toolName, input) {
|
|
90
60
|
const name = METRO_TOOL.exec(toolName)?.[1];
|
|
91
|
-
if (name === undefined
|
|
61
|
+
if (name === undefined) return null;
|
|
92
62
|
const snap = readSnapshot();
|
|
93
63
|
if (snap === null) return null;
|
|
94
|
-
if (name === undefined) return connectorVerdict(snap, toolName);
|
|
95
64
|
const tools = isRecord(snap.tools) ? snap.tools : {};
|
|
96
65
|
const group = tools[name] === 'read' ? 'read' : 'write';
|
|
97
66
|
const accounts = isRecord(snap.accounts) ? snap.accounts : {};
|
|
@@ -3,6 +3,8 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
5
|
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { mcpServerUrl } from '../mcp-server/config.js';
|
|
7
|
+
import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
|
|
6
8
|
import { removeHome, writeHomeText } from './home-fs.js';
|
|
7
9
|
import { closeMetroHome } from './metro-home.js';
|
|
8
10
|
import { agentUser, agentViewDir, type AgentUser } from './user.js';
|
|
@@ -49,7 +51,8 @@ export function viewFiles(dir = agentsDir()): Map<string, string | null> {
|
|
|
49
51
|
['agent.json', agentFile(dir)],
|
|
50
52
|
['model.json', modelFile(dir)],
|
|
51
53
|
]);
|
|
52
|
-
|
|
54
|
+
const copied = mcpServerUrl() === null ? COPIED : [...COPIED, MCP_TOKEN_FILE];
|
|
55
|
+
for (const name of copied) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
|
|
53
56
|
return files;
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -26,7 +26,6 @@ export interface AgentApiDeps extends AccountApiDeps {
|
|
|
26
26
|
toolGroups?: () => Record<string, { name: string; group: string }[]>;
|
|
27
27
|
attachable?: string[];
|
|
28
28
|
features?: string[];
|
|
29
|
-
connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
type Routable =
|
|
@@ -51,8 +50,8 @@ export function target(path: string): Target {
|
|
|
51
50
|
return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
function agentPayload(agent: AgentSummary
|
|
55
|
-
return { id: agent.id, name: agent.name
|
|
53
|
+
function agentPayload(agent: AgentSummary): Record<string, unknown> {
|
|
54
|
+
return { id: agent.id, name: agent.name };
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
function wantsAccounts(req: IncomingMessage): boolean {
|
|
@@ -66,9 +65,8 @@ async function handleList(
|
|
|
66
65
|
deps: AgentApiDeps,
|
|
67
66
|
): Promise<void> {
|
|
68
67
|
const list = await deps.listAgents();
|
|
69
|
-
const connectors = await deps.connectorIds(list.map((a) => a.id));
|
|
70
68
|
const base = {
|
|
71
|
-
agents: list.map((a) => agentPayload(a
|
|
69
|
+
agents: list.map((a) => agentPayload(a)),
|
|
72
70
|
capabilities: deps.capabilities(),
|
|
73
71
|
features: deps.features ?? [],
|
|
74
72
|
attachable: deps.attachable ?? ATTACHABLE,
|
|
@@ -17,6 +17,7 @@ export interface PendingPrompt {
|
|
|
17
17
|
line: string | undefined;
|
|
18
18
|
at: number;
|
|
19
19
|
call?: CallSource;
|
|
20
|
+
origin?: 'mcp-server';
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface Held extends PendingPrompt {
|
|
@@ -93,6 +94,8 @@ export const pendingPrompts = (): PendingPrompt[] => [...held.values()].map(show
|
|
|
93
94
|
|
|
94
95
|
export const promptLine = (requestId: string): string | undefined => held.get(requestId)?.line;
|
|
95
96
|
|
|
97
|
+
export const isPromptHeld = (requestId: string): boolean => held.has(requestId);
|
|
98
|
+
|
|
96
99
|
function chatNotice(via: AnsweredVia, behavior: Behavior): string | undefined {
|
|
97
100
|
if (via === 'expiry') return 'Expired, denied.';
|
|
98
101
|
if (via === 'page') return behavior === 'allow' ? 'Approved on the page.' : 'Denied on the page.';
|
|
@@ -116,8 +119,12 @@ export async function answerPrompt(
|
|
|
116
119
|
const entry = held.get(requestId);
|
|
117
120
|
if (entry === undefined) return undefined;
|
|
118
121
|
held.delete(requestId);
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
const server = entry.origin === 'mcp-server';
|
|
123
|
+
if (behavior === 'allow' && via !== 'expiry' && !server) grant(entry);
|
|
124
|
+
log.info(
|
|
125
|
+
{ requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` },
|
|
126
|
+
server ? 'approvals: an mcp.metro.box approval answered' : 'approvals: a Claude Code permission prompt answered',
|
|
127
|
+
);
|
|
121
128
|
await entry.send(behavior);
|
|
122
129
|
await tellChat(entry, via, behavior);
|
|
123
130
|
return shown(entry);
|
|
@@ -138,21 +145,15 @@ export function cancelPrompt(requestId: string, owner: object): void {
|
|
|
138
145
|
});
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
export function revokeToolPrompts(matches: (tool: string) => boolean): void {
|
|
142
|
-
grants = grants.filter((entry) => !matches(entry.tool));
|
|
143
|
-
for (const entry of held.values())
|
|
144
|
-
if (matches(entry.tool)) cancelPrompt(entry.requestId, entry.owner);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
148
|
export function revokeCallPrompts(route: CallRoute): void {
|
|
148
149
|
grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
|
|
149
150
|
for (const entry of held.values())
|
|
150
151
|
if (entry.call !== undefined && sameCall(entry.call.route, route)) cancelPrompt(entry.requestId, entry.owner);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
|
|
154
155
|
for (const [id, entry] of held)
|
|
155
|
-
if (toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
156
|
+
if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
156
157
|
held.delete(id);
|
|
157
158
|
log.info({ requestId: id, tool: entry.tool }, 'approvals: the call reached metro, so Claude Code already closed the prompt');
|
|
158
159
|
}
|
|
@@ -21,10 +21,10 @@ import {
|
|
|
21
21
|
trainEventToMetroEvent,
|
|
22
22
|
} from '../routes/http.js';
|
|
23
23
|
import { agentsDir, fileSource } from '../agents/files.js';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
24
|
+
import { syncPluginServers } from '../connectors/plugin-sync.js';
|
|
25
|
+
import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
|
|
26
|
+
import { setAnnouncedFile } from '../mcp-server/announced.js';
|
|
27
|
+
import { holdServerApproval } from '../mcp/server-approvals.js';
|
|
28
28
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
29
29
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
30
30
|
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
@@ -65,6 +65,7 @@ import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } f
|
|
|
65
65
|
installCrashGuard();
|
|
66
66
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
67
67
|
setOwnerLineFile(join(STATE_DIR, 'approval-line.json'));
|
|
68
|
+
setAnnouncedFile(join(agentsDir(), 'mcp-approvals.json'));
|
|
68
69
|
|
|
69
70
|
log.info({ self: userSelf() }, 'user identity');
|
|
70
71
|
|
|
@@ -140,7 +141,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
|
|
|
140
141
|
async function main(): Promise<void> {
|
|
141
142
|
applyLocalOwner();
|
|
142
143
|
installBearerSessions(agentsDir(), localOwner);
|
|
143
|
-
adoptOrgServerKeys();
|
|
144
144
|
watchPolicySnapshot();
|
|
145
145
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
146
146
|
await materializeFrom(fileSource);
|
|
@@ -160,7 +160,12 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
160
160
|
startPromptExpiry();
|
|
161
161
|
startUploadReaper();
|
|
162
162
|
startAttachReaper();
|
|
163
|
-
|
|
163
|
+
watchMcpServer({
|
|
164
|
+
owner: localOwner,
|
|
165
|
+
hold: (approval, answer) => {
|
|
166
|
+
holdServerApproval(approval, answer, emit);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
164
169
|
startCloudWatchPublisher();
|
|
165
170
|
watchFallbackUsage();
|
|
166
171
|
announceLocalEndpoint();
|
|
@@ -192,7 +197,7 @@ async function startClaude(): Promise<void> {
|
|
|
192
197
|
ensureMetroPlugin()
|
|
193
198
|
.then((outcome) => {
|
|
194
199
|
log.info({ outcome }, 'plugin: metro plugin for Claude Code');
|
|
195
|
-
if (outcome !== 'skipped')
|
|
200
|
+
if (outcome !== 'skipped') syncPluginServers();
|
|
196
201
|
})
|
|
197
202
|
.catch((err: unknown) => {
|
|
198
203
|
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
@@ -200,7 +205,7 @@ async function startClaude(): Promise<void> {
|
|
|
200
205
|
tryClaudeSetup();
|
|
201
206
|
try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
|
|
202
207
|
tryMemoryJob(memoryRoutine());
|
|
203
|
-
watchSession(
|
|
208
|
+
watchSession();
|
|
204
209
|
}
|
|
205
210
|
|
|
206
211
|
const SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
@@ -212,7 +217,9 @@ async function shutdown(): Promise<void> {
|
|
|
212
217
|
if (shuttingDown) return;
|
|
213
218
|
shuttingDown = true;
|
|
214
219
|
log.info('dispatcher shutting down');
|
|
215
|
-
|
|
220
|
+
stopMcpServer().catch((err: unknown) => {
|
|
221
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
|
|
222
|
+
});
|
|
216
223
|
const leaving = leaveCallsForShutdown();
|
|
217
224
|
unwatchSession();
|
|
218
225
|
tunnel?.stop();
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SavedMedia,
|
|
8
8
|
} from './media-note.js';
|
|
9
9
|
import { buildWebhookNote } from './webhook-note.js';
|
|
10
|
+
import { isMetroNotice } from './notice.js';
|
|
10
11
|
import { replyMeta } from './addressed.js';
|
|
11
12
|
import { reactContent, reactionEmoji } from './react-note.js';
|
|
12
13
|
import {
|
|
@@ -285,7 +286,9 @@ export class InboundRelay {
|
|
|
285
286
|
capSet(this.allowedLines, ALLOWED_LINES_MAX);
|
|
286
287
|
}
|
|
287
288
|
|
|
288
|
-
private droppedSender(
|
|
289
|
+
private droppedSender(ev: Record<string, unknown>, from: string, line: string): boolean {
|
|
290
|
+
if (isMetroNotice(ev)) return false;
|
|
291
|
+
const verified = ev.senderVerified;
|
|
289
292
|
if (
|
|
290
293
|
from.startsWith('metro://claude') ||
|
|
291
294
|
from === 'metro://user' ||
|
|
@@ -311,7 +314,7 @@ export class InboundRelay {
|
|
|
311
314
|
if (!this.deps.getStations().has(station)) return null;
|
|
312
315
|
const from = str(ev.from);
|
|
313
316
|
const line = str(ev.line);
|
|
314
|
-
if (this.droppedSender(from, line
|
|
317
|
+
if (this.droppedSender(ev, from, line)) return null;
|
|
315
318
|
const text = str(ev.text);
|
|
316
319
|
if (!replay && this.isDuplicate(station, line, evType, str(ev.messageId))) {
|
|
317
320
|
this.deps.log(
|
|
@@ -337,7 +340,7 @@ export class InboundRelay {
|
|
|
337
340
|
if (await this.handlePermissionReply(ev, base)) return;
|
|
338
341
|
await this.notify('notifications/claude/channel', {
|
|
339
342
|
content:
|
|
340
|
-
base.evType === 'system'
|
|
343
|
+
base.evType === 'system' && !isMetroNotice(ev)
|
|
341
344
|
? buildWebhookNote(base.text, str(ev.lineName), ev.payload)
|
|
342
345
|
: base.text,
|
|
343
346
|
meta: {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mintId, type MetroEvent } from '@metro-labs/core/events';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { asLine, Line } from '@metro-labs/core/lines';
|
|
4
|
+
|
|
5
|
+
const NOTICE_FROM = 'metro://metro';
|
|
6
|
+
const NOTICE_SOURCE = 'metro';
|
|
7
|
+
|
|
8
|
+
export function metroNotice(line: string, text: string): MetroEvent {
|
|
9
|
+
return {
|
|
10
|
+
id: mintId(),
|
|
11
|
+
ts: new Date().toISOString(),
|
|
12
|
+
station: Line.station(line) ?? '',
|
|
13
|
+
line: asLine(line),
|
|
14
|
+
from: asLine(NOTICE_FROM),
|
|
15
|
+
to: asLine(line),
|
|
16
|
+
text,
|
|
17
|
+
event: { type: 'system', source: NOTICE_SOURCE },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const isMetroNotice = (ev: Record<string, unknown>): boolean =>
|
|
22
|
+
ev.from === NOTICE_FROM && isRecord(ev.event) && ev.event.type === 'system' && ev.event.source === NOTICE_SOURCE;
|
|
@@ -49,7 +49,6 @@ export interface SessionDeps {
|
|
|
49
49
|
now?: () => number;
|
|
50
50
|
version?: string;
|
|
51
51
|
prepare?: (runner: HarnessRunner) => Promise<void>;
|
|
52
|
-
connectorsReady?: () => boolean;
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
export interface SessionStatus {
|
|
@@ -317,10 +316,9 @@ function strike(now: number): boolean {
|
|
|
317
316
|
return true;
|
|
318
317
|
}
|
|
319
318
|
|
|
320
|
-
export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused'
|
|
319
|
+
export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused';
|
|
321
320
|
|
|
322
321
|
function holdsBack(deps: SessionDeps, now: number): Ensured | null {
|
|
323
|
-
if (deps.connectorsReady?.() === false) return 'waiting';
|
|
324
322
|
if (now < memory.pausedUntil) return 'paused';
|
|
325
323
|
if (sessionBlocked(deps) !== null) return 'blocked';
|
|
326
324
|
return strike(now) ? 'paused' : null;
|
|
@@ -151,6 +151,8 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
|
|
|
151
151
|
'e6b59eba825b4568dfa9ace5049fd9568a2e10d731719976ad2657084fd4e5f9',
|
|
152
152
|
'5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
|
|
153
153
|
'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
|
|
154
|
+
'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
|
|
155
|
+
'587a1334f91a8b61541573cef16eaf44fbe63049a8d8b461fa9f5cc55bacbcb8',
|
|
154
156
|
]);
|
|
155
157
|
const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
156
158
|
'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
|
|
@@ -18,7 +18,6 @@ export interface EnrollApiDeps {
|
|
|
18
18
|
owner: () => string | null;
|
|
19
19
|
dir?: () => string;
|
|
20
20
|
api?: MetroApi;
|
|
21
|
-
invalidate?: () => void;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
export interface Enrolled {
|
|
@@ -89,7 +88,6 @@ async function enroll(req: IncomingMessage, session: ApiSession, deps: EnrollApi
|
|
|
89
88
|
if (enrolling.has(dir)) throw new ApiError('This agent is already enrolling. Wait for it to finish before trying again.', 409);
|
|
90
89
|
enrolling.add(dir);
|
|
91
90
|
try {
|
|
92
|
-
deps.invalidate?.();
|
|
93
91
|
return await exchange(ticket, owner, dir, deps);
|
|
94
92
|
} finally {
|
|
95
93
|
enrolling.delete(dir);
|
|
@@ -4,59 +4,20 @@ import { agentMarketplaceDir, agentUser } from '../agent-user/user.js';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { claudeDir } from '../claude/files.js';
|
|
7
|
-
import {
|
|
7
|
+
import { MCP_SERVER_KEY, mcpServerUrl } from '../mcp-server/config.js';
|
|
8
8
|
|
|
9
9
|
const MARKER = join('bin', 'metro-plugin.mjs');
|
|
10
10
|
const FILE = '.mcp.json';
|
|
11
|
-
const
|
|
12
|
-
const SLUG_MAX = 40;
|
|
11
|
+
const MCP_HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" mcp-headers';
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
id: string;
|
|
16
|
-
name: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface PluginServer {
|
|
13
|
+
interface PluginServer {
|
|
20
14
|
type: 'http';
|
|
21
15
|
url: string;
|
|
22
16
|
headersHelper: string;
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.toLowerCase()
|
|
28
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
29
|
-
.replace(/^-+|-+$/g, '')
|
|
30
|
-
.slice(0, SLUG_MAX)
|
|
31
|
-
.replace(/-+$/, '');
|
|
32
|
-
return slug === '' ? 'connector' : slug;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
|
|
36
|
-
let key = wanted;
|
|
37
|
-
let suffix = 1;
|
|
38
|
-
while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
|
|
39
|
-
return key;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>): Map<string, string> {
|
|
43
|
-
const keys = new Map(saved);
|
|
44
|
-
const reserved = new Set(keys.values());
|
|
45
|
-
for (const row of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
46
|
-
if (keys.has(row.id)) continue;
|
|
47
|
-
const key = availableKey(serverKey(row.name), reserved);
|
|
48
|
-
reserved.add(key);
|
|
49
|
-
keys.set(row.id, key);
|
|
50
|
-
}
|
|
51
|
-
return keys;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows)): Record<string, PluginServer> {
|
|
55
|
-
const out: Record<string, PluginServer> = {};
|
|
56
|
-
const visible = new Set(rows.map((row) => row.id));
|
|
57
|
-
for (const [id, key] of keys) if (visible.has(id)) out[key] = { type: 'http', url: `${base}/relay/${id}`, headersHelper: HELPER };
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
19
|
+
const pluginServers = (url: string | null): Record<string, PluginServer> =>
|
|
20
|
+
url === null ? {} : { [MCP_SERVER_KEY]: { type: 'http', url, headersHelper: MCP_HELPER } };
|
|
60
21
|
|
|
61
22
|
const SEARCH_DEPTH = 5;
|
|
62
23
|
|
|
@@ -108,9 +69,8 @@ function writeIfChanged(path: string, text: string): boolean {
|
|
|
108
69
|
|
|
109
70
|
export interface PluginSyncOptions {
|
|
110
71
|
dir?: string;
|
|
111
|
-
base?: string;
|
|
112
72
|
staged?: string | null;
|
|
113
|
-
|
|
73
|
+
url?: string | null;
|
|
114
74
|
}
|
|
115
75
|
|
|
116
76
|
function filesFor(opts: PluginSyncOptions): string[] {
|
|
@@ -118,23 +78,23 @@ function filesFor(opts: PluginSyncOptions): string[] {
|
|
|
118
78
|
return installedPluginFiles(opts.dir ?? claudeDir(), staged);
|
|
119
79
|
}
|
|
120
80
|
|
|
121
|
-
export function syncPluginServers(
|
|
81
|
+
export function syncPluginServers(opts: PluginSyncOptions = {}): number {
|
|
122
82
|
const files = filesFor(opts);
|
|
123
83
|
if (files.length === 0) return 0;
|
|
124
|
-
const servers = pluginServers(
|
|
84
|
+
const servers = pluginServers(opts.url === undefined ? mcpServerUrl() : opts.url);
|
|
125
85
|
const text = `${JSON.stringify(servers, null, 2)}\n`;
|
|
126
86
|
let written = 0;
|
|
127
87
|
for (const file of files) {
|
|
128
88
|
try {
|
|
129
89
|
if (writeIfChanged(file, text)) written += 1;
|
|
130
90
|
} catch (err) {
|
|
131
|
-
log.warn({ file, err: errMsg(err) }, 'plugin: could not write the
|
|
91
|
+
log.warn({ file, err: errMsg(err) }, 'plugin: could not write the MCP servers');
|
|
132
92
|
}
|
|
133
93
|
}
|
|
134
94
|
if (written > 0)
|
|
135
95
|
log.info(
|
|
136
|
-
{ files: written, servers: Object.keys(servers)
|
|
137
|
-
'plugin:
|
|
96
|
+
{ files: written, servers: Object.keys(servers) },
|
|
97
|
+
'plugin: MCP servers written; run /reload-plugins --force to pick them up in a running session',
|
|
138
98
|
);
|
|
139
99
|
return written;
|
|
140
100
|
}
|
|
@@ -19,6 +19,7 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
19
19
|
import { channelLog, type McpSession } from './session.js';
|
|
20
20
|
import { SessionSlot } from './session-slot.js';
|
|
21
21
|
import { connectCallBridge } from './call-bridge.js';
|
|
22
|
+
import { connectServerApprovals } from './server-approvals.js';
|
|
22
23
|
import { handleIncomingDiagnostic } from './diagnostics.js';
|
|
23
24
|
|
|
24
25
|
const isInitialize = (b: unknown): boolean =>
|
|
@@ -120,6 +121,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
120
121
|
const voiceSlot = new SessionSlot(false, 'voice');
|
|
121
122
|
activeSlots = [slot, voiceSlot];
|
|
122
123
|
connectCallBridge(() => slot.current);
|
|
124
|
+
connectServerApprovals(() => slot.current);
|
|
123
125
|
|
|
124
126
|
const httpHandler = async (
|
|
125
127
|
req: IncomingMessage,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { str } from '@metro-labs/core/str';
|
|
2
2
|
import { readPreview, shownValue } from '../approvals/preview.js';
|
|
3
|
-
import { connectorGates, connectorToolOf } from '../connectors/gates.js';
|
|
4
3
|
import { stationByName } from '../stations/registry.js';
|
|
5
4
|
import { stationOfAccount } from './read-tool.js';
|
|
6
5
|
|
|
@@ -75,15 +74,12 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
|
75
74
|
|
|
76
75
|
const ARGS_MAX = 8;
|
|
77
76
|
|
|
78
|
-
function
|
|
79
|
-
const lines = [`Approval needed: ${tool}`, `Connector: ${name}`];
|
|
77
|
+
function argLines(input: Record<string, unknown> | undefined): string[] {
|
|
80
78
|
const args = Object.entries(input ?? {}).flatMap(([key, value]) => {
|
|
81
79
|
const text = argText(value);
|
|
82
80
|
return text === undefined ? [] : [`${key}: ${shorten(text)}`];
|
|
83
81
|
});
|
|
84
|
-
|
|
85
|
-
if (args.length > ARGS_MAX) lines.push(`(${String(args.length - ARGS_MAX)} more)`);
|
|
86
|
-
return lines;
|
|
82
|
+
return args.length > ARGS_MAX ? [...args.slice(0, ARGS_MAX), `(${String(args.length - ARGS_MAX)} more)`] : args;
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
function bodyLines(params: PermissionParams): string[] {
|
|
@@ -91,18 +87,29 @@ function bodyLines(params: PermissionParams): string[] {
|
|
|
91
87
|
const { input } = readPreview(params.input_preview);
|
|
92
88
|
const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
|
|
93
89
|
if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
|
|
94
|
-
const connector = connectorToolOf(params.tool_name);
|
|
95
|
-
if (connector !== undefined) {
|
|
96
|
-
const { gate, tool } = connector;
|
|
97
|
-
const duplicate = connectorGates().some((entry) => entry.id !== gate.id && entry.name === gate.name);
|
|
98
|
-
return connectorLines(duplicate ? `${gate.name} (${gate.id})` : gate.name, tool, input);
|
|
99
|
-
}
|
|
100
90
|
return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
|
|
101
91
|
}
|
|
102
92
|
|
|
103
|
-
|
|
104
|
-
const answer = `Reply "yes ${
|
|
105
|
-
const body =
|
|
93
|
+
function withAnswer(requestId: string, lines: string[]): string {
|
|
94
|
+
const answer = `Reply "yes ${requestId}" or "no ${requestId}"`;
|
|
95
|
+
const body = lines.join('\n');
|
|
106
96
|
const room = PROMPT_MAX - answer.length - 3;
|
|
107
97
|
return `${body.length > room ? `${body.slice(0, room)}…` : body}\n\n${answer}`;
|
|
108
98
|
}
|
|
99
|
+
|
|
100
|
+
export function promptBody(params: PermissionParams): string {
|
|
101
|
+
return withAnswer(params.request_id, bodyLines(params));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ServerPrompt {
|
|
105
|
+
requestId: string;
|
|
106
|
+
tool: string;
|
|
107
|
+
preview: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function serverPromptBody(prompt: ServerPrompt): string {
|
|
111
|
+
const { input } = readPreview(prompt.preview);
|
|
112
|
+
const raw = prompt.preview.trim() === '' ? [] : [shorten(prompt.preview, RAW_MAX)];
|
|
113
|
+
const args = input === undefined ? raw : argLines(input);
|
|
114
|
+
return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...args]);
|
|
115
|
+
}
|