@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.84",
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
- const seenAt = 'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
75
- return writeClaudeSettings(item, body.text, seenAt, dir);
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 || head !== 'sessions') throw new ApiError('method not allowed', 405);
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
- return agentId === undefined ? acc : { ...rec, agentId };
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
- private announceToolSchema(): void {
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
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.84"
2
+ "version": "0.1.0-beta.87"
3
3
  }