@stage-labs/metro 0.1.0-beta.303 → 0.1.0-beta.304
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 +2 -2
- 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 +20 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +11 -0
- 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/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -6
- 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 +27 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -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/http/package.json +2 -1
- 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/runner.json +1 -1
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: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
3
|
+
"version": "0.1.0-beta.304",
|
|
4
|
+
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon (or one `mcp` server on mcp.metro.box), the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
7
7
|
"url": "https://metro.box"
|
|
@@ -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.** When the `mcp` server is present, it holds every connector tool behind 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,6 +4,7 @@ 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
9
|
function readAgent() {
|
|
9
10
|
const path = join(agentsDir(), 'agent.json');
|
|
@@ -17,8 +18,25 @@ function readAgent() {
|
|
|
17
18
|
return parsed;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
function readMcpToken() {
|
|
22
|
+
try {
|
|
23
|
+
const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
|
|
24
|
+
return MCP_TOKEN.test(token) ? token : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const command = process.argv[2];
|
|
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');
|
|
22
40
|
process.exit(1);
|
|
23
41
|
}
|
|
24
42
|
|
|
@@ -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
|
|
|
@@ -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);
|
|
@@ -152,7 +159,7 @@ export function revokeCallPrompts(route: CallRoute): void {
|
|
|
152
159
|
|
|
153
160
|
export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
|
|
154
161
|
for (const [id, entry] of held)
|
|
155
|
-
if (toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
162
|
+
if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
156
163
|
held.delete(id);
|
|
157
164
|
log.info({ requestId: id, tool: entry.tool }, 'approvals: the call reached metro, so Claude Code already closed the prompt');
|
|
158
165
|
}
|
|
@@ -25,6 +25,8 @@ import { connectorsReady, publishConnectors } from '../connectors/publication.js
|
|
|
25
25
|
import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
|
|
26
26
|
import { readAgentKey } from '../connectors/agent-key.js';
|
|
27
27
|
import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
|
|
28
|
+
import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
|
|
29
|
+
import { holdServerApproval } from '../mcp/server-approvals.js';
|
|
28
30
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
29
31
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
30
32
|
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
@@ -161,6 +163,12 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
161
163
|
startUploadReaper();
|
|
162
164
|
startAttachReaper();
|
|
163
165
|
watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
|
|
166
|
+
watchMcpServer({
|
|
167
|
+
owner: localOwner,
|
|
168
|
+
hold: (approval, answer) => {
|
|
169
|
+
holdServerApproval(approval, answer, emit);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
164
172
|
startCloudWatchPublisher();
|
|
165
173
|
watchFallbackUsage();
|
|
166
174
|
announceLocalEndpoint();
|
|
@@ -213,6 +221,9 @@ async function shutdown(): Promise<void> {
|
|
|
213
221
|
shuttingDown = true;
|
|
214
222
|
log.info('dispatcher shutting down');
|
|
215
223
|
stopOrgConnectorSync(true);
|
|
224
|
+
stopMcpServer().catch((err: unknown) => {
|
|
225
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
|
|
226
|
+
});
|
|
216
227
|
const leaving = leaveCallsForShutdown();
|
|
217
228
|
unwatchSession();
|
|
218
229
|
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;
|
|
@@ -151,6 +151,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
|
|
|
151
151
|
'e6b59eba825b4568dfa9ace5049fd9568a2e10d731719976ad2657084fd4e5f9',
|
|
152
152
|
'5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
|
|
153
153
|
'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
|
|
154
|
+
'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
|
|
154
155
|
]);
|
|
155
156
|
const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
156
157
|
'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
|
|
@@ -5,10 +5,12 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { claudeDir } from '../claude/files.js';
|
|
7
7
|
import { loopbackBase } from '../files/attach-serve.js';
|
|
8
|
+
import { MCP_SERVER_KEY, mcpServerUrl } from '../mcp-server/config.js';
|
|
8
9
|
|
|
9
10
|
const MARKER = join('bin', 'metro-plugin.mjs');
|
|
10
11
|
const FILE = '.mcp.json';
|
|
11
12
|
const HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers';
|
|
13
|
+
const MCP_HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" mcp-headers';
|
|
12
14
|
const SLUG_MAX = 40;
|
|
13
15
|
|
|
14
16
|
export interface PluginRow {
|
|
@@ -39,22 +41,25 @@ function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
|
|
|
39
41
|
return key;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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]);
|
|
45
49
|
for (const row of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
46
50
|
if (keys.has(row.id)) continue;
|
|
47
|
-
const key = availableKey(serverKey(row.name),
|
|
48
|
-
|
|
51
|
+
const key = availableKey(serverKey(row.name), used);
|
|
52
|
+
used.add(key);
|
|
49
53
|
keys.set(row.id, key);
|
|
50
54
|
}
|
|
51
55
|
return keys;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows)): Record<string, PluginServer> {
|
|
58
|
+
export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows), mcpUrl = mcpServerUrl()): Record<string, PluginServer> {
|
|
55
59
|
const out: Record<string, PluginServer> = {};
|
|
56
60
|
const visible = new Set(rows.map((row) => row.id));
|
|
57
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 };
|
|
58
63
|
return out;
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -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,
|
|
@@ -75,15 +75,16 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
|
75
75
|
|
|
76
76
|
const ARGS_MAX = 8;
|
|
77
77
|
|
|
78
|
-
function
|
|
79
|
-
const lines = [`Approval needed: ${tool}`, `Connector: ${name}`];
|
|
78
|
+
function argLines(input: Record<string, unknown> | undefined): string[] {
|
|
80
79
|
const args = Object.entries(input ?? {}).flatMap(([key, value]) => {
|
|
81
80
|
const text = argText(value);
|
|
82
81
|
return text === undefined ? [] : [`${key}: ${shorten(text)}`];
|
|
83
82
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
return args.length > ARGS_MAX ? [...args.slice(0, ARGS_MAX), `(${String(args.length - ARGS_MAX)} more)`] : args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
|
|
87
|
+
return [`Approval needed: ${tool}`, `Connector: ${name}`, ...argLines(input)];
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function bodyLines(params: PermissionParams): string[] {
|
|
@@ -100,9 +101,27 @@ function bodyLines(params: PermissionParams): string[] {
|
|
|
100
101
|
return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
const answer = `Reply "yes ${
|
|
105
|
-
const body =
|
|
104
|
+
function withAnswer(requestId: string, lines: string[]): string {
|
|
105
|
+
const answer = `Reply "yes ${requestId}" or "no ${requestId}"`;
|
|
106
|
+
const body = lines.join('\n');
|
|
106
107
|
const room = PROMPT_MAX - answer.length - 3;
|
|
107
108
|
return `${body.length > room ? `${body.slice(0, room)}…` : body}\n\n${answer}`;
|
|
108
109
|
}
|
|
110
|
+
|
|
111
|
+
export function promptBody(params: PermissionParams): string {
|
|
112
|
+
return withAnswer(params.request_id, bodyLines(params));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ServerPrompt {
|
|
116
|
+
requestId: string;
|
|
117
|
+
tool: string;
|
|
118
|
+
connector: string | undefined;
|
|
119
|
+
preview: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function serverPromptBody(prompt: ServerPrompt): string {
|
|
123
|
+
const { input } = readPreview(prompt.preview);
|
|
124
|
+
const raw = prompt.preview.trim() === '' ? [] : [shorten(prompt.preview, RAW_MAX)];
|
|
125
|
+
const args = input === undefined ? raw : argLines(input);
|
|
126
|
+
return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...(prompt.connector === undefined ? [] : [`Connector: ${prompt.connector}`]), ...args]);
|
|
127
|
+
}
|
|
@@ -24,7 +24,7 @@ type PermissionRequest = z.infer<typeof PermissionRequestSchema>;
|
|
|
24
24
|
|
|
25
25
|
const trainOf = (line: string): string => line.split('/')[2] ?? '';
|
|
26
26
|
|
|
27
|
-
async function metroSend(line: string, text: string): Promise<void> {
|
|
27
|
+
export async function metroSend(line: string, text: string): Promise<void> {
|
|
28
28
|
await metroCall(trainOf(line), 'send', { line, text });
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -36,15 +36,17 @@ export interface PermissionRelayDeps {
|
|
|
36
36
|
log: (...a: unknown[]) => void;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
export type PromptLineDeps = Pick<PermissionRelayDeps, 'relay' | 'inScope' | 'live' | 'log'>;
|
|
40
|
+
|
|
41
|
+
const callChat = (deps: PromptLineDeps): string | undefined => {
|
|
40
42
|
const route = sharedCalls.snapshot();
|
|
41
43
|
return route !== null && deps.inScope(route.line) ? route.line : undefined;
|
|
42
44
|
};
|
|
43
45
|
|
|
44
|
-
const ownerChat = (deps:
|
|
46
|
+
const ownerChat = (deps: PromptLineDeps, owner: OwnerLine | undefined): string | undefined =>
|
|
45
47
|
owner !== undefined && mayApprove(owner.line, owner.from) && deps.inScope(owner.line) && lineReceives(owner.line) ? owner.line : undefined;
|
|
46
48
|
|
|
47
|
-
function relayLine(deps:
|
|
49
|
+
export function relayLine(deps: PromptLineDeps, requestId: string, callLine?: string): string | undefined {
|
|
48
50
|
if (!deps.live()) {
|
|
49
51
|
deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
|
|
50
52
|
return undefined;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { MetroEvent } from '@metro-labs/core/events';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import type { McpApproval, McpDecision } from '@metro-labs/http/mcp-server-protocol';
|
|
4
|
+
import { holdPrompt, isPromptHeld, type Behavior } from '../approvals/pending.js';
|
|
5
|
+
import { ownerLine } from '../approvals/owner-line.js';
|
|
6
|
+
import { metroNotice } from '../channels/notice.js';
|
|
7
|
+
import { connectorGates } from '../connectors/gates.js';
|
|
8
|
+
import { serverKey } from '../connectors/plugin-sync.js';
|
|
9
|
+
import type { AnswerOutcome } from '../mcp-server/link.js';
|
|
10
|
+
import { serverPromptBody } from './permission-prompt.js';
|
|
11
|
+
import { metroSend, relayLine } from './permission-relay.js';
|
|
12
|
+
import { channelLog, type McpSession } from './session.js';
|
|
13
|
+
|
|
14
|
+
const RECENT_MS = 10 * 60_000;
|
|
15
|
+
const RECENT_MAX = 1_000;
|
|
16
|
+
|
|
17
|
+
const owner = {};
|
|
18
|
+
const recent = new Map<string, number>();
|
|
19
|
+
let current: () => McpSession | undefined = () => undefined;
|
|
20
|
+
|
|
21
|
+
export function connectServerApprovals(session: () => McpSession | undefined): void {
|
|
22
|
+
current = session;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function promptLineFor(requestId: string): string | undefined {
|
|
26
|
+
const session = current();
|
|
27
|
+
if (session === undefined) return undefined;
|
|
28
|
+
return relayLine({ relay: session.relay, inScope: (line) => session.inScope(line), live: session.live, log: channelLog }, requestId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const noticeLineFor = (line: string | undefined): string | undefined => line ?? current()?.relay.knownLine ?? ownerLine()?.line;
|
|
32
|
+
|
|
33
|
+
function remember(id: string, now = Date.now()): void {
|
|
34
|
+
for (const [seen, at] of recent) if (now - at >= RECENT_MS) recent.delete(seen);
|
|
35
|
+
recent.set(id, now);
|
|
36
|
+
while (recent.size > RECENT_MAX) {
|
|
37
|
+
const oldest = recent.keys().next();
|
|
38
|
+
if (oldest.done) break;
|
|
39
|
+
recent.delete(oldest.value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const answeredRecently = (id: string, now = Date.now()): boolean => now - (recent.get(id) ?? -Infinity) < RECENT_MS;
|
|
44
|
+
|
|
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
|
+
const noticeText = (tool: string, id: string, behavior: Behavior): string =>
|
|
51
|
+
behavior === 'allow' ? `Approved: ${tool} (${id}). Call call_tool again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
|
|
52
|
+
|
|
53
|
+
const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
|
|
54
|
+
|
|
55
|
+
interface Held {
|
|
56
|
+
id: string;
|
|
57
|
+
tool: string;
|
|
58
|
+
line: string | undefined;
|
|
59
|
+
answer: (decision: McpDecision) => Promise<AnswerOutcome>;
|
|
60
|
+
publish: (event: MetroEvent) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function deliver(held: Held, behavior: Behavior): Promise<void> {
|
|
64
|
+
const outcome = await held.answer(behavior);
|
|
65
|
+
if (outcome !== 'acknowledged') {
|
|
66
|
+
log.info({ id: held.id, outcome }, 'mcp-server: the answer was not taken, so the agent is not told');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const line = noticeLineFor(held.line);
|
|
70
|
+
if (line === undefined) {
|
|
71
|
+
log.warn({ id: held.id }, 'mcp-server: no chat is known to tell the agent the answer');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
held.publish(metroNotice(line, noticeText(held.tool, held.id, behavior)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sender(held: Held): (behavior: Behavior) => Promise<void> {
|
|
78
|
+
return (behavior) => {
|
|
79
|
+
remember(held.id);
|
|
80
|
+
deliver(held, behavior).catch((err: unknown) => {
|
|
81
|
+
log.warn({ id: held.id, err: errMsg(err) }, 'mcp-server: the answer could not be delivered');
|
|
82
|
+
});
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function holdServerApproval(approval: McpApproval, answer: Held['answer'], publish: Held['publish']): boolean {
|
|
88
|
+
if (isPromptHeld(approval.id) || answeredRecently(approval.id)) return false;
|
|
89
|
+
const tool = `${approval.connector}.${approval.tool}`;
|
|
90
|
+
const name = connectorName(approval.connector);
|
|
91
|
+
const line = promptLineFor(approval.id);
|
|
92
|
+
const held: Held = { id: approval.id, tool, line, answer, publish };
|
|
93
|
+
holdPrompt(
|
|
94
|
+
{ requestId: approval.id, tool, description: name ?? approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
|
|
95
|
+
owner,
|
|
96
|
+
sender(held),
|
|
97
|
+
line === undefined ? undefined : async (text) => metroSend(line, text),
|
|
98
|
+
);
|
|
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
|
+
});
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function forgetServerApprovals(): void {
|
|
108
|
+
recent.clear();
|
|
109
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
|
|
3
|
+
export const MCP_SERVER_KEY = 'mcp';
|
|
4
|
+
|
|
5
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
6
|
+
|
|
7
|
+
let refused: string | undefined;
|
|
8
|
+
|
|
9
|
+
function usable(raw: string): boolean {
|
|
10
|
+
let url: URL;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const secure = url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
|
|
17
|
+
return secure && url.username === '' && url.password === '' && url.search === '' && url.hash === '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function mcpServerUrl(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
21
|
+
const raw = env.METRO_MCP_SERVER_URL?.trim() ?? '';
|
|
22
|
+
if (raw === '') return null;
|
|
23
|
+
if (usable(raw)) return raw.replace(/\/+$/, '');
|
|
24
|
+
if (refused !== raw) {
|
|
25
|
+
refused = raw;
|
|
26
|
+
log.warn({ url: raw }, 'mcp-server: METRO_MCP_SERVER_URL must be an https url (http only on this machine), so it is ignored');
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { MCP_APPROVAL_WAIT_MS, MCP_PATHS, mcpApprovalsOf, type McpApproval, type McpDecision } from '@metro-labs/http/mcp-server-protocol';
|
|
3
|
+
import { agentsDir } from '../agents/files.js';
|
|
4
|
+
import { readAgentKey, type AgentKey } from '../connectors/agent-key.js';
|
|
5
|
+
import { agentCall, MetroApiRefusal, type AgentFetch, type MetroApi } from '../connectors/metro-api.js';
|
|
6
|
+
import { mcpServerUrl } from './config.js';
|
|
7
|
+
import { ensureMcpToken } from './token.js';
|
|
8
|
+
|
|
9
|
+
export type AnswerOutcome = 'acknowledged' | 'gone' | 'stopped';
|
|
10
|
+
|
|
11
|
+
export type HoldApproval = (approval: McpApproval, answer: (decision: McpDecision) => Promise<AnswerOutcome>) => void;
|
|
12
|
+
|
|
13
|
+
export interface McpTiming {
|
|
14
|
+
retryMs: number;
|
|
15
|
+
retryMaxMs: number;
|
|
16
|
+
idleMs: number;
|
|
17
|
+
minPollMs: number;
|
|
18
|
+
pollTimeoutMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface McpLinkDeps {
|
|
22
|
+
url: string;
|
|
23
|
+
owner: () => string | null;
|
|
24
|
+
key: () => AgentKey | null;
|
|
25
|
+
dir: () => string;
|
|
26
|
+
hold: HoldApproval;
|
|
27
|
+
fetch?: AgentFetch;
|
|
28
|
+
timing?: Partial<McpTiming>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TIMING: McpTiming = { retryMs: 2_000, retryMaxMs: 60_000, idleMs: 30_000, minPollMs: 1_000, pollTimeoutMs: MCP_APPROVAL_WAIT_MS + 15_000 };
|
|
32
|
+
const APPROVALS_MAX = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
const refusedWith = (err: unknown, status: number): boolean => err instanceof MetroApiRefusal && err.upstreamStatus === status;
|
|
35
|
+
|
|
36
|
+
export class McpServerLink {
|
|
37
|
+
private readonly api: MetroApi;
|
|
38
|
+
private readonly timing: McpTiming;
|
|
39
|
+
private readonly abort = new AbortController();
|
|
40
|
+
private registered: string | null = null;
|
|
41
|
+
private failures = 0;
|
|
42
|
+
private running: Promise<void> | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly deps: McpLinkDeps) {
|
|
45
|
+
this.api = { base: deps.url, fetch: deps.fetch ?? ((url, init) => fetch(url, init)), now: () => Date.now() };
|
|
46
|
+
this.timing = { ...TIMING, ...deps.timing };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private get stopped(): boolean {
|
|
50
|
+
return this.abort.signal.aborted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
start(): void {
|
|
54
|
+
this.running ??= this.run().catch((err: unknown) => {
|
|
55
|
+
log.error({ err: errMsg(err) }, 'mcp-server: the token and approval loop stopped');
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async stop(): Promise<void> {
|
|
60
|
+
this.abort.abort();
|
|
61
|
+
await this.running;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async answer(id: string, decision: McpDecision): Promise<AnswerOutcome> {
|
|
65
|
+
for (let attempt = 0; !this.stopped; attempt += 1) {
|
|
66
|
+
if (attempt > 0) await this.pause(this.delay(attempt));
|
|
67
|
+
const key = this.enrolledKey();
|
|
68
|
+
if (key === null || this.stopped) continue;
|
|
69
|
+
try {
|
|
70
|
+
await agentCall(this.api, key, MCP_PATHS.answer, { id, decision }, { signal: this.abort.signal });
|
|
71
|
+
log.info({ id, decision }, 'mcp-server: the owner answer reached the server');
|
|
72
|
+
return 'acknowledged';
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (refusedWith(err, 404)) return 'gone';
|
|
75
|
+
if (refusedWith(err, 401)) this.registered = null;
|
|
76
|
+
if (!this.stopped) log.warn({ id, attempt: attempt + 1, err: errMsg(err) }, 'mcp-server: the owner answer did not reach the server; trying again');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return 'stopped';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private enrolledKey(): AgentKey | null {
|
|
83
|
+
try {
|
|
84
|
+
const key = this.deps.key();
|
|
85
|
+
const owner = this.deps.owner();
|
|
86
|
+
return key !== null && owner !== null && key.enrollment?.organization === owner ? key : null;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: the agent key could not be read');
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async run(): Promise<void> {
|
|
94
|
+
while (!this.stopped) {
|
|
95
|
+
const key = this.enrolledKey();
|
|
96
|
+
if (key === null) {
|
|
97
|
+
this.registered = null;
|
|
98
|
+
await this.pause(this.timing.idleMs);
|
|
99
|
+
} else if (await this.step(key)) this.failures = 0;
|
|
100
|
+
else await this.pause(this.delay(++this.failures));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async step(key: AgentKey): Promise<boolean> {
|
|
105
|
+
let doing = 'register the MCP token';
|
|
106
|
+
try {
|
|
107
|
+
await this.register(key);
|
|
108
|
+
doing = 'ask for approvals';
|
|
109
|
+
await this.poll(key);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (this.stopped) return true;
|
|
113
|
+
if (refusedWith(err, 401)) this.registered = null;
|
|
114
|
+
log.warn({ server: this.deps.url, failures: this.failures + 1, err: errMsg(err) }, `mcp-server: could not ${doing}; trying again`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async register(key: AgentKey): Promise<void> {
|
|
120
|
+
const token = ensureMcpToken(key.keyId, this.deps.dir());
|
|
121
|
+
if (token.fresh) log.info({ keyId: key.keyId }, 'mcp-server: made a new MCP token for this agent');
|
|
122
|
+
const binding = `${key.keyId} ${token.hash}`;
|
|
123
|
+
if (this.registered === binding) return;
|
|
124
|
+
await agentCall(this.api, key, MCP_PATHS.token, { tokenHash: token.hash }, { signal: this.abort.signal });
|
|
125
|
+
this.registered = binding;
|
|
126
|
+
log.info({ server: this.deps.url, keyId: key.keyId }, 'mcp-server: the MCP token is registered');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async poll(key: AgentKey): Promise<void> {
|
|
130
|
+
const started = Date.now();
|
|
131
|
+
const answer = await agentCall(this.api, key, MCP_PATHS.approvals, {}, { signal: this.abort.signal, timeoutMs: this.timing.pollTimeoutMs, answerMax: APPROVALS_MAX });
|
|
132
|
+
const approvals = mcpApprovalsOf(answer);
|
|
133
|
+
for (const approval of approvals) this.receive(approval);
|
|
134
|
+
const rest = this.timing.minPollMs - (Date.now() - started);
|
|
135
|
+
if (approvals.length === 0 && rest > 0) await this.pause(rest);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private receive(approval: McpApproval): void {
|
|
139
|
+
try {
|
|
140
|
+
this.deps.hold(approval, (decision) => this.answer(approval.id, decision));
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log.warn({ id: approval.id, err: errMsg(err) }, 'mcp-server: an approval could not be held');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private delay(attempt: number): number {
|
|
147
|
+
return Math.min(this.timing.retryMs * 2 ** Math.min(attempt - 1, 20), this.timing.retryMaxMs);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private pause(ms: number): Promise<void> {
|
|
151
|
+
if (this.stopped) return Promise.resolve();
|
|
152
|
+
const signal = this.abort.signal;
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
const done = (): void => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
signal.removeEventListener('abort', done);
|
|
157
|
+
resolve();
|
|
158
|
+
};
|
|
159
|
+
const timer = setTimeout(done, ms);
|
|
160
|
+
timer.unref();
|
|
161
|
+
signal.addEventListener('abort', done, { once: true });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let active: McpServerLink | null = null;
|
|
167
|
+
|
|
168
|
+
export interface McpServerWatch {
|
|
169
|
+
owner: () => string | null;
|
|
170
|
+
hold: HoldApproval;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function watchMcpServer(watch: McpServerWatch, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
174
|
+
const url = mcpServerUrl(env);
|
|
175
|
+
if (url === null || active !== null) return false;
|
|
176
|
+
active = new McpServerLink({ url, owner: watch.owner, key: () => readAgentKey(), dir: agentsDir, hold: watch.hold });
|
|
177
|
+
active.start();
|
|
178
|
+
log.info({ server: url }, 'mcp-server: connector approvals relay from this server once the agent is enrolled');
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function stopMcpServer(): Promise<void> {
|
|
183
|
+
const link = active;
|
|
184
|
+
active = null;
|
|
185
|
+
await link?.stop();
|
|
186
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
6
|
+
import { MCP_TOKEN_RE } from '@metro-labs/http/mcp-server-protocol';
|
|
7
|
+
|
|
8
|
+
export const MCP_TOKEN_FILE = 'mcp-token';
|
|
9
|
+
const BINDING_FILE = 'mcp-token.json';
|
|
10
|
+
|
|
11
|
+
export interface McpToken {
|
|
12
|
+
token: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
fresh: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const mcpTokenHash = (token: string): string => createHash('sha256').update(token).digest('base64url');
|
|
18
|
+
|
|
19
|
+
const readText = (path: string): string | null => {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(path, 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function readMcpToken(dir: string): string | null {
|
|
28
|
+
const text = readText(join(dir, MCP_TOKEN_FILE))?.trim() ?? '';
|
|
29
|
+
return MCP_TOKEN_RE.test(text) ? text : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function boundKeyId(dir: string): string | null {
|
|
33
|
+
const text = readText(join(dir, BINDING_FILE));
|
|
34
|
+
if (text === null) return null;
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(text);
|
|
37
|
+
return isRecord(parsed) && parsed.version === 1 && typeof parsed.keyId === 'string' ? parsed.keyId : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ensureMcpToken(keyId: string, dir: string): McpToken {
|
|
44
|
+
const kept = readMcpToken(dir);
|
|
45
|
+
if (kept !== null && boundKeyId(dir) === keyId) return { token: kept, hash: mcpTokenHash(kept), fresh: false };
|
|
46
|
+
const token = `mcp_${randomBytes(32).toString('base64url')}`;
|
|
47
|
+
writeSecure(join(dir, MCP_TOKEN_FILE), `${token}\n`);
|
|
48
|
+
writeSecure(join(dir, BINDING_FILE), `${JSON.stringify({ version: 1, keyId })}\n`);
|
|
49
|
+
return { token, hash: mcpTokenHash(token), fresh: true };
|
|
50
|
+
}
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"./connector-input": "./src/connector-input.ts",
|
|
25
25
|
"./connector-delivery": "./src/connector-delivery.ts",
|
|
26
26
|
"./connector-migration": "./src/connector-migration.ts",
|
|
27
|
-
"./connector-grant": "./src/connector-grant.ts"
|
|
27
|
+
"./connector-grant": "./src/connector-grant.ts",
|
|
28
|
+
"./mcp-server-protocol": "./src/mcp-server-protocol.ts"
|
|
28
29
|
},
|
|
29
30
|
"scripts": {
|
|
30
31
|
"build": "tsc",
|
|
@@ -9,6 +9,7 @@ export interface RemoteTool {
|
|
|
9
9
|
title: string;
|
|
10
10
|
description: string;
|
|
11
11
|
readOnly: boolean;
|
|
12
|
+
inputSchema?: Record<string, unknown>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const TIMEOUT_MS = 15_000;
|
|
@@ -39,7 +40,7 @@ function toTool(raw: unknown): RemoteTool | null {
|
|
|
39
40
|
if (!isRecord(raw) || typeof raw.name !== 'string') return null;
|
|
40
41
|
const annotations = isRecord(raw.annotations) ? raw.annotations : {};
|
|
41
42
|
const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
|
|
42
|
-
return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
|
|
43
|
+
return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
|
|
3
|
+
export const MCP_PATHS = { token: '/v1/token', approvals: '/v1/approvals', answer: '/v1/approvals/answer' } as const;
|
|
4
|
+
export const MCP_FIND_DEFAULT = 5;
|
|
5
|
+
export const MCP_FIND_MAX = 20;
|
|
6
|
+
export const MCP_CALLS_MAX = 10;
|
|
7
|
+
export const MCP_QUERY_MAX = 500;
|
|
8
|
+
export const MCP_APPROVAL_WAIT_MS = 25_000;
|
|
9
|
+
export const MCP_APPROVAL_ID_RE = /^[a-km-z]{5}$/;
|
|
10
|
+
export const MCP_TOKEN_RE = /^mcp_[A-Za-z0-9_-]{43}$/;
|
|
11
|
+
|
|
12
|
+
export type McpErrorCode = 'blocked' | 'needs_approval' | 'denied' | 'not_found' | 'signin' | 'unavailable' | 'vendor_error' | 'invalid';
|
|
13
|
+
|
|
14
|
+
export interface McpFindRequest {
|
|
15
|
+
query: string;
|
|
16
|
+
max_results: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface McpCall {
|
|
20
|
+
tool: string;
|
|
21
|
+
arguments: Record<string, unknown>;
|
|
22
|
+
approval?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface McpApproval {
|
|
26
|
+
id: string;
|
|
27
|
+
connector: string;
|
|
28
|
+
tool: string;
|
|
29
|
+
preview: string;
|
|
30
|
+
requestedAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type McpDecision = 'allow' | 'deny';
|
|
34
|
+
|
|
35
|
+
const TOOL_RE = /^([a-z0-9][a-z0-9-]{0,62})\.([A-Za-z0-9_][A-Za-z0-9_.:/-]{0,127})$/;
|
|
36
|
+
|
|
37
|
+
export function splitMcpTool(name: string): { connector: string; tool: string } | null {
|
|
38
|
+
const match = TOOL_RE.exec(name);
|
|
39
|
+
return match === null ? null : { connector: match[1] ?? '', tool: match[2] ?? '' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
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.');
|
|
44
|
+
const wanted = raw.max_results ?? MCP_FIND_DEFAULT;
|
|
45
|
+
if (typeof wanted !== 'number' || !Number.isInteger(wanted) || wanted < 1) throw new Error('max_results must be a whole number from 1.');
|
|
46
|
+
return { query: raw.query.trim(), max_results: Math.min(wanted, MCP_FIND_MAX) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
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.');
|
|
51
|
+
const args = raw.arguments ?? {};
|
|
52
|
+
if (!isRecord(args)) throw new Error(`The arguments of ${raw.tool} must be an object.`);
|
|
53
|
+
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
|
+
return { tool: raw.tool, arguments: args, ...(typeof raw.approval === 'string' ? { approval: raw.approval } : {}) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mcpCallsOf(raw: unknown): McpCall[] {
|
|
58
|
+
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.`);
|
|
60
|
+
return calls.map(callOf);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function mcpTokenHashOf(raw: unknown): string {
|
|
64
|
+
if (!isRecord(raw) || typeof raw.tokenHash !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(raw.tokenHash)) throw new Error('tokenHash must be the base64url SHA-256 of the MCP token.');
|
|
65
|
+
return raw.tokenHash;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function approvalOf(raw: unknown): McpApproval | null {
|
|
69
|
+
if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || typeof raw.connector !== 'string' || typeof raw.tool !== 'string' || typeof raw.preview !== 'string' || typeof raw.requestedAt !== 'number') return null;
|
|
70
|
+
return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function mcpApprovalsOf(raw: unknown): McpApproval[] {
|
|
74
|
+
if (!isRecord(raw) || !Array.isArray(raw.approvals)) throw new Error('Mcp sent an unreadable approval list.');
|
|
75
|
+
return raw.approvals.map(approvalOf).filter((row): row is McpApproval => row !== null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function mcpAnswerOf(raw: unknown): { id: string; decision: McpDecision } {
|
|
79
|
+
if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || (raw.decision !== 'allow' && raw.decision !== 'deny')) throw new Error('An answer needs the approval id and allow or deny.');
|
|
80
|
+
return { id: raw.id, decision: raw.decision };
|
|
81
|
+
}
|
package/runtime/runtime.json
CHANGED