@stage-labs/metro 0.1.0-beta.304 → 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 +1 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +4 -28
- 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/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -9
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -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 +9 -54
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
- 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 +14 -15
- 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 +3 -1
- 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 +1 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
- 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,7 +15,7 @@ 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.**
|
|
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
19
|
|
|
20
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.
|
|
21
21
|
|
|
@@ -6,18 +6,6 @@ import { join } from 'node:path';
|
|
|
6
6
|
const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
|
|
7
7
|
const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
|
|
8
8
|
|
|
9
|
-
function readAgent() {
|
|
10
|
-
const path = join(agentsDir(), 'agent.json');
|
|
11
|
-
let parsed;
|
|
12
|
-
try {
|
|
13
|
-
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
14
|
-
} catch {
|
|
15
|
-
throw new Error(`no agent on this machine yet (${path})`);
|
|
16
|
-
}
|
|
17
|
-
if (typeof parsed?.key !== 'string') throw new Error(`no agent key in ${path}`);
|
|
18
|
-
return parsed;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
9
|
function readMcpToken() {
|
|
22
10
|
try {
|
|
23
11
|
const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
|
|
@@ -27,22 +15,10 @@ function readMcpToken() {
|
|
|
27
15
|
}
|
|
28
16
|
}
|
|
29
17
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (command === 'mcp-headers') {
|
|
33
|
-
const token = readMcpToken();
|
|
34
|
-
if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
|
|
35
|
-
process.exit(0);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (command !== 'headers') {
|
|
39
|
-
process.stderr.write('usage: metro-plugin.mjs headers | mcp-headers\n');
|
|
18
|
+
if (process.argv[2] !== 'mcp-headers') {
|
|
19
|
+
process.stderr.write('usage: metro-plugin.mjs mcp-headers\n');
|
|
40
20
|
process.exit(1);
|
|
41
21
|
}
|
|
42
22
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} catch (err) {
|
|
46
|
-
process.stderr.write(`metro: ${err.message}\n`);
|
|
47
|
-
process.stdout.write('{}\n');
|
|
48
|
-
}
|
|
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 : {};
|
|
@@ -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,
|
|
@@ -145,19 +145,13 @@ export function cancelPrompt(requestId: string, owner: object): void {
|
|
|
145
145
|
});
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
export function revokeToolPrompts(matches: (tool: string) => boolean): void {
|
|
149
|
-
grants = grants.filter((entry) => !matches(entry.tool));
|
|
150
|
-
for (const entry of held.values())
|
|
151
|
-
if (matches(entry.tool)) cancelPrompt(entry.requestId, entry.owner);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
148
|
export function revokeCallPrompts(route: CallRoute): void {
|
|
155
149
|
grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
|
|
156
150
|
for (const entry of held.values())
|
|
157
151
|
if (entry.call !== undefined && sameCall(entry.call.route, route)) cancelPrompt(entry.requestId, entry.owner);
|
|
158
152
|
}
|
|
159
153
|
|
|
160
|
-
|
|
154
|
+
function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
|
|
161
155
|
for (const [id, entry] of held)
|
|
162
156
|
if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
163
157
|
held.delete(id);
|
|
@@ -21,11 +21,9 @@ import {
|
|
|
21
21
|
trainEventToMetroEvent,
|
|
22
22
|
} from '../routes/http.js';
|
|
23
23
|
import { agentsDir, fileSource } from '../agents/files.js';
|
|
24
|
-
import {
|
|
25
|
-
import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
|
|
26
|
-
import { readAgentKey } from '../connectors/agent-key.js';
|
|
27
|
-
import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
|
|
24
|
+
import { syncPluginServers } from '../connectors/plugin-sync.js';
|
|
28
25
|
import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
|
|
26
|
+
import { setAnnouncedFile } from '../mcp-server/announced.js';
|
|
29
27
|
import { holdServerApproval } from '../mcp/server-approvals.js';
|
|
30
28
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
31
29
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
@@ -67,6 +65,7 @@ import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } f
|
|
|
67
65
|
installCrashGuard();
|
|
68
66
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
69
67
|
setOwnerLineFile(join(STATE_DIR, 'approval-line.json'));
|
|
68
|
+
setAnnouncedFile(join(agentsDir(), 'mcp-approvals.json'));
|
|
70
69
|
|
|
71
70
|
log.info({ self: userSelf() }, 'user identity');
|
|
72
71
|
|
|
@@ -142,7 +141,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
|
|
|
142
141
|
async function main(): Promise<void> {
|
|
143
142
|
applyLocalOwner();
|
|
144
143
|
installBearerSessions(agentsDir(), localOwner);
|
|
145
|
-
adoptOrgServerKeys();
|
|
146
144
|
watchPolicySnapshot();
|
|
147
145
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
148
146
|
await materializeFrom(fileSource);
|
|
@@ -162,7 +160,6 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
162
160
|
startPromptExpiry();
|
|
163
161
|
startUploadReaper();
|
|
164
162
|
startAttachReaper();
|
|
165
|
-
watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
|
|
166
163
|
watchMcpServer({
|
|
167
164
|
owner: localOwner,
|
|
168
165
|
hold: (approval, answer) => {
|
|
@@ -200,7 +197,7 @@ async function startClaude(): Promise<void> {
|
|
|
200
197
|
ensureMetroPlugin()
|
|
201
198
|
.then((outcome) => {
|
|
202
199
|
log.info({ outcome }, 'plugin: metro plugin for Claude Code');
|
|
203
|
-
if (outcome !== 'skipped')
|
|
200
|
+
if (outcome !== 'skipped') syncPluginServers();
|
|
204
201
|
})
|
|
205
202
|
.catch((err: unknown) => {
|
|
206
203
|
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
@@ -208,7 +205,7 @@ async function startClaude(): Promise<void> {
|
|
|
208
205
|
tryClaudeSetup();
|
|
209
206
|
try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
|
|
210
207
|
tryMemoryJob(memoryRoutine());
|
|
211
|
-
watchSession(
|
|
208
|
+
watchSession();
|
|
212
209
|
}
|
|
213
210
|
|
|
214
211
|
const SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
@@ -220,7 +217,6 @@ async function shutdown(): Promise<void> {
|
|
|
220
217
|
if (shuttingDown) return;
|
|
221
218
|
shuttingDown = true;
|
|
222
219
|
log.info('dispatcher shutting down');
|
|
223
|
-
stopOrgConnectorSync(true);
|
|
224
220
|
stopMcpServer().catch((err: unknown) => {
|
|
225
221
|
log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
|
|
226
222
|
});
|
|
@@ -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;
|
|
@@ -152,6 +152,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
|
|
|
152
152
|
'5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
|
|
153
153
|
'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
|
|
154
154
|
'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
|
|
155
|
+
'587a1334f91a8b61541573cef16eaf44fbe63049a8d8b461fa9f5cc55bacbcb8',
|
|
155
156
|
]);
|
|
156
157
|
const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
157
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,64 +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 { loopbackBase } from '../files/attach-serve.js';
|
|
8
7
|
import { MCP_SERVER_KEY, mcpServerUrl } from '../mcp-server/config.js';
|
|
9
8
|
|
|
10
9
|
const MARKER = join('bin', 'metro-plugin.mjs');
|
|
11
10
|
const FILE = '.mcp.json';
|
|
12
|
-
const HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers';
|
|
13
11
|
const MCP_HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" mcp-headers';
|
|
14
|
-
const SLUG_MAX = 40;
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
id: string;
|
|
18
|
-
name: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface PluginServer {
|
|
13
|
+
interface PluginServer {
|
|
22
14
|
type: 'http';
|
|
23
15
|
url: string;
|
|
24
16
|
headersHelper: string;
|
|
25
17
|
}
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.toLowerCase()
|
|
30
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
31
|
-
.replace(/^-+|-+$/g, '')
|
|
32
|
-
.slice(0, SLUG_MAX)
|
|
33
|
-
.replace(/-+$/, '');
|
|
34
|
-
return slug === '' ? 'connector' : slug;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
|
|
38
|
-
let key = wanted;
|
|
39
|
-
let suffix = 1;
|
|
40
|
-
while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
|
|
41
|
-
return key;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const reservedKeys = (): ReadonlySet<string> => new Set(mcpServerUrl() === null ? [] : [MCP_SERVER_KEY]);
|
|
45
|
-
|
|
46
|
-
export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>, reserved: ReadonlySet<string> = reservedKeys()): Map<string, string> {
|
|
47
|
-
const keys = new Map([...(saved ?? [])].filter(([, key]) => !reserved.has(key)));
|
|
48
|
-
const used = new Set([...keys.values(), ...reserved]);
|
|
49
|
-
for (const row of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
50
|
-
if (keys.has(row.id)) continue;
|
|
51
|
-
const key = availableKey(serverKey(row.name), used);
|
|
52
|
-
used.add(key);
|
|
53
|
-
keys.set(row.id, key);
|
|
54
|
-
}
|
|
55
|
-
return keys;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows), mcpUrl = mcpServerUrl()): Record<string, PluginServer> {
|
|
59
|
-
const out: Record<string, PluginServer> = {};
|
|
60
|
-
const visible = new Set(rows.map((row) => row.id));
|
|
61
|
-
for (const [id, key] of keys) if (visible.has(id)) out[key] = { type: 'http', url: `${base}/relay/${id}`, headersHelper: HELPER };
|
|
62
|
-
if (mcpUrl !== null) out[MCP_SERVER_KEY] = { type: 'http', url: mcpUrl, headersHelper: MCP_HELPER };
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
19
|
+
const pluginServers = (url: string | null): Record<string, PluginServer> =>
|
|
20
|
+
url === null ? {} : { [MCP_SERVER_KEY]: { type: 'http', url, headersHelper: MCP_HELPER } };
|
|
65
21
|
|
|
66
22
|
const SEARCH_DEPTH = 5;
|
|
67
23
|
|
|
@@ -113,9 +69,8 @@ function writeIfChanged(path: string, text: string): boolean {
|
|
|
113
69
|
|
|
114
70
|
export interface PluginSyncOptions {
|
|
115
71
|
dir?: string;
|
|
116
|
-
base?: string;
|
|
117
72
|
staged?: string | null;
|
|
118
|
-
|
|
73
|
+
url?: string | null;
|
|
119
74
|
}
|
|
120
75
|
|
|
121
76
|
function filesFor(opts: PluginSyncOptions): string[] {
|
|
@@ -123,23 +78,23 @@ function filesFor(opts: PluginSyncOptions): string[] {
|
|
|
123
78
|
return installedPluginFiles(opts.dir ?? claudeDir(), staged);
|
|
124
79
|
}
|
|
125
80
|
|
|
126
|
-
export function syncPluginServers(
|
|
81
|
+
export function syncPluginServers(opts: PluginSyncOptions = {}): number {
|
|
127
82
|
const files = filesFor(opts);
|
|
128
83
|
if (files.length === 0) return 0;
|
|
129
|
-
const servers = pluginServers(
|
|
84
|
+
const servers = pluginServers(opts.url === undefined ? mcpServerUrl() : opts.url);
|
|
130
85
|
const text = `${JSON.stringify(servers, null, 2)}\n`;
|
|
131
86
|
let written = 0;
|
|
132
87
|
for (const file of files) {
|
|
133
88
|
try {
|
|
134
89
|
if (writeIfChanged(file, text)) written += 1;
|
|
135
90
|
} catch (err) {
|
|
136
|
-
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');
|
|
137
92
|
}
|
|
138
93
|
}
|
|
139
94
|
if (written > 0)
|
|
140
95
|
log.info(
|
|
141
|
-
{ files: written, servers: Object.keys(servers)
|
|
142
|
-
'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',
|
|
143
98
|
);
|
|
144
99
|
return written;
|
|
145
100
|
}
|
|
@@ -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
|
|
|
@@ -83,21 +82,11 @@ function argLines(input: Record<string, unknown> | undefined): string[] {
|
|
|
83
82
|
return args.length > ARGS_MAX ? [...args.slice(0, ARGS_MAX), `(${String(args.length - ARGS_MAX)} more)`] : args;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
|
|
87
|
-
return [`Approval needed: ${tool}`, `Connector: ${name}`, ...argLines(input)];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
85
|
function bodyLines(params: PermissionParams): string[] {
|
|
91
86
|
const metro = METRO_TOOL.exec(params.tool_name)?.[1];
|
|
92
87
|
const { input } = readPreview(params.input_preview);
|
|
93
88
|
const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
|
|
94
89
|
if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
|
|
95
|
-
const connector = connectorToolOf(params.tool_name);
|
|
96
|
-
if (connector !== undefined) {
|
|
97
|
-
const { gate, tool } = connector;
|
|
98
|
-
const duplicate = connectorGates().some((entry) => entry.id !== gate.id && entry.name === gate.name);
|
|
99
|
-
return connectorLines(duplicate ? `${gate.name} (${gate.id})` : gate.name, tool, input);
|
|
100
|
-
}
|
|
101
90
|
return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
|
|
102
91
|
}
|
|
103
92
|
|
|
@@ -115,7 +104,6 @@ export function promptBody(params: PermissionParams): string {
|
|
|
115
104
|
export interface ServerPrompt {
|
|
116
105
|
requestId: string;
|
|
117
106
|
tool: string;
|
|
118
|
-
connector: string | undefined;
|
|
119
107
|
preview: string;
|
|
120
108
|
}
|
|
121
109
|
|
|
@@ -123,5 +111,5 @@ export function serverPromptBody(prompt: ServerPrompt): string {
|
|
|
123
111
|
const { input } = readPreview(prompt.preview);
|
|
124
112
|
const raw = prompt.preview.trim() === '' ? [] : [shorten(prompt.preview, RAW_MAX)];
|
|
125
113
|
const args = input === undefined ? raw : argLines(input);
|
|
126
|
-
return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...
|
|
114
|
+
return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...args]);
|
|
127
115
|
}
|
|
@@ -7,7 +7,6 @@ import { agentsDir } from '../agents/files.js';
|
|
|
7
7
|
import { knownAccounts } from '../agents/map.js';
|
|
8
8
|
import { onPoliciesChanged, storedPolicies, type ToolPolicy } from '../policy/policy.js';
|
|
9
9
|
import { UNGATED } from './policy-gate.js';
|
|
10
|
-
import { connectorGates } from '../connectors/gates.js';
|
|
11
10
|
import { stationToolOwners, TOOL_DEFS, toolGroupOf } from './tool-catalog.js';
|
|
12
11
|
|
|
13
12
|
interface PolicySnapshot {
|
|
@@ -17,14 +16,6 @@ interface PolicySnapshot {
|
|
|
17
16
|
ungated: string[];
|
|
18
17
|
accounts: Record<string, ToolPolicy>;
|
|
19
18
|
stations: Record<string, string[]>;
|
|
20
|
-
connectors: Record<string, SnapshotConnector>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface SnapshotConnector {
|
|
24
|
-
id: string;
|
|
25
|
-
name: string;
|
|
26
|
-
policy: ToolPolicy;
|
|
27
|
-
tools: Record<string, ToolGroup>;
|
|
28
19
|
}
|
|
29
20
|
|
|
30
21
|
const FILE = 'policy.json';
|
|
@@ -44,11 +35,6 @@ function policySnapshot(): PolicySnapshot {
|
|
|
44
35
|
ungated: [...UNGATED],
|
|
45
36
|
accounts,
|
|
46
37
|
stations,
|
|
47
|
-
connectors: Object.fromEntries(
|
|
48
|
-
connectorGates()
|
|
49
|
-
.filter((gate) => gate.server !== '')
|
|
50
|
-
.map((gate) => [gate.server, { id: gate.id, name: gate.name, policy: gate.policy, tools: gate.tools }]),
|
|
51
|
-
),
|
|
52
38
|
};
|
|
53
39
|
}
|
|
54
40
|
|
|
@@ -4,8 +4,7 @@ import type { McpApproval, McpDecision } from '@metro-labs/http/mcp-server-proto
|
|
|
4
4
|
import { holdPrompt, isPromptHeld, type Behavior } from '../approvals/pending.js';
|
|
5
5
|
import { ownerLine } from '../approvals/owner-line.js';
|
|
6
6
|
import { metroNotice } from '../channels/notice.js';
|
|
7
|
-
import {
|
|
8
|
-
import { serverKey } from '../connectors/plugin-sync.js';
|
|
7
|
+
import { announcedLine, noteAnnounced } from '../mcp-server/announced.js';
|
|
9
8
|
import type { AnswerOutcome } from '../mcp-server/link.js';
|
|
10
9
|
import { serverPromptBody } from './permission-prompt.js';
|
|
11
10
|
import { metroSend, relayLine } from './permission-relay.js';
|
|
@@ -42,11 +41,6 @@ function remember(id: string, now = Date.now()): void {
|
|
|
42
41
|
|
|
43
42
|
const answeredRecently = (id: string, now = Date.now()): boolean => now - (recent.get(id) ?? -Infinity) < RECENT_MS;
|
|
44
43
|
|
|
45
|
-
function connectorName(key: string): string | undefined {
|
|
46
|
-
const names = new Set(connectorGates().filter((gate) => gate.server === key || serverKey(gate.name) === key).map((gate) => gate.name));
|
|
47
|
-
return names.size === 1 ? [...names][0] : undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
44
|
const noticeText = (tool: string, id: string, behavior: Behavior): string =>
|
|
51
45
|
behavior === 'allow' ? `Approved: ${tool} (${id}). Call call_tool again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
|
|
52
46
|
|
|
@@ -84,23 +78,28 @@ function sender(held: Held): (behavior: Behavior) => Promise<void> {
|
|
|
84
78
|
};
|
|
85
79
|
}
|
|
86
80
|
|
|
81
|
+
function announce(id: string, line: string, text: string): void {
|
|
82
|
+
metroSend(line, text).then(() => {
|
|
83
|
+
noteAnnounced(id, line);
|
|
84
|
+
}).catch((err: unknown) => {
|
|
85
|
+
log.warn({ id, err: errMsg(err) }, 'mcp-server: the approval prompt could not be posted');
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
87
89
|
export function holdServerApproval(approval: McpApproval, answer: Held['answer'], publish: Held['publish']): boolean {
|
|
88
90
|
if (isPromptHeld(approval.id) || answeredRecently(approval.id)) return false;
|
|
89
91
|
const tool = `${approval.connector}.${approval.tool}`;
|
|
90
|
-
const
|
|
91
|
-
const line = promptLineFor(approval.id);
|
|
92
|
+
const announced = announcedLine(approval.id);
|
|
93
|
+
const line = announced ?? promptLineFor(approval.id);
|
|
92
94
|
const held: Held = { id: approval.id, tool, line, answer, publish };
|
|
93
95
|
holdPrompt(
|
|
94
|
-
{ requestId: approval.id, tool, description:
|
|
96
|
+
{ requestId: approval.id, tool, description: approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
|
|
95
97
|
owner,
|
|
96
98
|
sender(held),
|
|
97
99
|
line === undefined ? undefined : async (text) => metroSend(line, text),
|
|
98
100
|
);
|
|
99
|
-
log.info({ id: approval.id, tool, inChat: line !== undefined }, 'mcp-server: an approval is waiting for the owner');
|
|
100
|
-
if (line !== undefined)
|
|
101
|
-
metroSend(line, serverPromptBody({ requestId: approval.id, tool, connector: name, preview: approval.preview })).catch((err: unknown) => {
|
|
102
|
-
log.warn({ id: approval.id, err: errMsg(err) }, 'mcp-server: the approval prompt could not be posted');
|
|
103
|
-
});
|
|
101
|
+
log.info({ id: approval.id, tool, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
|
|
102
|
+
if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool, preview: approval.preview }));
|
|
104
103
|
return true;
|
|
105
104
|
}
|
|
106
105
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
|
+
import { MCP_APPROVAL_ID_RE } from '@metro-labs/http/mcp-server-protocol';
|
|
5
|
+
|
|
6
|
+
const KEEP_MS = 24 * 3_600_000;
|
|
7
|
+
const MAX = 200;
|
|
8
|
+
|
|
9
|
+
interface Announced {
|
|
10
|
+
line: string;
|
|
11
|
+
at: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let file: string | undefined;
|
|
15
|
+
let known = new Map<string, Announced>();
|
|
16
|
+
|
|
17
|
+
function entryOf(raw: unknown): Announced | null {
|
|
18
|
+
if (!isRecord(raw) || typeof raw.line !== 'string' || !raw.line.startsWith('metro://') || typeof raw.at !== 'number' || !Number.isFinite(raw.at)) return null;
|
|
19
|
+
return { line: raw.line, at: raw.at };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parse(raw: unknown): Map<string, Announced> {
|
|
23
|
+
const out = new Map<string, Announced>();
|
|
24
|
+
const entries = isRecord(raw) && raw.version === 1 && isRecord(raw.announced) ? raw.announced : {};
|
|
25
|
+
for (const [id, value] of Object.entries(entries)) {
|
|
26
|
+
const entry = entryOf(value);
|
|
27
|
+
if (MCP_APPROVAL_ID_RE.test(id) && entry !== null) out.set(id, entry);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function prune(now: number): void {
|
|
33
|
+
for (const [id, entry] of known) if (now - entry.at >= KEEP_MS || entry.at > now) known.delete(id);
|
|
34
|
+
const oldest = [...known].sort(([, a], [, b]) => a.at - b.at);
|
|
35
|
+
for (const [id] of oldest.slice(0, Math.max(0, known.size - MAX))) known.delete(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function save(): void {
|
|
39
|
+
if (file === undefined) return;
|
|
40
|
+
const announced = Object.fromEntries([...known].map(([id, entry]) => [id, { line: entry.line, at: entry.at }]));
|
|
41
|
+
try {
|
|
42
|
+
writeSecure(file, `${JSON.stringify({ version: 1, announced })}\n`);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: the announced approvals could not be saved, so they are kept in memory only');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setAnnouncedFile(path: string, now = Date.now()): void {
|
|
49
|
+
file = path;
|
|
50
|
+
known = parse(readJson<unknown>(path, null, { warn: 'mcp-server: the announced approvals file is not valid JSON, so it was ignored' }));
|
|
51
|
+
prune(now);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function announcedLine(id: string, now = Date.now()): string | undefined {
|
|
55
|
+
const entry = known.get(id);
|
|
56
|
+
return entry !== undefined && now - entry.at < KEEP_MS && entry.at <= now ? entry.line : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function noteAnnounced(id: string, line: string, now = Date.now()): void {
|
|
60
|
+
known.set(id, { line, at: now });
|
|
61
|
+
prune(now);
|
|
62
|
+
save();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function forgetAnnounced(): void {
|
|
66
|
+
file = undefined;
|
|
67
|
+
known = new Map();
|
|
68
|
+
}
|
|
@@ -2,6 +2,8 @@ import { log } from '@metro-labs/core/log';
|
|
|
2
2
|
|
|
3
3
|
export const MCP_SERVER_KEY = 'mcp';
|
|
4
4
|
|
|
5
|
+
const DEFAULT_URL = 'https://mcp.metro.box';
|
|
6
|
+
|
|
5
7
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
6
8
|
|
|
7
9
|
let refused: string | undefined;
|
|
@@ -18,7 +20,7 @@ function usable(raw: string): boolean {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export function mcpServerUrl(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
21
|
-
const raw = env.METRO_MCP_SERVER_URL?.trim() ??
|
|
23
|
+
const raw = env.METRO_MCP_SERVER_URL?.trim() ?? DEFAULT_URL;
|
|
22
24
|
if (raw === '') return null;
|
|
23
25
|
if (usable(raw)) return raw.replace(/\/+$/, '');
|
|
24
26
|
if (refused !== raw) {
|