@stage-labs/metro 0.1.0-beta.47 → 0.1.0-beta.48
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/mcp/src/daemon/import-api.ts +2 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +46 -17
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +21 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +29 -1
- 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.48",
|
|
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": {
|
|
@@ -15,6 +15,7 @@ const PATH = '/api/agents/import';
|
|
|
15
15
|
|
|
16
16
|
export interface ImportedAgent extends CreatedAgent {
|
|
17
17
|
stations: number;
|
|
18
|
+
connectors: number;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface ImportApiDeps {
|
|
@@ -31,7 +32,7 @@ async function handle(
|
|
|
31
32
|
const code = parsePairingCode(bodyField(await readJsonBody(req), 'code'));
|
|
32
33
|
const made = await deps.importAgent(subject, code);
|
|
33
34
|
log.info(
|
|
34
|
-
{ agent: made.name, id: made.id, stations: made.stations },
|
|
35
|
+
{ agent: made.name, id: made.id, stations: made.stations, connectors: made.connectors },
|
|
35
36
|
'import-api: agent imported from metro',
|
|
36
37
|
);
|
|
37
38
|
sendJson(req, res, 201, made);
|
|
@@ -14,6 +14,7 @@ import { allowLocalConnectors } from './connector-url.js';
|
|
|
14
14
|
import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
|
|
15
15
|
import type { RelayApiDeps } from './relay.js';
|
|
16
16
|
import {
|
|
17
|
+
localImportConnectors,
|
|
17
18
|
localAddConnector,
|
|
18
19
|
localAgentConnectors,
|
|
19
20
|
localConnectorNamesByIds,
|
|
@@ -164,6 +165,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
|
164
165
|
assertLocalOwner(subject);
|
|
165
166
|
const agent = await fetchAgent(code, hostname());
|
|
166
167
|
const made = await localImportAgent(subject, agent);
|
|
168
|
+
const connectors = localImportConnectors(agent.connectors ?? []);
|
|
167
169
|
for (const station of new Set(agent.accounts.map((a) => a.station)))
|
|
168
170
|
await deps.syncStations(station).catch((err: unknown) => {
|
|
169
171
|
log.warn(
|
|
@@ -171,7 +173,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
|
171
173
|
'import: station reload failed, the change lands at the next boot',
|
|
172
174
|
);
|
|
173
175
|
});
|
|
174
|
-
return made;
|
|
176
|
+
return { ...made, connectors };
|
|
175
177
|
},
|
|
176
178
|
};
|
|
177
179
|
}
|
|
@@ -187,12 +187,7 @@ export function assertLocalOwner(subject: string, dir = agentsDir()): void {
|
|
|
187
187
|
if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
subject: string,
|
|
192
|
-
agent: LoadedAgent,
|
|
193
|
-
dir = agentsDir(),
|
|
194
|
-
): Promise<{ id: string; name: string; key: string; stations: number }> {
|
|
195
|
-
assertLocalOwner(subject, dir);
|
|
190
|
+
function assertImportable(agent: LoadedAgent): asserts agent is LoadedAgent & { key: string } {
|
|
196
191
|
const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
|
|
197
192
|
if (immovable !== undefined)
|
|
198
193
|
throw new AgentAdminError(
|
|
@@ -201,37 +196,71 @@ export async function localImportAgent(
|
|
|
201
196
|
);
|
|
202
197
|
if (agent.key === null)
|
|
203
198
|
throw new AgentAdminError('that agent has no key; reset it on metro.box first', 400);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface ImportTarget {
|
|
202
|
+
path: string;
|
|
203
|
+
previous: AgentFile | undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function importTarget(dir: string, agent: LoadedAgent & { key: string }): ImportTarget {
|
|
207
|
+
const existing = storedAgents(dir);
|
|
208
|
+
const same = existing.find((s) => s.file.id === agent.id);
|
|
209
|
+
const fresh = join(dir, agent.name, AGENT_FILE);
|
|
210
|
+
if (same === undefined && existsSync(fresh))
|
|
207
211
|
throw new AgentAdminError(
|
|
208
212
|
`an agent named '${agent.name}' already exists on this machine`,
|
|
209
213
|
409,
|
|
210
214
|
);
|
|
211
|
-
assertUnclaimed(
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
assertUnclaimed(existing.filter((s) => s !== same), agent);
|
|
216
|
+
return { path: same?.path ?? fresh, previous: same?.file };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function localImportAgent(
|
|
220
|
+
subject: string,
|
|
221
|
+
agent: LoadedAgent,
|
|
222
|
+
dir = agentsDir(),
|
|
223
|
+
): Promise<{ id: string; name: string; key: string; stations: number }> {
|
|
224
|
+
assertLocalOwner(subject, dir);
|
|
225
|
+
assertImportable(agent);
|
|
226
|
+
const { path, previous } = importTarget(dir, agent);
|
|
227
|
+
const file = fileFor(agent, localOwner(dir), path, previous);
|
|
228
|
+
ensureSecureDir(join(path, '..'));
|
|
214
229
|
save({ path, file });
|
|
230
|
+
if (previous !== undefined && previous.key !== agent.key) unregisterAgentKey(agent.id);
|
|
215
231
|
registerKey(agent.key, agent.id);
|
|
216
232
|
return Promise.resolve({
|
|
217
233
|
id: agent.id,
|
|
218
234
|
name: agent.name,
|
|
219
235
|
key: agent.key,
|
|
220
|
-
stations:
|
|
236
|
+
stations: file.stations.length,
|
|
221
237
|
});
|
|
222
238
|
}
|
|
223
239
|
|
|
224
|
-
function
|
|
240
|
+
function mergedStations(agent: LoadedAgent, previous: AgentFile | undefined): AgentFile['stations'] {
|
|
241
|
+
const fromMetro = agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] }));
|
|
242
|
+
const kept = (previous?.stations ?? []).filter(
|
|
243
|
+
(s) => !fromMetro.some((m) => m.station === s.station && m.id === s.id),
|
|
244
|
+
);
|
|
245
|
+
return [...fromMetro, ...kept];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function fileFor(
|
|
249
|
+
agent: LoadedAgent,
|
|
250
|
+
owner: string | null,
|
|
251
|
+
path: string,
|
|
252
|
+
previous?: AgentFile,
|
|
253
|
+
): AgentFile {
|
|
225
254
|
try {
|
|
226
255
|
return parseAgentFile(
|
|
227
256
|
JSON.stringify({
|
|
228
257
|
version: 1,
|
|
229
258
|
id: agent.id,
|
|
230
|
-
name: agent.name,
|
|
259
|
+
name: previous?.name ?? agent.name,
|
|
231
260
|
key: agent.key,
|
|
232
261
|
owner,
|
|
233
|
-
stations: agent
|
|
234
|
-
connectors: [],
|
|
262
|
+
stations: mergedStations(agent, previous),
|
|
263
|
+
connectors: previous?.connectors ?? [],
|
|
235
264
|
}),
|
|
236
265
|
path,
|
|
237
266
|
);
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
LOCAL_PROJECT_ID,
|
|
49
49
|
} from './file-admin.js';
|
|
50
50
|
import { newId } from './ids.js';
|
|
51
|
+
import type { LoadedConnector } from './materialize.js';
|
|
51
52
|
|
|
52
53
|
export interface LocalConnectorRow {
|
|
53
54
|
id: string;
|
|
@@ -113,6 +114,26 @@ async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
|
|
|
113
114
|
return advertisesOAuth(url);
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
export function localImportConnectors(rows: LoadedConnector[], dir = agentsDir()): number {
|
|
118
|
+
const current = readLocalConnectors(dir);
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
const other = current.find((r) => r.id !== row.id && r.name === row.name);
|
|
121
|
+
if (other !== undefined)
|
|
122
|
+
throw new ConnectorError(`a connector named '${row.name}' already exists on this daemon; rename it first`, 409);
|
|
123
|
+
}
|
|
124
|
+
const imported: LocalConnectorRow[] = rows.map((row) => ({
|
|
125
|
+
id: row.id,
|
|
126
|
+
name: row.name,
|
|
127
|
+
url: row.url,
|
|
128
|
+
transport: 'http',
|
|
129
|
+
config: readConfig(row.config),
|
|
130
|
+
}));
|
|
131
|
+
const ids = new Set(imported.map((r) => r.id));
|
|
132
|
+
writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
|
|
133
|
+
for (const row of imported) localHoldEverywhere(row.id, dir);
|
|
134
|
+
return imported.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
export async function localListConnectors(
|
|
117
138
|
subject: string,
|
|
118
139
|
project: string,
|
|
@@ -21,7 +21,14 @@ import {
|
|
|
21
21
|
type AllowlistMap,
|
|
22
22
|
} from './agent-map.js';
|
|
23
23
|
import { setKeyMap } from './key-map.js';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
agentConnectors,
|
|
26
|
+
agents,
|
|
27
|
+
connectors,
|
|
28
|
+
stations,
|
|
29
|
+
STATIONS,
|
|
30
|
+
type StationName,
|
|
31
|
+
} from './schema.js';
|
|
25
32
|
|
|
26
33
|
interface StationTarget {
|
|
27
34
|
file: string;
|
|
@@ -36,11 +43,20 @@ export interface LoadedAccount {
|
|
|
36
43
|
config: Record<string, unknown>;
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
export interface LoadedConnector {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
url: string;
|
|
50
|
+
transport: 'http';
|
|
51
|
+
config: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export interface LoadedAgent {
|
|
40
55
|
id: string;
|
|
41
56
|
name: string;
|
|
42
57
|
accounts: LoadedAccount[];
|
|
43
58
|
key: string | null;
|
|
59
|
+
connectors?: LoadedConnector[];
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
const METRO_DIR = join(homedir(), '.metro');
|
|
@@ -133,10 +149,22 @@ export async function loadAgentForRuntime(
|
|
|
133
149
|
.select()
|
|
134
150
|
.from(stations)
|
|
135
151
|
.where(eq(stations.agentId, a.id));
|
|
152
|
+
const held = await db
|
|
153
|
+
.select({ id: connectors.id, name: connectors.name, url: connectors.url, config: connectors.config })
|
|
154
|
+
.from(agentConnectors)
|
|
155
|
+
.innerJoin(connectors, eq(connectors.id, agentConnectors.connectorId))
|
|
156
|
+
.where(eq(agentConnectors.agentId, a.id));
|
|
136
157
|
return {
|
|
137
158
|
id: a.id,
|
|
138
159
|
name: a.name,
|
|
139
160
|
key: a.key,
|
|
161
|
+
connectors: held.map((c) => ({
|
|
162
|
+
id: c.id,
|
|
163
|
+
name: c.name,
|
|
164
|
+
url: c.url,
|
|
165
|
+
transport: 'http' as const,
|
|
166
|
+
config: c.config as Record<string, unknown>,
|
|
167
|
+
})),
|
|
140
168
|
accounts: acctRows
|
|
141
169
|
.filter((r) => MOVABLE_STATIONS.has(r.station))
|
|
142
170
|
.map((r) => ({
|
package/runtime/runtime.json
CHANGED