@stage-labs/metro 0.1.0-beta.41 → 0.1.0-beta.43
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/dist/api.js +19 -2
- package/dist/cli.js +11 -8
- package/dist/local.js +67 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +95 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-files.ts +321 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-url.ts +20 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-cli-api.ts +70 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +67 -22
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +3 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +7 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +12 -2
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +36 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +11 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +312 -0
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/hosted-connectors.ts +0 -118
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-connectors-api.ts +0 -56
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readJson, writeSecure } from '../daemon/secure-fs.js';
|
|
3
|
+
import { errMsg, log } from '../daemon/log.js';
|
|
4
|
+
import {
|
|
5
|
+
connectorUrlText,
|
|
6
|
+
ConnectorVerifyError,
|
|
7
|
+
parseConnectorUrl,
|
|
8
|
+
verifyRemoteMcp,
|
|
9
|
+
type ConnectorAuth,
|
|
10
|
+
type OAuthAuth,
|
|
11
|
+
} from '../daemon/connector-verify.js';
|
|
12
|
+
import { oauthExpired, refreshOAuth } from '../daemon/connector-oauth.js';
|
|
13
|
+
import { advertisesOAuth } from '../daemon/oauth-discovery.js';
|
|
14
|
+
import {
|
|
15
|
+
ConnectorError,
|
|
16
|
+
connectorAuth,
|
|
17
|
+
connectorName,
|
|
18
|
+
readConfig,
|
|
19
|
+
signInState,
|
|
20
|
+
stamp,
|
|
21
|
+
type ConnectorConfig,
|
|
22
|
+
} from './connector-config.js';
|
|
23
|
+
import {
|
|
24
|
+
bearerHeaders,
|
|
25
|
+
fixedTarget,
|
|
26
|
+
unrefreshedTarget,
|
|
27
|
+
type RelayTarget,
|
|
28
|
+
} from './connector-relay.js';
|
|
29
|
+
import {
|
|
30
|
+
connectorFromRow,
|
|
31
|
+
UNVERIFIED,
|
|
32
|
+
type Connector,
|
|
33
|
+
type ConnectorCheck,
|
|
34
|
+
type ConnectorInput,
|
|
35
|
+
type ConnectorSummary,
|
|
36
|
+
type DeletedConnector,
|
|
37
|
+
} from './connectors.js';
|
|
38
|
+
import type { AgentConnectors } from './agent-connectors.js';
|
|
39
|
+
import { agentsDir } from './file-source.js';
|
|
40
|
+
import {
|
|
41
|
+
assertLocalOwner,
|
|
42
|
+
connectorIdsOfLocalAgent,
|
|
43
|
+
localAgentConnectorIds,
|
|
44
|
+
localAgentsWith,
|
|
45
|
+
localDropConnectorEverywhere,
|
|
46
|
+
localOwnedAgentOrThrow,
|
|
47
|
+
localSetAgentConnectors,
|
|
48
|
+
LOCAL_PROJECT_ID,
|
|
49
|
+
} from './file-admin.js';
|
|
50
|
+
import { newId } from './ids.js';
|
|
51
|
+
|
|
52
|
+
export interface LocalConnectorRow {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
url: string;
|
|
56
|
+
transport: 'http';
|
|
57
|
+
config: ConnectorConfig;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FILE = 'connectors.json';
|
|
61
|
+
const filePath = (dir: string): string => join(dir, FILE);
|
|
62
|
+
|
|
63
|
+
const isRow = (v: unknown): v is { id: string; name: string; url: string; config?: unknown } =>
|
|
64
|
+
typeof v === 'object' &&
|
|
65
|
+
v !== null &&
|
|
66
|
+
typeof (v as { id?: unknown }).id === 'string' &&
|
|
67
|
+
typeof (v as { name?: unknown }).name === 'string' &&
|
|
68
|
+
typeof (v as { url?: unknown }).url === 'string';
|
|
69
|
+
|
|
70
|
+
const asList = (value: unknown): unknown[] =>
|
|
71
|
+
Array.isArray(value) ? value.map((item: unknown) => item) : [];
|
|
72
|
+
|
|
73
|
+
export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
|
|
74
|
+
const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
|
|
75
|
+
warn: 'connectors.json: malformed, ignoring',
|
|
76
|
+
});
|
|
77
|
+
return asList(raw.connectors).filter(isRow).map((r) => ({
|
|
78
|
+
id: r.id,
|
|
79
|
+
name: r.name,
|
|
80
|
+
url: r.url,
|
|
81
|
+
transport: 'http' as const,
|
|
82
|
+
config: readConfig(r.config),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeRows(dir: string, rows: LocalConnectorRow[]): void {
|
|
87
|
+
writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const missing = (): ConnectorError => new ConnectorError('no such connector', 404);
|
|
91
|
+
|
|
92
|
+
function ownedRows(subject: string, project: string, dir: string): LocalConnectorRow[] {
|
|
93
|
+
assertLocalOwner(subject, dir);
|
|
94
|
+
if (project !== LOCAL_PROJECT_ID) throw new ConnectorError('no such project', 404);
|
|
95
|
+
return readLocalConnectors(dir);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rowOrThrow(subject: string, id: string, dir: string): LocalConnectorRow {
|
|
99
|
+
assertLocalOwner(subject, dir);
|
|
100
|
+
const row = readLocalConnectors(dir).find((r) => r.id === id);
|
|
101
|
+
if (row === undefined) throw missing();
|
|
102
|
+
return row;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function replace(dir: string, row: LocalConnectorRow): Connector {
|
|
106
|
+
writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
|
|
107
|
+
return connectorFromRow(row);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
|
|
111
|
+
if (auth.kind === 'oauth') return true;
|
|
112
|
+
if (auth.kind === 'header') return false;
|
|
113
|
+
return advertisesOAuth(url);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function localListConnectors(
|
|
117
|
+
subject: string,
|
|
118
|
+
project: string,
|
|
119
|
+
dir = agentsDir(),
|
|
120
|
+
): Promise<Connector[]> {
|
|
121
|
+
return Promise.resolve(ownedRows(subject, project, dir).map(connectorFromRow));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function localGetConnector(subject: string, id: string, dir = agentsDir()): Promise<Connector> {
|
|
125
|
+
return Promise.resolve(connectorFromRow(rowOrThrow(subject, id, dir)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
|
|
129
|
+
const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), transport: 'http', config };
|
|
130
|
+
writeRows(dir, [...readLocalConnectors(dir), row]);
|
|
131
|
+
return connectorFromRow(row);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function localCreateConnector(
|
|
135
|
+
subject: string,
|
|
136
|
+
project: string,
|
|
137
|
+
input: ConnectorInput,
|
|
138
|
+
dir = agentsDir(),
|
|
139
|
+
): Promise<Connector> {
|
|
140
|
+
ownedRows(subject, project, dir);
|
|
141
|
+
const name = connectorName(input.name);
|
|
142
|
+
const url = parseConnectorUrl(input.url);
|
|
143
|
+
const auth = connectorAuth(input.header, input.value);
|
|
144
|
+
const verified = stamp(await verifyRemoteMcp(url, auth));
|
|
145
|
+
return insert(dir, name, url, {
|
|
146
|
+
auth,
|
|
147
|
+
createdAt: new Date().toISOString(),
|
|
148
|
+
verified,
|
|
149
|
+
oauth: await oauthCapable(url, auth),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function localCreatePendingConnector(
|
|
154
|
+
subject: string,
|
|
155
|
+
project: string,
|
|
156
|
+
input: { name: unknown; url: unknown },
|
|
157
|
+
dir = agentsDir(),
|
|
158
|
+
): Promise<Connector> {
|
|
159
|
+
ownedRows(subject, project, dir);
|
|
160
|
+
const name = connectorName(input.name);
|
|
161
|
+
const url = parseConnectorUrl(input.url);
|
|
162
|
+
return Promise.resolve(
|
|
163
|
+
insert(dir, name, url, { auth: { kind: 'none' }, createdAt: new Date().toISOString(), verified: UNVERIFIED, oauth: true }),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function localReconnectConnector(
|
|
168
|
+
subject: string,
|
|
169
|
+
id: string,
|
|
170
|
+
auth: OAuthAuth,
|
|
171
|
+
dir = agentsDir(),
|
|
172
|
+
): Promise<Connector> {
|
|
173
|
+
const row = rowOrThrow(subject, id, dir);
|
|
174
|
+
const verified = stamp(await verifyRemoteMcp(parseConnectorUrl(row.url), auth));
|
|
175
|
+
return replace(dir, { ...row, config: { ...row.config, auth, verified, oauth: true } });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function localDisconnectConnector(subject: string, id: string, dir = agentsDir()): Promise<Connector> {
|
|
179
|
+
const row = rowOrThrow(subject, id, dir);
|
|
180
|
+
if (row.config.auth.kind !== 'oauth') throw new ConnectorError('that connector is not signed in', 400);
|
|
181
|
+
return Promise.resolve(replace(dir, { ...row, config: { ...row.config, auth: { kind: 'none' }, oauth: true } }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function freshAuth(auth: ConnectorAuth, resource: string): Promise<ConnectorAuth> {
|
|
185
|
+
if (auth.kind !== 'oauth' || !oauthExpired(auth)) return auth;
|
|
186
|
+
return refreshOAuth(auth, resource);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function localVerifyConnector(subject: string, id: string, dir = agentsDir()): Promise<ConnectorCheck> {
|
|
190
|
+
const row = rowOrThrow(subject, id, dir);
|
|
191
|
+
try {
|
|
192
|
+
const url = parseConnectorUrl(row.url);
|
|
193
|
+
const auth = await freshAuth(row.config.auth, url.toString());
|
|
194
|
+
const verified = stamp(await verifyRemoteMcp(url, auth));
|
|
195
|
+
const oauth = row.config.oauth || (await oauthCapable(url, auth));
|
|
196
|
+
replace(dir, { ...row, config: { ...row.config, auth, verified, oauth } });
|
|
197
|
+
return { id: row.id, name: row.name, ok: true, verified };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (!(err instanceof ConnectorVerifyError)) throw err;
|
|
200
|
+
return { id: row.id, name: row.name, ok: false, reason: err.message };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const nameClash = (name: string, agent: string): ConnectorError =>
|
|
205
|
+
new ConnectorError(`the agent '${agent}' already has a connector named '${name}'`, 409);
|
|
206
|
+
|
|
207
|
+
function clashFor(name: string, exceptId: string, agentIds: string[], dir: string): string | null {
|
|
208
|
+
const rows = readLocalConnectors(dir);
|
|
209
|
+
for (const agentId of agentIds) {
|
|
210
|
+
const held = connectorIdsOfLocalAgent(agentId, dir) ?? [];
|
|
211
|
+
const other = rows.find((r) => r.id !== exceptId && held.includes(r.id) && r.name === name);
|
|
212
|
+
if (other !== undefined) return localAgentsWith(other.id, dir).find((a) => a.id === agentId)?.name ?? agentId;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
218
|
+
const name = connectorName(raw);
|
|
219
|
+
const row = rowOrThrow(subject, id, dir);
|
|
220
|
+
const clash = clashFor(name, id, localAgentsWith(id, dir).map((a) => a.id), dir);
|
|
221
|
+
if (clash !== null) throw nameClash(name, clash);
|
|
222
|
+
return Promise.resolve(replace(dir, { ...row, name }));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function localDeleteConnector(subject: string, id: string, dir = agentsDir()): Promise<DeletedConnector> {
|
|
226
|
+
const row = rowOrThrow(subject, id, dir);
|
|
227
|
+
writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
|
|
228
|
+
localDropConnectorEverywhere(id, dir);
|
|
229
|
+
return Promise.resolve({ id: row.id, name: row.name });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const byIds = (ids: string[], dir: string): LocalConnectorRow[] =>
|
|
233
|
+
readLocalConnectors(dir).filter((r) => ids.includes(r.id));
|
|
234
|
+
|
|
235
|
+
export async function localConnectorSummariesByIds(ids: string[], dir = agentsDir()): Promise<ConnectorSummary[]> {
|
|
236
|
+
return Promise.resolve(
|
|
237
|
+
byIds(ids, dir).map((r) => ({ id: r.id, name: r.name, url: r.url, transport: r.transport, signIn: signInState(r.config) })),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function localConnectorNamesByIds(ids: string[], dir = agentsDir()): Promise<{ id: string; name: string }[]> {
|
|
242
|
+
return Promise.resolve(byIds(ids, dir).map((r) => ({ id: r.id, name: r.name })));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function localAgentConnectors(subject: string, agentId: string, dir = agentsDir()): Promise<AgentConnectors> {
|
|
246
|
+
const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
|
|
247
|
+
return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function localAddConnector(subject: string, agentId: string, connectorId: string, dir = agentsDir()): Promise<AgentConnectors> {
|
|
251
|
+
const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
|
|
252
|
+
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
253
|
+
if (row === undefined) throw missing();
|
|
254
|
+
const held = localAgentConnectorIds(subject, agentId, dir);
|
|
255
|
+
if (!held.includes(connectorId)) {
|
|
256
|
+
const clash = clashFor(row.name, connectorId, [agentId], dir);
|
|
257
|
+
if (clash !== null) throw nameClash(row.name, clash);
|
|
258
|
+
localSetAgentConnectors(subject, agentId, [...held, connectorId], dir);
|
|
259
|
+
}
|
|
260
|
+
return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function localRemoveConnector(subject: string, agentId: string, connectorId: string, dir = agentsDir()): Promise<AgentConnectors> {
|
|
264
|
+
const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
|
|
265
|
+
const held = localAgentConnectorIds(subject, agentId, dir);
|
|
266
|
+
localSetAgentConnectors(subject, agentId, held.filter((id) => id !== connectorId), dir);
|
|
267
|
+
return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const inflight = new Map<string, Promise<OAuthAuth>>();
|
|
271
|
+
|
|
272
|
+
function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Promise<OAuthAuth> {
|
|
273
|
+
const running = inflight.get(row.id);
|
|
274
|
+
if (running !== undefined) return running;
|
|
275
|
+
const job = refreshOAuth(auth, parseConnectorUrl(row.url).toString())
|
|
276
|
+
.then((fresh) => {
|
|
277
|
+
replace(dir, { ...row, config: { ...row.config, auth: fresh } });
|
|
278
|
+
return fresh;
|
|
279
|
+
})
|
|
280
|
+
.finally(() => {
|
|
281
|
+
inflight.delete(row.id);
|
|
282
|
+
});
|
|
283
|
+
inflight.set(row.id, job);
|
|
284
|
+
return job;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function oauthTarget(row: LocalConnectorRow, auth: OAuthAuth, force: boolean, dir: string): Promise<RelayTarget> {
|
|
288
|
+
if (!force && !oauthExpired(auth)) return { kind: 'ok', url: row.url, headers: bearerHeaders(auth.accessToken) };
|
|
289
|
+
try {
|
|
290
|
+
const fresh = await refreshOnce(row, auth, dir);
|
|
291
|
+
return { kind: 'ok', url: row.url, headers: bearerHeaders(fresh.accessToken) };
|
|
292
|
+
} catch (err) {
|
|
293
|
+
log.warn({ id: row.id, err: errMsg(err) }, 'local relay: token refresh failed');
|
|
294
|
+
return unrefreshedTarget(row.url, auth, force);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function localRelayTarget(
|
|
299
|
+
agentId: string,
|
|
300
|
+
connectorId: string,
|
|
301
|
+
force: boolean,
|
|
302
|
+
dir = agentsDir(),
|
|
303
|
+
): Promise<RelayTarget> {
|
|
304
|
+
const held = connectorIdsOfLocalAgent(agentId, dir);
|
|
305
|
+
if (!held?.includes(connectorId)) return { kind: 'missing' };
|
|
306
|
+
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
307
|
+
if (row === undefined) return { kind: 'missing' };
|
|
308
|
+
parseConnectorUrl(row.url);
|
|
309
|
+
const auth = row.config.auth;
|
|
310
|
+
if (auth.kind === 'oauth') return oauthTarget(row, auth, force, dir);
|
|
311
|
+
return fixedTarget(row.url, auth, force);
|
|
312
|
+
}
|
package/runtime/runtime.json
CHANGED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { errMsg, log } from './log.js';
|
|
4
|
-
import { configDir } from './paths.js';
|
|
5
|
-
import { metroBaseUrl } from './agent-import.js';
|
|
6
|
-
|
|
7
|
-
const FETCH_TIMEOUT_MS = 15_000;
|
|
8
|
-
const CACHE_MS = 60_000;
|
|
9
|
-
|
|
10
|
-
export interface HostedConnector {
|
|
11
|
-
id: string;
|
|
12
|
-
name: string;
|
|
13
|
-
url: string;
|
|
14
|
-
transport: string;
|
|
15
|
-
signIn: 'connected' | 'disconnected' | null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface HostedCredential {
|
|
19
|
-
token: string;
|
|
20
|
-
url: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function readJsonFile(path: string): Record<string, unknown> | null {
|
|
24
|
-
try {
|
|
25
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
26
|
-
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function agentOfToken(token: string): string | null {
|
|
33
|
-
const body = token.split('.')[1];
|
|
34
|
-
if (body === undefined) return null;
|
|
35
|
-
try {
|
|
36
|
-
const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { agent?: unknown };
|
|
37
|
-
return typeof claims.agent === 'string' ? claims.agent : null;
|
|
38
|
-
} catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function credential(file: Record<string, unknown> | null): HostedCredential | null {
|
|
44
|
-
if (file === null || typeof file.token !== 'string' || file.token === '') return null;
|
|
45
|
-
return { token: file.token, url: typeof file.url === 'string' && file.url !== '' ? file.url : metroBaseUrl() };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function hostedCredentialsFor(agentId: string, dir = configDir()): HostedCredential[] {
|
|
49
|
-
const out: HostedCredential[] = [];
|
|
50
|
-
const login = credential(readJsonFile(join(dir, 'credentials.json')));
|
|
51
|
-
if (login !== null && agentOfToken(login.token) === agentId) out.push(login);
|
|
52
|
-
const runtime = join(dir, `runtime-${agentId}.json`);
|
|
53
|
-
if (existsSync(runtime)) {
|
|
54
|
-
const cred = credential(readJsonFile(runtime));
|
|
55
|
-
if (cred !== null) out.push(cred);
|
|
56
|
-
}
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const isConnector = (v: unknown): v is HostedConnector =>
|
|
61
|
-
typeof v === 'object' &&
|
|
62
|
-
v !== null &&
|
|
63
|
-
typeof (v as { id?: unknown }).id === 'string' &&
|
|
64
|
-
typeof (v as { name?: unknown }).name === 'string';
|
|
65
|
-
|
|
66
|
-
class Refused extends Error {}
|
|
67
|
-
|
|
68
|
-
async function fetchHosted(cred: HostedCredential): Promise<HostedConnector[]> {
|
|
69
|
-
const res = await fetch(`${cred.url}/api/cli/connectors`, {
|
|
70
|
-
headers: { authorization: `Bearer ${cred.token}` },
|
|
71
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
72
|
-
redirect: 'manual',
|
|
73
|
-
});
|
|
74
|
-
if (res.status === 401 || res.status === 403 || res.status === 409) {
|
|
75
|
-
await res.arrayBuffer().catch(() => undefined);
|
|
76
|
-
throw new Refused(`metro no longer accepts this credential (${String(res.status)})`);
|
|
77
|
-
}
|
|
78
|
-
if (!res.ok) throw new Error(`metro answered ${String(res.status)} for the connectors`);
|
|
79
|
-
const body = (await res.json()) as { connectors?: unknown };
|
|
80
|
-
return Array.isArray(body.connectors) ? body.connectors.filter(isConnector) : [];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function firstAccepted(agentId: string, creds: HostedCredential[]): Promise<HostedConnector[] | null> {
|
|
84
|
-
for (const cred of creds) {
|
|
85
|
-
try {
|
|
86
|
-
return await fetchHosted(cred);
|
|
87
|
-
} catch (err) {
|
|
88
|
-
if (!(err instanceof Refused)) throw err;
|
|
89
|
-
log.warn({ agent: agentId, reason: errMsg(err) }, 'hosted connectors: credential refused, trying the next');
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const cache = new Map<string, { at: number; list: HostedConnector[] }>();
|
|
96
|
-
|
|
97
|
-
export async function hostedConnectorsFor(agentId: string, dir = configDir()): Promise<HostedConnector[]> {
|
|
98
|
-
const hit = cache.get(agentId);
|
|
99
|
-
if (hit !== undefined && Date.now() - hit.at < CACHE_MS) return hit.list;
|
|
100
|
-
const creds = hostedCredentialsFor(agentId, dir);
|
|
101
|
-
if (creds.length === 0) return [];
|
|
102
|
-
try {
|
|
103
|
-
const list = await firstAccepted(agentId, creds);
|
|
104
|
-
if (list === null) {
|
|
105
|
-
log.warn({ agent: agentId }, 'hosted connectors: every stored credential is stale; metro login again');
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
108
|
-
cache.set(agentId, { at: Date.now(), list });
|
|
109
|
-
return list;
|
|
110
|
-
} catch (err) {
|
|
111
|
-
log.warn({ agent: agentId, err: errMsg(err) }, 'hosted connectors: unavailable, listing none');
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function forgetHostedConnectors(): void {
|
|
117
|
-
cache.clear();
|
|
118
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { errMsg, log } from './log.js';
|
|
3
|
-
import { apiSession, cors, sendJson } from './api-http.js';
|
|
4
|
-
import type { HostedConnector } from './hosted-connectors.js';
|
|
5
|
-
|
|
6
|
-
const PATH = '/api/connectors';
|
|
7
|
-
|
|
8
|
-
export interface LocalConnectorsDeps {
|
|
9
|
-
listConnectors: (subject: string) => Promise<HostedConnector[]>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const payload = (c: HostedConnector): Record<string, unknown> => ({
|
|
13
|
-
id: c.id,
|
|
14
|
-
name: c.name,
|
|
15
|
-
url: c.url,
|
|
16
|
-
transport: c.transport,
|
|
17
|
-
auth: c.signIn === null ? 'none' : 'oauth',
|
|
18
|
-
header: null,
|
|
19
|
-
signIn: c.signIn,
|
|
20
|
-
verified: null,
|
|
21
|
-
managed: 'metro.box',
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export function handleLocalConnectorsRequest(
|
|
25
|
-
req: IncomingMessage,
|
|
26
|
-
res: ServerResponse,
|
|
27
|
-
deps: LocalConnectorsDeps,
|
|
28
|
-
): boolean {
|
|
29
|
-
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
30
|
-
if (path !== PATH && !path.startsWith(`${PATH}/`)) return false;
|
|
31
|
-
if (req.method === 'OPTIONS') {
|
|
32
|
-
res.writeHead(204, cors(req)).end();
|
|
33
|
-
return true;
|
|
34
|
-
}
|
|
35
|
-
if (path !== PATH || req.method !== 'GET') {
|
|
36
|
-
sendJson(req, res, path === PATH ? 405 : 404, {
|
|
37
|
-
error: 'connectors are managed on metro.box, not on a local daemon',
|
|
38
|
-
});
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
const session = apiSession(req);
|
|
42
|
-
if (!session) {
|
|
43
|
-
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
deps
|
|
47
|
-
.listConnectors(session.subject)
|
|
48
|
-
.then((list) => {
|
|
49
|
-
sendJson(req, res, 200, { connectors: list.map(payload) });
|
|
50
|
-
})
|
|
51
|
-
.catch((err: unknown) => {
|
|
52
|
-
log.warn({ err: errMsg(err) }, 'local connectors: request failed');
|
|
53
|
-
if (!res.headersSent) sendJson(req, res, 500, { error: 'connectors failed' });
|
|
54
|
-
});
|
|
55
|
-
return true;
|
|
56
|
-
}
|