@stage-labs/metro 0.1.0-beta.84 → 0.1.0-beta.87
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/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +58 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +16 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/senders.ts +38 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +26 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +32 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +154 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/aggregate.ts +199 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/upstream.ts +172 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/connector-tools.ts +31 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-registry.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +6 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.87",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { normalizeAllowlist } from './allowlist.js';
|
|
4
|
+
import type { RecentSender } from './senders.js';
|
|
3
5
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
6
|
import {
|
|
5
7
|
apiFailure,
|
|
@@ -63,13 +65,24 @@ export interface AccountApiDeps {
|
|
|
63
65
|
accountId: string,
|
|
64
66
|
) => Promise<AccountRef>;
|
|
65
67
|
syncStations: (station: StationName) => Promise<void>;
|
|
68
|
+
reloadAgents: () => Promise<void>;
|
|
69
|
+
setAllowlist: (
|
|
70
|
+
subject: string,
|
|
71
|
+
agentId: string,
|
|
72
|
+
station: StationName,
|
|
73
|
+
accountId: string,
|
|
74
|
+
allowlist: string[],
|
|
75
|
+
) => Promise<string[]>;
|
|
76
|
+
recentSenders: (station: StationName, accountId: string) => RecentSender[];
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
export type AccountRoute =
|
|
69
80
|
| { kind: 'start' }
|
|
70
81
|
| { kind: 'session'; attachId: string }
|
|
71
82
|
| { kind: 'step'; attachId: string }
|
|
72
|
-
| { kind: 'account'; station: StationName; accountId: string }
|
|
83
|
+
| { kind: 'account'; station: StationName; accountId: string }
|
|
84
|
+
| { kind: 'allowlist'; station: StationName; accountId: string }
|
|
85
|
+
| { kind: 'senders'; station: StationName; accountId: string };
|
|
73
86
|
|
|
74
87
|
export const ATTACHABLE: string[] = [
|
|
75
88
|
...ATTACHABLE_STATIONS,
|
|
@@ -81,6 +94,8 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
81
94
|
session: ['GET', 'DELETE'],
|
|
82
95
|
step: ['POST'],
|
|
83
96
|
account: ['DELETE'],
|
|
97
|
+
allowlist: ['PUT'],
|
|
98
|
+
senders: ['GET'],
|
|
84
99
|
};
|
|
85
100
|
|
|
86
101
|
function twoSegmentRoute(head: string, tail: string): AccountRoute | null {
|
|
@@ -100,10 +115,17 @@ export function accountRoute(rest: string[]): AccountRoute | null {
|
|
|
100
115
|
: ATTACH_ID_RE.test(head)
|
|
101
116
|
? { kind: 'session', attachId: head }
|
|
102
117
|
: null;
|
|
118
|
+
if (rest.length === 3 && tail !== undefined) return accountSubRoute(head, tail, rest[2]);
|
|
103
119
|
if (rest.length !== 2 || tail === undefined) return null;
|
|
104
120
|
return twoSegmentRoute(head, tail);
|
|
105
121
|
}
|
|
106
122
|
|
|
123
|
+
function accountSubRoute(head: string, tail: string, sub: string | undefined): AccountRoute | null {
|
|
124
|
+
if (!isStationName(head) || (sub !== 'allowlist' && sub !== 'senders')) return null;
|
|
125
|
+
const accountId = parseAccountId(tail);
|
|
126
|
+
return accountId === null ? null : { kind: sub, station: head, accountId };
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
export function accountRouteAllows(
|
|
108
130
|
route: AccountRoute,
|
|
109
131
|
method: string | undefined,
|
|
@@ -242,6 +264,36 @@ async function handleDetach(
|
|
|
242
264
|
});
|
|
243
265
|
}
|
|
244
266
|
|
|
267
|
+
async function applied(deps: AccountApiDeps): Promise<boolean> {
|
|
268
|
+
try {
|
|
269
|
+
await deps.reloadAgents();
|
|
270
|
+
return true;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log.warn({ err: errMsg(err) }, 'account-api: allowlist reload failed, the change lands at the next boot');
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function handleAllowlist(
|
|
278
|
+
req: IncomingMessage,
|
|
279
|
+
res: ServerResponse,
|
|
280
|
+
deps: AccountApiDeps,
|
|
281
|
+
session: ApiSession,
|
|
282
|
+
agentId: string,
|
|
283
|
+
target: { station: StationName; accountId: string },
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
const wanted = normalizeAllowlist(bodyField(await readJsonBody(req), 'allowlist'));
|
|
286
|
+
const allowlist = await deps.setAllowlist(session.subject, agentId, target.station, target.accountId, wanted);
|
|
287
|
+
log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
|
|
288
|
+
sendJson(req, res, 200, {
|
|
289
|
+
agentId,
|
|
290
|
+
station: target.station,
|
|
291
|
+
accountId: target.accountId,
|
|
292
|
+
allowlist,
|
|
293
|
+
activated: await applied(deps),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
245
297
|
async function handleSession(
|
|
246
298
|
req: IncomingMessage,
|
|
247
299
|
res: ServerResponse,
|
|
@@ -292,6 +344,11 @@ async function dispatchRoute(
|
|
|
292
344
|
);
|
|
293
345
|
if (route.kind === 'step')
|
|
294
346
|
return handleStep(req, res, deps, ownerOf(session, agentId), route.attachId);
|
|
347
|
+
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, session, agentId, route);
|
|
348
|
+
if (route.kind === 'senders') {
|
|
349
|
+
sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
295
352
|
return handleDetach(req, res, deps, session, agentId, route);
|
|
296
353
|
}
|
|
297
354
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
+
|
|
3
|
+
export const EVERYONE = '*';
|
|
4
|
+
const MAX_ENTRIES = 500;
|
|
5
|
+
const MAX_LENGTH = 200;
|
|
6
|
+
|
|
7
|
+
function hasControlChar(value: string): boolean {
|
|
8
|
+
for (const ch of value) {
|
|
9
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
10
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function senderId(entry: unknown): string {
|
|
16
|
+
if (typeof entry !== 'string') throw new ApiError('allowlist must be a list of sender ids', 400);
|
|
17
|
+
const value = entry.trim();
|
|
18
|
+
if (value.length > MAX_LENGTH || hasControlChar(value))
|
|
19
|
+
throw new ApiError(`a sender id is at most ${String(MAX_LENGTH)} plain characters`, 400);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeAllowlist(raw: unknown): string[] {
|
|
24
|
+
if (!Array.isArray(raw)) throw new ApiError('allowlist must be a list of sender ids', 400);
|
|
25
|
+
if (raw.length > MAX_ENTRIES) throw new ApiError(`allowlist holds at most ${String(MAX_ENTRIES)} senders`, 400);
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
const out: string[] = [];
|
|
28
|
+
for (const entry of raw) {
|
|
29
|
+
const value = senderId(entry);
|
|
30
|
+
if (value === EVERYONE) return [EVERYONE];
|
|
31
|
+
const key = value.toLowerCase();
|
|
32
|
+
if (key === '' || seen.has(key)) continue;
|
|
33
|
+
seen.add(key);
|
|
34
|
+
out.push(value);
|
|
35
|
+
}
|
|
36
|
+
return out.length === 0 ? [EVERYONE] : out;
|
|
37
|
+
}
|
|
@@ -310,6 +310,22 @@ export async function localAttachAccount(
|
|
|
310
310
|
return Promise.resolve({ agentId, station, accountId });
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
export async function localSetAllowlist(
|
|
314
|
+
subject: string,
|
|
315
|
+
agentId: string,
|
|
316
|
+
station: StationName,
|
|
317
|
+
accountId: string,
|
|
318
|
+
allowlist: string[],
|
|
319
|
+
dir = agentsDir(),
|
|
320
|
+
): Promise<string[]> {
|
|
321
|
+
const stored = ownedOrThrow(subject, agentId, dir);
|
|
322
|
+
const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
|
|
323
|
+
if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
|
|
324
|
+
account.allowlist = allowlist;
|
|
325
|
+
save(stored);
|
|
326
|
+
return Promise.resolve(allowlist);
|
|
327
|
+
}
|
|
328
|
+
|
|
313
329
|
export async function localDetachAccount(
|
|
314
330
|
subject: string,
|
|
315
331
|
agentId: string,
|
|
@@ -65,6 +65,10 @@ export function stationAgentIds(station: string): string[] {
|
|
|
65
65
|
.map(([, id]) => id);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function allowlistForAccount(station: string, accountId: string): string[] | undefined {
|
|
69
|
+
return allowlistMap[mapKey(station, accountId)];
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
export function allowlistForLine(line: string): string[] | undefined {
|
|
69
73
|
const a = accountFromLine(line);
|
|
70
74
|
return a ? allowlistMap[mapKey(a.station, a.accountId)] : undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { bufferedSince, type MetroEvent } from '@metro-labs/core/events';
|
|
2
|
+
import { accountFromLine } from './map.js';
|
|
3
|
+
|
|
4
|
+
export interface RecentSender {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
at: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LIMIT = 20;
|
|
11
|
+
|
|
12
|
+
const SKIP = new Set(['', 'self', 'unknown']);
|
|
13
|
+
|
|
14
|
+
function sameAccount(from: string, station: string, accountId: string): boolean {
|
|
15
|
+
const account = accountFromLine(from);
|
|
16
|
+
return account?.station === station && account.accountId === accountId;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function senderOf(event: MetroEvent, station: string, accountId: string): { id: string; name: string } | null {
|
|
20
|
+
const from = String(event.from);
|
|
21
|
+
if (event.station !== station || !sameAccount(from, station, accountId)) return null;
|
|
22
|
+
const id = from.split('/').pop() ?? '';
|
|
23
|
+
if (SKIP.has(id)) return null;
|
|
24
|
+
return { id, name: event.fromDisplayName ?? event.fromName ?? '' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function recentSenders(station: string, accountId: string, limit = DEFAULT_LIMIT): RecentSender[] {
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
const out: RecentSender[] = [];
|
|
30
|
+
for (const { event } of bufferedSince(0).reverse()) {
|
|
31
|
+
const sender = senderOf(event, station, accountId);
|
|
32
|
+
if (sender === null || seen.has(sender.id.toLowerCase())) continue;
|
|
33
|
+
seen.add(sender.id.toLowerCase());
|
|
34
|
+
out.push({ ...sender, at: event.ts });
|
|
35
|
+
if (out.length >= limit) break;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -19,7 +19,10 @@ import {
|
|
|
19
19
|
trainEventToMetroEvent,
|
|
20
20
|
} from '../routes/http.js';
|
|
21
21
|
import { localAgentKey } from '../stations/materialize.js';
|
|
22
|
-
import { fileSource } from '../agents/files.js';
|
|
22
|
+
import { agentsDir, fileSource } from '../agents/files.js';
|
|
23
|
+
import { ConnectorAggregate } from '../connectors/aggregate.js';
|
|
24
|
+
import { setConnectorToolProvider } from '../mcp/connector-tools.js';
|
|
25
|
+
import { invalidateToolSchema } from '../mcp/tool-dispatch.js';
|
|
23
26
|
import { applyLocalOwner } from './local-owner.js';
|
|
24
27
|
import { localOwner } from '../agents/file-admin.js';
|
|
25
28
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
agentLiveness,
|
|
30
33
|
closeAgentSession,
|
|
31
34
|
createMetroMcp,
|
|
35
|
+
announceToolSchemaToAll,
|
|
32
36
|
} from '../mcp/index.js';
|
|
33
37
|
import { metroCall } from '../mcp/ctx.js';
|
|
34
38
|
import { gatherAccountsForAgents } from '../mcp/accounts.js';
|
|
@@ -98,6 +102,9 @@ async function syncStations(station: StationName): Promise<void> {
|
|
|
98
102
|
function sessionApis(): SessionApis {
|
|
99
103
|
return localSessionApis({
|
|
100
104
|
syncStations,
|
|
105
|
+
reloadAgents: async () => {
|
|
106
|
+
await reloadFrom(fileSource);
|
|
107
|
+
},
|
|
101
108
|
restart: () => {
|
|
102
109
|
exitCode = RESTART_EXIT;
|
|
103
110
|
onShutdown();
|
|
@@ -114,6 +121,22 @@ function sessionApis(): SessionApis {
|
|
|
114
121
|
});
|
|
115
122
|
}
|
|
116
123
|
|
|
124
|
+
let connectors: ConnectorAggregate | null = null;
|
|
125
|
+
|
|
126
|
+
function startConnectors(): void {
|
|
127
|
+
const aggregate = new ConnectorAggregate(agentsDir(), () => {
|
|
128
|
+
invalidateToolSchema();
|
|
129
|
+
announceToolSchemaToAll();
|
|
130
|
+
});
|
|
131
|
+
connectors = aggregate;
|
|
132
|
+
setConnectorToolProvider({
|
|
133
|
+
list: () => aggregate.list(),
|
|
134
|
+
owns: (name) => aggregate.owns(name),
|
|
135
|
+
call: (name, args) => aggregate.call(name, args),
|
|
136
|
+
});
|
|
137
|
+
aggregate.start();
|
|
138
|
+
}
|
|
139
|
+
|
|
117
140
|
async function main(): Promise<void> {
|
|
118
141
|
applyLocalOwner();
|
|
119
142
|
await materializeFrom(fileSource, { allowEmpty: true });
|
|
@@ -126,6 +149,7 @@ async function main(): Promise<void> {
|
|
|
126
149
|
metroCall,
|
|
127
150
|
);
|
|
128
151
|
metroMcp.startInbound();
|
|
152
|
+
startConnectors();
|
|
129
153
|
startUploadReaper();
|
|
130
154
|
announceLocalEndpoint();
|
|
131
155
|
tunnel?.start();
|
|
@@ -145,6 +169,7 @@ async function shutdown(): Promise<void> {
|
|
|
145
169
|
if (shuttingDown) return;
|
|
146
170
|
shuttingDown = true;
|
|
147
171
|
log.info('dispatcher shutting down');
|
|
172
|
+
connectors?.stop();
|
|
148
173
|
tunnel?.stop();
|
|
149
174
|
if (webhookServer) {
|
|
150
175
|
const server = webhookServer;
|
|
@@ -4,6 +4,14 @@ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-lab
|
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './settings.js';
|
|
7
|
+
import {
|
|
8
|
+
createClaudeSkill,
|
|
9
|
+
deleteClaudeSkill,
|
|
10
|
+
listClaudeSkills,
|
|
11
|
+
readClaudeSkill,
|
|
12
|
+
skillHomes,
|
|
13
|
+
writeClaudeSkill,
|
|
14
|
+
} from './skills.js';
|
|
7
15
|
import {
|
|
8
16
|
answerClaudeLogin,
|
|
9
17
|
claudeAccount,
|
|
@@ -54,6 +62,10 @@ const COLLECTIONS: Record<string, Handler> = {
|
|
|
54
62
|
sessions: (query, dir) => ({ sessions: listClaudeSessions(projectOf(query), dir) }),
|
|
55
63
|
memory: (query, dir) => listMemory(projectOf(query), dir),
|
|
56
64
|
settings: (_query, dir) => ({ files: listClaudeSettings(dir) }),
|
|
65
|
+
skills: (_query, dir) => ({
|
|
66
|
+
skills: listClaudeSkills(dir),
|
|
67
|
+
places: skillHomes(dir).map((home) => ({ id: home.prefix, scope: home.scope, where: home.where })),
|
|
68
|
+
}),
|
|
57
69
|
};
|
|
58
70
|
|
|
59
71
|
const ITEMS: Record<string, Handler> = {
|
|
@@ -62,17 +74,30 @@ const ITEMS: Record<string, Handler> = {
|
|
|
62
74
|
return readTranscript(projectOf(query), id, offset, limit, dir);
|
|
63
75
|
},
|
|
64
76
|
memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
|
|
77
|
+
skills: (_query, dir, id) => readClaudeSkill(decodeURIComponent(id), dir),
|
|
65
78
|
};
|
|
66
79
|
|
|
67
80
|
const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/').filter(Boolean);
|
|
68
81
|
|
|
82
|
+
const seenIn = (body: Record<string, unknown>): string | null | undefined =>
|
|
83
|
+
'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
|
|
84
|
+
|
|
69
85
|
async function writeAnswer(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
|
|
70
86
|
const [head = '', item = ''] = parts(path);
|
|
71
|
-
if (head !== 'settings' || item === '') throw new ApiError('method not allowed', 405);
|
|
87
|
+
if ((head !== 'settings' && head !== 'skills') || item === '') throw new ApiError('method not allowed', 405);
|
|
72
88
|
const body = await readJsonBody(req, BODY_MAX);
|
|
73
89
|
if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
|
|
74
|
-
|
|
75
|
-
return writeClaudeSettings(item, body.text,
|
|
90
|
+
if (head === 'skills') return writeClaudeSkill(decodeURIComponent(item), body.text, seenIn(body), dir);
|
|
91
|
+
return writeClaudeSettings(item, body.text, seenIn(body), dir);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function created(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
|
|
95
|
+
const rest = parts(path);
|
|
96
|
+
if (rest.length !== 1 || rest[0] !== 'skills') throw new ApiError('method not allowed', 405);
|
|
97
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
98
|
+
if (!isRecord(body) || typeof body.name !== 'string') throw new ApiError('name is required', 400);
|
|
99
|
+
const scope = typeof body.scope === 'string' ? body.scope : undefined;
|
|
100
|
+
return createClaudeSkill(body.name, scope, typeof body.text === 'string' ? body.text : undefined, dir);
|
|
76
101
|
}
|
|
77
102
|
|
|
78
103
|
const LOGIN = 'login';
|
|
@@ -94,7 +119,9 @@ async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps
|
|
|
94
119
|
|
|
95
120
|
function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
|
|
96
121
|
const [head = '', item = ''] = rest;
|
|
97
|
-
if (rest.length !== 2
|
|
122
|
+
if (rest.length !== 2) throw new ApiError('method not allowed', 405);
|
|
123
|
+
if (head === 'skills') return { deleted: deleteClaudeSkill(decodeURIComponent(item), dir) };
|
|
124
|
+
if (head !== 'sessions') throw new ApiError('method not allowed', 405);
|
|
98
125
|
deleteClaudeSession(projectOf(query), item, dir);
|
|
99
126
|
return { deleted: item };
|
|
100
127
|
}
|
|
@@ -132,6 +159,7 @@ export function handleClaudeRequest(
|
|
|
132
159
|
const [head = '', item = ''] = parts(path);
|
|
133
160
|
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
134
161
|
if (req.method === 'PUT') return writeAnswer(req, path, dir);
|
|
162
|
+
if (req.method === 'POST') return created(req, path, dir);
|
|
135
163
|
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
136
164
|
})
|
|
137
165
|
.then((body) => {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { claudeDir, listClaudeProjects } from './files.js';
|
|
5
|
+
|
|
6
|
+
export const SKILL_MAX = 256 * 1024;
|
|
7
|
+
export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
8
|
+
const USER_SCOPE = 'user';
|
|
9
|
+
const FILE = 'SKILL.md';
|
|
10
|
+
const DEFAULT_MODE = 0o644;
|
|
11
|
+
const SUMMARY_MAX = 300;
|
|
12
|
+
|
|
13
|
+
export type SkillScope = 'user' | 'project';
|
|
14
|
+
|
|
15
|
+
export interface ClaudeSkill {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
scope: SkillScope;
|
|
21
|
+
where: string;
|
|
22
|
+
path: string;
|
|
23
|
+
editable: boolean;
|
|
24
|
+
updatedAt: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const frontmatterField = (text: string, field: string): string => {
|
|
28
|
+
const head = text.startsWith('---') ? text.slice(3, text.indexOf('\n---', 3)) : '';
|
|
29
|
+
for (const line of head.split('\n')) {
|
|
30
|
+
const at = line.indexOf(':');
|
|
31
|
+
if (at > 0 && line.slice(0, at).trim() === field) return line.slice(at + 1).trim().replace(/^["']|["']$/g, '').slice(0, SUMMARY_MAX);
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function entryOf(id: string, name: string, scope: SkillScope, where: string, path: string): ClaudeSkill {
|
|
37
|
+
const stat = statSync(path);
|
|
38
|
+
const editable = stat.size <= SKILL_MAX;
|
|
39
|
+
const text = editable ? readFileSync(path, 'utf8') : '';
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
name,
|
|
43
|
+
title: frontmatterField(text, 'name') || name,
|
|
44
|
+
description: frontmatterField(text, 'description'),
|
|
45
|
+
scope,
|
|
46
|
+
where,
|
|
47
|
+
path,
|
|
48
|
+
editable,
|
|
49
|
+
updatedAt: stat.mtime.toISOString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function skillsIn(root: string, prefix: string, scope: SkillScope, where: string): ClaudeSkill[] {
|
|
54
|
+
if (!existsSync(root)) return [];
|
|
55
|
+
const out: ClaudeSkill[] = [];
|
|
56
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
57
|
+
if (!entry.isDirectory() || !SKILL_NAME_RE.test(entry.name)) continue;
|
|
58
|
+
const path = join(root, entry.name, FILE);
|
|
59
|
+
if (existsSync(path)) out.push(entryOf(`${prefix}:${entry.name}`, entry.name, scope, where, path));
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const userSkillsRoot = (dir: string): string => join(dir, 'skills');
|
|
65
|
+
|
|
66
|
+
export interface SkillHome {
|
|
67
|
+
prefix: string;
|
|
68
|
+
scope: SkillScope;
|
|
69
|
+
where: string;
|
|
70
|
+
root: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function skillHomes(dir = claudeDir()): SkillHome[] {
|
|
74
|
+
const homes: SkillHome[] = [{ prefix: USER_SCOPE, scope: USER_SCOPE, where: 'This machine', root: userSkillsRoot(dir) }];
|
|
75
|
+
for (const project of listClaudeProjects(dir)) {
|
|
76
|
+
const cwd = project.cwd;
|
|
77
|
+
if (cwd === null || !existsSync(cwd)) continue;
|
|
78
|
+
homes.push({ prefix: project.id, scope: 'project', where: cwd, root: join(cwd, '.claude', 'skills') });
|
|
79
|
+
}
|
|
80
|
+
return homes;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function listClaudeSkills(dir = claudeDir()): ClaudeSkill[] {
|
|
84
|
+
const out = skillHomes(dir).flatMap((home) => skillsIn(home.root, home.prefix, home.scope, home.where));
|
|
85
|
+
return out.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') || a.name.localeCompare(b.name));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function found(id: string, dir: string): ClaudeSkill {
|
|
89
|
+
const skill = listClaudeSkills(dir).find((entry) => entry.id === id);
|
|
90
|
+
if (skill === undefined) throw new ApiError('no such skill', 404);
|
|
91
|
+
return skill;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readClaudeSkill(id: string, dir = claudeDir()): ClaudeSkill & { text: string } {
|
|
95
|
+
const skill = found(id, dir);
|
|
96
|
+
return { ...skill, text: skill.editable ? readFileSync(skill.path, 'utf8') : '' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeAtomic(path: string, text: string): void {
|
|
100
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
101
|
+
const tmp = `${path}.metro-${String(process.pid)}`;
|
|
102
|
+
writeFileSync(tmp, text, { mode: DEFAULT_MODE });
|
|
103
|
+
renameSync(tmp, path);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertText(text: string): void {
|
|
107
|
+
if (text.length > SKILL_MAX) throw new ApiError('that is more text than a skill may hold', 413);
|
|
108
|
+
if (text.trim() === '') throw new ApiError('a skill needs some text', 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function writeClaudeSkill(
|
|
112
|
+
id: string,
|
|
113
|
+
text: string,
|
|
114
|
+
seenAt: string | null | undefined,
|
|
115
|
+
dir = claudeDir(),
|
|
116
|
+
): ClaudeSkill {
|
|
117
|
+
const skill = found(id, dir);
|
|
118
|
+
if (!skill.editable) throw new ApiError('that skill is too large to edit here', 409);
|
|
119
|
+
assertText(text);
|
|
120
|
+
if (seenAt !== undefined && seenAt !== skill.updatedAt)
|
|
121
|
+
throw new ApiError('that skill changed on disk since you opened it; reload it before saving', 409);
|
|
122
|
+
writeAtomic(skill.path, text);
|
|
123
|
+
return entryOf(skill.id, skill.name, skill.scope, skill.where, skill.path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const skillTemplate = (name: string): string =>
|
|
127
|
+
['---', `name: ${name}`, 'description: what this skill does, and when Claude should reach for it', '---', '', `# ${name}`, '', 'Write the instructions here.', ''].join('\n');
|
|
128
|
+
|
|
129
|
+
export function createClaudeSkill(
|
|
130
|
+
name: string,
|
|
131
|
+
scope: string | undefined,
|
|
132
|
+
text: string | undefined,
|
|
133
|
+
dir = claudeDir(),
|
|
134
|
+
): ClaudeSkill {
|
|
135
|
+
if (typeof name !== 'string' || !SKILL_NAME_RE.test(name))
|
|
136
|
+
throw new ApiError('a skill name is lowercase letters, digits and dashes, up to 64 characters', 400);
|
|
137
|
+
const prefix = scope === undefined || scope === '' ? USER_SCOPE : scope;
|
|
138
|
+
const home = skillHomes(dir).find((h) => h.prefix === prefix);
|
|
139
|
+
if (home === undefined) throw new ApiError('no such place to keep a skill', 404);
|
|
140
|
+
const path = join(home.root, name, FILE);
|
|
141
|
+
if (existsSync(path)) throw new ApiError('a skill by that name already lives there', 409);
|
|
142
|
+
const body = text === undefined || text.trim() === '' ? skillTemplate(name) : text;
|
|
143
|
+
assertText(body);
|
|
144
|
+
writeAtomic(path, body);
|
|
145
|
+
return entryOf(`${home.prefix}:${name}`, name, home.scope, home.where, path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function deleteClaudeSkill(id: string, dir = claudeDir()): string {
|
|
149
|
+
const skill = found(id, dir);
|
|
150
|
+
const folder = dirname(skill.path);
|
|
151
|
+
if (basename(folder) !== skill.name) throw new ApiError('that skill does not live in its own folder', 409);
|
|
152
|
+
rmSync(folder, { recursive: true, force: true });
|
|
153
|
+
return id;
|
|
154
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'node:fs';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import type { ToolResult } from '@metro-labs/core/stations/types';
|
|
5
|
+
import { signInState } from './config.js';
|
|
6
|
+
import type { RelayTarget } from './relay-target.js';
|
|
7
|
+
import { localRelayTarget, readLocalConnectors, type LocalConnectorRow } from './store.js';
|
|
8
|
+
import { UpstreamClient, type UpstreamTool } from './upstream.js';
|
|
9
|
+
|
|
10
|
+
const SEP = '__';
|
|
11
|
+
const SLUG_MAX = 24;
|
|
12
|
+
const NAME_MAX = 64;
|
|
13
|
+
const WATCH_DEBOUNCE_MS = 300;
|
|
14
|
+
const CONNECTORS_FILE = 'connectors.json';
|
|
15
|
+
|
|
16
|
+
export interface ConnectorToolView {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
inputSchema: unknown;
|
|
20
|
+
annotations?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Entry {
|
|
24
|
+
row: LocalConnectorRow;
|
|
25
|
+
stamp: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
client: UpstreamClient;
|
|
28
|
+
tools: UpstreamTool[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type TargetOf = (connectorId: string, force: boolean, dir: string) => Promise<RelayTarget>;
|
|
32
|
+
|
|
33
|
+
export function slugOf(name: string): string {
|
|
34
|
+
const slug = name
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
37
|
+
.replace(/^_+|_+$/g, '')
|
|
38
|
+
.slice(0, SLUG_MAX)
|
|
39
|
+
.replace(/_+$/, '');
|
|
40
|
+
return slug === '' ? 'connector' : slug;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stampOf = (row: LocalConnectorRow): string =>
|
|
44
|
+
JSON.stringify([row.name, row.url, row.config.auth.kind, row.config.verified.at, row.config.oauth]);
|
|
45
|
+
|
|
46
|
+
const toolName = (slug: string, tool: string): string => `${slug}${SEP}${tool}`.slice(0, NAME_MAX);
|
|
47
|
+
|
|
48
|
+
const textBlock = (text: string): { type: 'text'; text: string } => ({ type: 'text', text });
|
|
49
|
+
|
|
50
|
+
function blockOf(block: unknown): { type: 'text'; text: string } {
|
|
51
|
+
if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') return textBlock(block.text);
|
|
52
|
+
const kind = isRecord(block) && typeof block.type === 'string' ? block.type : 'unknown';
|
|
53
|
+
return textBlock(`[${kind} block omitted by metro]`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function toToolResult(raw: unknown): ToolResult {
|
|
57
|
+
if (isRecord(raw) && Array.isArray(raw.content)) {
|
|
58
|
+
const content = raw.content.map(blockOf);
|
|
59
|
+
return raw.isError === true ? { content, isError: true } : { content };
|
|
60
|
+
}
|
|
61
|
+
return { content: [textBlock(JSON.stringify(raw ?? null))] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dedupeSlugs(entries: Map<string, Entry>): void {
|
|
65
|
+
const bySlug = new Map<string, Entry[]>();
|
|
66
|
+
for (const entry of entries.values()) bySlug.set(entry.slug, [...(bySlug.get(entry.slug) ?? []), entry]);
|
|
67
|
+
for (const group of bySlug.values()) {
|
|
68
|
+
if (group.length < 2) continue;
|
|
69
|
+
for (const entry of group) entry.slug = `${entry.slug}_${entry.row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class ConnectorAggregate {
|
|
74
|
+
private entries = new Map<string, Entry>();
|
|
75
|
+
private watcher: FSWatcher | null = null;
|
|
76
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
77
|
+
private loading: Promise<void> | null = null;
|
|
78
|
+
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly dir: string,
|
|
81
|
+
private readonly onChange: () => void,
|
|
82
|
+
private readonly targetOf: TargetOf = localRelayTarget,
|
|
83
|
+
) {}
|
|
84
|
+
|
|
85
|
+
start(): void {
|
|
86
|
+
try {
|
|
87
|
+
this.watcher = watch(this.dir, (_event, file) => {
|
|
88
|
+
if (file === null || file === CONNECTORS_FILE) this.schedule();
|
|
89
|
+
});
|
|
90
|
+
this.watcher.on('error', (err: unknown) => {
|
|
91
|
+
log.warn({ err: errMsg(err) }, 'connectors: watcher failed; tools refresh on the next daemon start');
|
|
92
|
+
});
|
|
93
|
+
this.watcher.unref();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log.warn({ err: errMsg(err) }, 'connectors: could not watch the agents dir');
|
|
96
|
+
}
|
|
97
|
+
this.schedule(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop(): void {
|
|
101
|
+
this.watcher?.close();
|
|
102
|
+
this.watcher = null;
|
|
103
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
104
|
+
this.timer = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private schedule(ms = WATCH_DEBOUNCE_MS): void {
|
|
108
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
109
|
+
this.timer = setTimeout(() => {
|
|
110
|
+
this.timer = null;
|
|
111
|
+
this.reload().catch((err: unknown) => {
|
|
112
|
+
log.warn({ err: errMsg(err) }, 'connectors: reload failed');
|
|
113
|
+
});
|
|
114
|
+
}, ms);
|
|
115
|
+
this.timer.unref();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
reload(): Promise<void> {
|
|
119
|
+
this.loading ??= this.load().finally(() => {
|
|
120
|
+
this.loading = null;
|
|
121
|
+
});
|
|
122
|
+
return this.loading;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async load(): Promise<void> {
|
|
126
|
+
const rows = readLocalConnectors(this.dir).filter((row) => signInState(row.config) !== 'disconnected');
|
|
127
|
+
const next = new Map<string, Entry>();
|
|
128
|
+
const jobs: Promise<void>[] = [];
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
const stamp = stampOf(row);
|
|
131
|
+
const kept = this.entries.get(row.id);
|
|
132
|
+
if (kept?.stamp === stamp && kept !== undefined) {
|
|
133
|
+
next.set(row.id, kept);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const entry: Entry = { row, stamp, slug: slugOf(row.name), client: new UpstreamClient((force) => this.targetOf(row.id, force, this.dir)), tools: [] };
|
|
137
|
+
next.set(row.id, entry);
|
|
138
|
+
jobs.push(this.fill(entry));
|
|
139
|
+
}
|
|
140
|
+
await Promise.all(jobs);
|
|
141
|
+
dedupeSlugs(next);
|
|
142
|
+
const before = this.signature();
|
|
143
|
+
this.entries = next;
|
|
144
|
+
if (this.signature() !== before) this.onChange();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async fill(entry: Entry): Promise<void> {
|
|
148
|
+
try {
|
|
149
|
+
entry.tools = await entry.client.listTools();
|
|
150
|
+
log.info({ connector: entry.row.name, tools: entry.tools.length }, 'connectors: tools listed');
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log.warn({ connector: entry.row.name, err: errMsg(err) }, 'connectors: tools not listed; the connector has no tools until it answers');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
signature(): string {
|
|
157
|
+
return JSON.stringify([...this.entries.values()].map((e) => [e.row.id, e.slug, e.tools.map((t) => t.name)]).sort());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
list(): ConnectorToolView[] {
|
|
161
|
+
const out: ConnectorToolView[] = [];
|
|
162
|
+
for (const entry of [...this.entries.values()].sort((a, b) => a.slug.localeCompare(b.slug)))
|
|
163
|
+
for (const tool of entry.tools)
|
|
164
|
+
out.push({
|
|
165
|
+
name: toolName(entry.slug, tool.name),
|
|
166
|
+
description: tool.description === '' ? `${entry.row.name}: ${tool.name}` : `${tool.description} (${entry.row.name})`,
|
|
167
|
+
inputSchema: tool.inputSchema,
|
|
168
|
+
...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
|
|
169
|
+
});
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private resolve(name: string): { entry: Entry; tool: UpstreamTool } | null {
|
|
174
|
+
const at = name.indexOf(SEP);
|
|
175
|
+
if (at <= 0) return null;
|
|
176
|
+
const slug = name.slice(0, at);
|
|
177
|
+
for (const entry of this.entries.values()) {
|
|
178
|
+
if (entry.slug !== slug) continue;
|
|
179
|
+
const tool = entry.tools.find((t) => toolName(slug, t.name) === name);
|
|
180
|
+
return tool === undefined ? null : { entry, tool };
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
owns(name: string): boolean {
|
|
186
|
+
return this.resolve(name) !== null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async call(name: string, args: Record<string, unknown>): Promise<ToolResult> {
|
|
190
|
+
const found = this.resolve(name);
|
|
191
|
+
if (found === null) return { content: [textBlock(`metro: no connector serves ${name}`)], isError: true };
|
|
192
|
+
try {
|
|
193
|
+
return toToolResult(await found.entry.client.callTool(found.tool.name, args));
|
|
194
|
+
} catch (err) {
|
|
195
|
+
log.warn({ connector: found.entry.row.name, tool: found.tool.name, err: errMsg(err) }, 'connectors: tool call failed');
|
|
196
|
+
return { content: [textBlock(`metro: connector ${found.entry.row.name} ${errMsg(err)}`)], isError: true };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { errMsg } from '@metro-labs/core/log';
|
|
3
|
+
import type { RelayTarget } from './relay-target.js';
|
|
4
|
+
|
|
5
|
+
const ACCEPT = 'application/json, text/event-stream';
|
|
6
|
+
const PROTOCOL = '2025-11-25';
|
|
7
|
+
const MAX_PAGES = 10;
|
|
8
|
+
const LIST_MS = 15_000;
|
|
9
|
+
const CALL_MS = 120_000;
|
|
10
|
+
|
|
11
|
+
export interface UpstreamTool {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
inputSchema: unknown;
|
|
15
|
+
annotations: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TargetOf = (force: boolean) => Promise<RelayTarget>;
|
|
19
|
+
|
|
20
|
+
export class UpstreamRefused extends Error {}
|
|
21
|
+
export class UpstreamFailed extends Error {}
|
|
22
|
+
|
|
23
|
+
interface Live {
|
|
24
|
+
session: string | null;
|
|
25
|
+
protocol: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Answer {
|
|
29
|
+
status: number;
|
|
30
|
+
text: string;
|
|
31
|
+
contentType: string;
|
|
32
|
+
session: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function dataLines(text: string): string[] {
|
|
36
|
+
return text
|
|
37
|
+
.replace(/\r\n/g, '\n')
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter((line) => line.startsWith('data:'))
|
|
40
|
+
.map((line) => line.slice(5).trim());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function messageFor(answer: Answer, id: number): Record<string, unknown> | null {
|
|
44
|
+
const candidates = answer.contentType.includes('text/event-stream') ? dataLines(answer.text) : [answer.text];
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(candidate);
|
|
48
|
+
if (isRecord(parsed) && parsed.id === id) return parsed;
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resultOf(answer: Answer, id: number): unknown {
|
|
57
|
+
const message = messageFor(answer, id);
|
|
58
|
+
if (message === null) throw new UpstreamFailed('answered without a result for the request');
|
|
59
|
+
if (isRecord(message.error)) {
|
|
60
|
+
const text = typeof message.error.message === 'string' ? message.error.message : JSON.stringify(message.error);
|
|
61
|
+
throw new UpstreamFailed(text);
|
|
62
|
+
}
|
|
63
|
+
return message.result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const initializeBody = (id: number): unknown => ({
|
|
67
|
+
jsonrpc: '2.0',
|
|
68
|
+
id,
|
|
69
|
+
method: 'initialize',
|
|
70
|
+
params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'metro', version: '0.1.0' } },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function toolOf(raw: unknown): UpstreamTool | null {
|
|
74
|
+
if (!isRecord(raw) || typeof raw.name !== 'string' || raw.name === '') return null;
|
|
75
|
+
return {
|
|
76
|
+
name: raw.name,
|
|
77
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
78
|
+
inputSchema: isRecord(raw.inputSchema) ? raw.inputSchema : { type: 'object' },
|
|
79
|
+
annotations: isRecord(raw.annotations) ? raw.annotations : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class UpstreamClient {
|
|
84
|
+
private live: Live | null = null;
|
|
85
|
+
private seq = 0;
|
|
86
|
+
|
|
87
|
+
constructor(private readonly target: TargetOf) {}
|
|
88
|
+
|
|
89
|
+
forget(): void {
|
|
90
|
+
this.live = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async listTools(): Promise<UpstreamTool[]> {
|
|
94
|
+
const tools: UpstreamTool[] = [];
|
|
95
|
+
let cursor = '';
|
|
96
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
97
|
+
const result = await this.rpc('tools/list', cursor === '' ? {} : { cursor }, LIST_MS);
|
|
98
|
+
if (!isRecord(result)) break;
|
|
99
|
+
if (Array.isArray(result.tools)) for (const raw of result.tools) {
|
|
100
|
+
const tool = toolOf(raw);
|
|
101
|
+
if (tool !== null) tools.push(tool);
|
|
102
|
+
}
|
|
103
|
+
const next = result.nextCursor;
|
|
104
|
+
if (typeof next !== 'string' || next === '' || next === cursor) break;
|
|
105
|
+
cursor = next;
|
|
106
|
+
}
|
|
107
|
+
return tools;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
111
|
+
return this.rpc('tools/call', { name, arguments: args }, CALL_MS);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async post(body: unknown, headers: Record<string, string>, force: boolean, ms: number): Promise<Answer> {
|
|
115
|
+
const target = await this.target(force);
|
|
116
|
+
if (target.kind === 'missing') throw new UpstreamRefused('this connector no longer exists on the daemon');
|
|
117
|
+
if (target.kind === 'signin') throw new UpstreamRefused('this connector needs signing in again on its page');
|
|
118
|
+
let res: Response;
|
|
119
|
+
try {
|
|
120
|
+
res = await fetch(target.url, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
redirect: 'manual',
|
|
123
|
+
signal: AbortSignal.timeout(ms),
|
|
124
|
+
headers: { 'content-type': 'application/json', accept: ACCEPT, ...target.headers, ...headers },
|
|
125
|
+
body: JSON.stringify(body),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new UpstreamFailed(`could not be reached (${errMsg(err)})`);
|
|
129
|
+
}
|
|
130
|
+
const text = await res.text().catch(() => '');
|
|
131
|
+
return { status: res.status, text, contentType: res.headers.get('content-type') ?? '', session: res.headers.get('mcp-session-id') };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async withCredentialLadder(body: unknown, headers: Record<string, string>, ms: number): Promise<Answer> {
|
|
135
|
+
const first = await this.post(body, headers, false, ms);
|
|
136
|
+
if (first.status !== 401 && first.status !== 403) return first;
|
|
137
|
+
const again = await this.post(body, headers, true, ms);
|
|
138
|
+
if (again.status === 401 || again.status === 403) throw new UpstreamRefused('rejected the credential; sign in again on its page');
|
|
139
|
+
return again;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async ensureSession(): Promise<Live> {
|
|
143
|
+
if (this.live !== null) return this.live;
|
|
144
|
+
const id = ++this.seq;
|
|
145
|
+
const answer = await this.withCredentialLadder(initializeBody(id), {}, LIST_MS);
|
|
146
|
+
if (answer.status >= 300) throw new UpstreamFailed(`answered ${String(answer.status)} to initialize`);
|
|
147
|
+
const result = resultOf(answer, id);
|
|
148
|
+
const protocol = isRecord(result) && typeof result.protocolVersion === 'string' ? result.protocolVersion : PROTOCOL;
|
|
149
|
+
this.live = { session: answer.session, protocol };
|
|
150
|
+
await this.post({ jsonrpc: '2.0', method: 'notifications/initialized' }, this.sessionHeaders(this.live), false, LIST_MS).catch(() => undefined);
|
|
151
|
+
return this.live;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private sessionHeaders(live: Live): Record<string, string> {
|
|
155
|
+
const headers: Record<string, string> = { 'mcp-protocol-version': live.protocol };
|
|
156
|
+
if (live.session !== null && live.session !== '') headers['mcp-session-id'] = live.session;
|
|
157
|
+
return headers;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async rpc(method: string, params: unknown, ms: number): Promise<unknown> {
|
|
161
|
+
const live = await this.ensureSession();
|
|
162
|
+
const id = ++this.seq;
|
|
163
|
+
const body = { jsonrpc: '2.0', id, method, params };
|
|
164
|
+
let answer = await this.withCredentialLadder(body, this.sessionHeaders(live), ms);
|
|
165
|
+
if (answer.status === 404 && live.session !== null) {
|
|
166
|
+
this.live = null;
|
|
167
|
+
answer = await this.withCredentialLadder(body, this.sessionHeaders(await this.ensureSession()), ms);
|
|
168
|
+
}
|
|
169
|
+
if (answer.status >= 300) throw new UpstreamFailed(`answered ${String(answer.status)}${answer.text === '' ? '' : `: ${answer.text.slice(0, 200)}`}`);
|
|
170
|
+
return resultOf(answer, id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from '../stations/registry.js';
|
|
6
6
|
import { listEndpoints } from '../net/tunnel.js';
|
|
7
7
|
import { hookUrl } from '../stations/attach.js';
|
|
8
|
-
import { agentIdForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
8
|
+
import { agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
9
9
|
|
|
10
10
|
const accountId = (acc: unknown): string | undefined => {
|
|
11
11
|
const id = (acc as { id?: unknown }).id;
|
|
@@ -23,7 +23,9 @@ function withAgentId(station: string, acc: unknown): unknown {
|
|
|
23
23
|
const id = typeof rec.id === 'string' ? rec.id : undefined;
|
|
24
24
|
if (id === undefined) return acc;
|
|
25
25
|
const agentId = agentIdForAccount(station, id);
|
|
26
|
-
|
|
26
|
+
if (agentId === undefined) return acc;
|
|
27
|
+
const allowlist = allowlistForAccount(station, id);
|
|
28
|
+
return allowlist === undefined ? { ...rec, agentId } : { ...rec, agentId, allowlist };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function attachAgentIds(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ToolResult } from '@metro-labs/core/stations/types';
|
|
2
|
+
import { errResult } from './ctx.js';
|
|
3
|
+
|
|
4
|
+
export interface ConnectorToolEntry {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
inputSchema: unknown;
|
|
8
|
+
annotations?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ConnectorToolProvider {
|
|
12
|
+
list: () => ConnectorToolEntry[];
|
|
13
|
+
owns: (name: string) => boolean;
|
|
14
|
+
call: (name: string, args: Record<string, unknown>) => Promise<ToolResult>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NONE: ConnectorToolProvider = {
|
|
18
|
+
list: () => [],
|
|
19
|
+
owns: () => false,
|
|
20
|
+
call: (name) => Promise.resolve(errResult(`metro: no connector serves ${name}`)),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let provider: ConnectorToolProvider = NONE;
|
|
24
|
+
|
|
25
|
+
export function setConnectorToolProvider(next: ConnectorToolProvider | null): void {
|
|
26
|
+
provider = next ?? NONE;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const connectorToolList = (): ConnectorToolEntry[] => provider.list();
|
|
30
|
+
export const isConnectorTool = (name: string): boolean => provider.owns(name);
|
|
31
|
+
export const callConnectorTool = (name: string, args: Record<string, unknown>): Promise<ToolResult> => provider.call(name, args);
|
|
@@ -138,6 +138,10 @@ export function agentLiveness(): Map<string, AgentLiveness> {
|
|
|
138
138
|
return activeRegistry?.liveness() ?? new Map<string, AgentLiveness>();
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
export function announceToolSchemaToAll(): void {
|
|
142
|
+
activeRegistry?.announceToolSchema();
|
|
143
|
+
}
|
|
144
|
+
|
|
141
145
|
export async function closeAgentSession(agentId: string): Promise<boolean> {
|
|
142
146
|
const scopeKey = sessionScopeKey({ kind: 'agent', agentId });
|
|
143
147
|
return (await activeRegistry?.closeScope(scopeKey)) ?? false;
|
|
@@ -61,6 +61,10 @@ export class SessionRegistry {
|
|
|
61
61
|
return out;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
announceToolSchema(): void {
|
|
65
|
+
for (const session of this.byId.values()) session.announceToolSchema();
|
|
66
|
+
}
|
|
67
|
+
|
|
64
68
|
forScope(scopeKey: string): McpSession | undefined {
|
|
65
69
|
return this.byScope.get(scopeKey);
|
|
66
70
|
}
|
|
@@ -165,7 +165,7 @@ export class McpSession {
|
|
|
165
165
|
return this.issuedSchema !== toolSchemaSignature();
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
announceToolSchema(): void {
|
|
169
169
|
if (!this.streamAttached) return;
|
|
170
170
|
this.deliverSchemaNotice(
|
|
171
171
|
() => this.server.sendToolListChanged(),
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type RequestIdentity,
|
|
32
32
|
} from './request-identity.js';
|
|
33
33
|
import { str } from '@metro-labs/core/str';
|
|
34
|
+
import { callConnectorTool, connectorToolList, isConnectorTool } from './connector-tools.js';
|
|
34
35
|
|
|
35
36
|
const STATION_TOOLS = new Map<
|
|
36
37
|
string,
|
|
@@ -62,11 +63,16 @@ const toolList = (): { tools: unknown[] } => ({
|
|
|
62
63
|
})),
|
|
63
64
|
),
|
|
64
65
|
LIST_ACCOUNTS_TOOL,
|
|
66
|
+
...connectorToolList(),
|
|
65
67
|
],
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
let schemaSignature: string | undefined;
|
|
69
71
|
|
|
72
|
+
export function invalidateToolSchema(): void {
|
|
73
|
+
schemaSignature = undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
export const toolSchemaSignature = (): string => {
|
|
71
77
|
schemaSignature ??= createHash('sha256')
|
|
72
78
|
.update(JSON.stringify(toolList()))
|
|
@@ -149,6 +155,7 @@ async function runTool(
|
|
|
149
155
|
const name = req.params.name;
|
|
150
156
|
const a = req.params.arguments ?? {};
|
|
151
157
|
|
|
158
|
+
if (isConnectorTool(name)) return callConnectorTool(name, a);
|
|
152
159
|
const identity = currentIdentity();
|
|
153
160
|
if (name !== 'list_accounts' && scopeDenied(identity, name, a))
|
|
154
161
|
return errResult('metro: this account is outside your authorized scope');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { AttachSessions } from '../stations/attach-session.js';
|
|
4
|
+
import { recentSenders } from '../agents/senders.js';
|
|
4
5
|
import type { AgentApiDeps } from '../agents/api.js';
|
|
5
6
|
import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
|
|
6
7
|
import type { SessionApis } from './session-apis.js';
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
localCreateAgent,
|
|
34
35
|
localDeleteAgent,
|
|
35
36
|
localDetachAccount,
|
|
37
|
+
localSetAllowlist,
|
|
36
38
|
localImportAgent,
|
|
37
39
|
localListAgents,
|
|
38
40
|
localOwnedAgentOrThrow,
|
|
@@ -46,6 +48,7 @@ import type { StationName } from '@metro-labs/core/station-names';
|
|
|
46
48
|
|
|
47
49
|
export interface LocalModeDeps {
|
|
48
50
|
syncStations: (station: StationName) => Promise<void>;
|
|
51
|
+
reloadAgents: () => Promise<void>;
|
|
49
52
|
restart: () => void;
|
|
50
53
|
stop: () => void;
|
|
51
54
|
closeAgentSession: (id: string) => Promise<boolean>;
|
|
@@ -98,6 +101,9 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
98
101
|
attachAccount: localAttachAccount,
|
|
99
102
|
detachAccount: localDetachAccount,
|
|
100
103
|
syncStations: deps.syncStations,
|
|
104
|
+
setAllowlist: localSetAllowlist,
|
|
105
|
+
recentSenders,
|
|
106
|
+
reloadAgents: deps.reloadAgents,
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
|
package/runtime/runtime.json
CHANGED