@stage-labs/metro 0.1.0-beta.39 → 0.1.0-beta.40
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/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +9 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/hosted-connectors.ts +91 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-connectors-api.ts +56 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +20 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +8 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +18 -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.40",
|
|
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": {
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
deleteConnectorForUser,
|
|
84
84
|
disconnectConnectorForUser,
|
|
85
85
|
connectorNamesByIds,
|
|
86
|
+
connectorSummariesByIds,
|
|
86
87
|
getConnectorForUser,
|
|
87
88
|
listConnectorsForUser,
|
|
88
89
|
renameConnectorForUser,
|
|
@@ -276,6 +277,7 @@ const projectApi: ProjectApiDeps = {
|
|
|
276
277
|
const connectorApi: ConnectorApiDeps = {
|
|
277
278
|
listConnectors: listConnectorsForUser,
|
|
278
279
|
connectorNamesByIds,
|
|
280
|
+
connectorSummariesByIds,
|
|
279
281
|
agentConnectors: agentConnectorsForUser,
|
|
280
282
|
fenceRuntime,
|
|
281
283
|
renameConnector: renameConnectorForUser,
|
|
@@ -20,11 +20,13 @@ import type { ConnectorApiDeps } from './connector-api.js';
|
|
|
20
20
|
const CLAIM_PATH = '/api/cli/claim';
|
|
21
21
|
const MCP_PATH = '/api/cli/mcp';
|
|
22
22
|
const SESSION_PATH = '/api/cli/session';
|
|
23
|
+
const CONNECTORS_PATH = '/api/cli/connectors';
|
|
23
24
|
|
|
24
25
|
const ALLOWED: Record<string, string> = {
|
|
25
26
|
[CLAIM_PATH]: 'POST',
|
|
26
27
|
[MCP_PATH]: 'GET',
|
|
27
28
|
[SESSION_PATH]: 'GET',
|
|
29
|
+
[CONNECTORS_PATH]: 'GET',
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
function secretOrNull(): string | null {
|
|
@@ -94,6 +96,13 @@ async function handleRead(
|
|
|
94
96
|
});
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
99
|
+
if (path === CONNECTORS_PATH) {
|
|
100
|
+
sendJson(req, res, 200, {
|
|
101
|
+
agent: agent.name,
|
|
102
|
+
connectors: await deps.connectorSummariesByIds(agent.connectorIds),
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
97
106
|
const entries = await deps.connectorNamesByIds(agent.connectorIds);
|
|
98
107
|
const json = relayServersJson(
|
|
99
108
|
entries,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectorSummary } from '../db/connectors.js';
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
3
|
import { errMsg, log } from './log.js';
|
|
3
4
|
import {
|
|
@@ -36,6 +37,7 @@ const asText = (value: unknown): string =>
|
|
|
36
37
|
|
|
37
38
|
export interface ConnectorApiDeps extends OAuthRouteDeps {
|
|
38
39
|
listConnectors: (subject: string, project: string) => Promise<Connector[]>;
|
|
40
|
+
connectorSummariesByIds: (ids: string[]) => Promise<ConnectorSummary[]>;
|
|
39
41
|
connectorNamesByIds: (ids: string[]) => Promise<RelayServerEntry[]>;
|
|
40
42
|
agentConnectors: (subject: string, agentId: string) => Promise<AgentConnectors>;
|
|
41
43
|
fenceRuntime: Fence;
|
|
@@ -0,0 +1,91 @@
|
|
|
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 hostedCredentialFor(agentId: string, dir = configDir()): HostedCredential | null {
|
|
49
|
+
const runtime = join(dir, `runtime-${agentId}.json`);
|
|
50
|
+
if (existsSync(runtime)) return credential(readJsonFile(runtime));
|
|
51
|
+
const login = credential(readJsonFile(join(dir, 'credentials.json')));
|
|
52
|
+
return login !== null && agentOfToken(login.token) === agentId ? login : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isConnector = (v: unknown): v is HostedConnector =>
|
|
56
|
+
typeof v === 'object' &&
|
|
57
|
+
v !== null &&
|
|
58
|
+
typeof (v as { id?: unknown }).id === 'string' &&
|
|
59
|
+
typeof (v as { name?: unknown }).name === 'string';
|
|
60
|
+
|
|
61
|
+
async function fetchHosted(cred: HostedCredential): Promise<HostedConnector[]> {
|
|
62
|
+
const res = await fetch(`${cred.url}/api/cli/connectors`, {
|
|
63
|
+
headers: { authorization: `Bearer ${cred.token}` },
|
|
64
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
65
|
+
redirect: 'manual',
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) throw new Error(`metro answered ${String(res.status)} for the connectors`);
|
|
68
|
+
const body = (await res.json()) as { connectors?: unknown };
|
|
69
|
+
return Array.isArray(body.connectors) ? body.connectors.filter(isConnector) : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cache = new Map<string, { at: number; list: HostedConnector[] }>();
|
|
73
|
+
|
|
74
|
+
export async function hostedConnectorsFor(agentId: string, dir = configDir()): Promise<HostedConnector[]> {
|
|
75
|
+
const hit = cache.get(agentId);
|
|
76
|
+
if (hit !== undefined && Date.now() - hit.at < CACHE_MS) return hit.list;
|
|
77
|
+
const cred = hostedCredentialFor(agentId, dir);
|
|
78
|
+
if (cred === null) return [];
|
|
79
|
+
try {
|
|
80
|
+
const list = await fetchHosted(cred);
|
|
81
|
+
cache.set(agentId, { at: Date.now(), list });
|
|
82
|
+
return list;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log.warn({ agent: agentId, err: errMsg(err) }, 'hosted connectors: unavailable, listing none');
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function forgetHostedConnectors(): void {
|
|
90
|
+
cache.clear();
|
|
91
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { SessionApis } from './session-apis.js';
|
|
|
10
10
|
import type { ModeInfo } from './mode-api.js';
|
|
11
11
|
import type { ImportApiDeps } from './import-api.js';
|
|
12
12
|
import { fetchAgentWithCode } from './agent-import.js';
|
|
13
|
+
import { forgetHostedConnectors, hostedConnectorsFor, type HostedConnector } from './hosted-connectors.js';
|
|
13
14
|
import {
|
|
14
15
|
assertLocalOwner,
|
|
15
16
|
claimLocalOwner,
|
|
@@ -93,7 +94,7 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
93
94
|
liveness: deps.liveness,
|
|
94
95
|
releaseRuntime: () => notHere('runtime leases'),
|
|
95
96
|
runtimes: () => Promise.resolve(new Map()),
|
|
96
|
-
connectorIds:
|
|
97
|
+
connectorIds: connectorIdsOfLocalAgents,
|
|
97
98
|
prepareAccount: deps.prepareAccount,
|
|
98
99
|
attachAccount: localAttachAccount,
|
|
99
100
|
detachAccount: localDetachAccount,
|
|
@@ -123,10 +124,25 @@ const projectApi: ProjectApiDeps = {
|
|
|
123
124
|
removeMember: () => notHere('members'),
|
|
124
125
|
};
|
|
125
126
|
|
|
127
|
+
async function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
|
|
128
|
+
const pairs = await Promise.all(
|
|
129
|
+
ids.map(async (id) => [id, (await hostedConnectorsFor(id)).map((c) => c.id)] as const),
|
|
130
|
+
);
|
|
131
|
+
return new Map(pairs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function connectorsOfOwner(subject: string): Promise<HostedConnector[]> {
|
|
135
|
+
const agents = await localListAgents(subject, LOCAL_PROJECT_ID).catch(() => []);
|
|
136
|
+
const lists = await Promise.all(agents.map((a) => hostedConnectorsFor(a.id)));
|
|
137
|
+
const seen = new Map<string, HostedConnector>();
|
|
138
|
+
for (const c of lists.flat()) seen.set(c.id, c);
|
|
139
|
+
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
const agentConnectorApi: AgentConnectorApiDeps = {
|
|
127
143
|
agentConnectors: async (subject, id) => {
|
|
128
144
|
const { agent } = await localOwnedAgentOrThrow(subject, id);
|
|
129
|
-
return { ...agent, connectorIds:
|
|
145
|
+
return { ...agent, connectorIds: (await hostedConnectorsFor(id)).map((c) => c.id) };
|
|
130
146
|
},
|
|
131
147
|
addConnector: () => notHere('connectors'),
|
|
132
148
|
removeConnector: () => notHere('connectors'),
|
|
@@ -141,6 +157,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
|
141
157
|
assertLocalOwner(subject);
|
|
142
158
|
const agent = await fetchAgent(code, hostname());
|
|
143
159
|
const made = await localImportAgent(subject, agent);
|
|
160
|
+
forgetHostedConnectors();
|
|
144
161
|
for (const station of new Set(agent.accounts.map((a) => a.station)))
|
|
145
162
|
await deps.syncStations(station).catch((err: unknown) => {
|
|
146
163
|
log.warn(
|
|
@@ -162,6 +179,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
162
179
|
agentApi: agentApi(deps),
|
|
163
180
|
agentConnectorApi,
|
|
164
181
|
importApi: importApi(deps),
|
|
182
|
+
localConnectors: { listConnectors: connectorsOfOwner },
|
|
165
183
|
projectApi,
|
|
166
184
|
siwe: { ensureUser: claimLocalOwner },
|
|
167
185
|
mode: localModeInfo,
|
|
@@ -18,12 +18,17 @@ import {
|
|
|
18
18
|
} from './agent-connector-api.js';
|
|
19
19
|
import type { SiweRouteDeps } from './siwe-routes.js';
|
|
20
20
|
import { handleImportRequest, type ImportApiDeps } from './import-api.js';
|
|
21
|
+
import {
|
|
22
|
+
handleLocalConnectorsRequest,
|
|
23
|
+
type LocalConnectorsDeps,
|
|
24
|
+
} from './local-connectors-api.js';
|
|
21
25
|
import type { ModeInfo } from './mode-api.js';
|
|
22
26
|
|
|
23
27
|
export interface SessionApis {
|
|
24
28
|
agentApi?: AgentApiDeps;
|
|
25
29
|
agentConnectorApi?: AgentConnectorApiDeps;
|
|
26
30
|
importApi?: ImportApiDeps;
|
|
31
|
+
localConnectors?: LocalConnectorsDeps;
|
|
27
32
|
connectorApi?: ConnectorApiDeps;
|
|
28
33
|
projectApi?: ProjectApiDeps;
|
|
29
34
|
runApi?: RunApiDeps;
|
|
@@ -38,6 +43,7 @@ export function handleSessionApis(
|
|
|
38
43
|
apis: SessionApis,
|
|
39
44
|
): boolean {
|
|
40
45
|
const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
|
|
46
|
+
const { localConnectors } = apis;
|
|
41
47
|
const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
|
|
42
48
|
if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
|
|
43
49
|
if (projectApi)
|
|
@@ -48,6 +54,8 @@ export function handleSessionApis(
|
|
|
48
54
|
() => handleConnectorApiRequest(req, res, connectorApi),
|
|
49
55
|
);
|
|
50
56
|
if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
|
|
57
|
+
if (localConnectors)
|
|
58
|
+
routes.push(() => handleLocalConnectorsRequest(req, res, localConnectors));
|
|
51
59
|
if (agentConnectorApi)
|
|
52
60
|
routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
|
|
53
61
|
if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
|
|
@@ -139,6 +139,24 @@ async function rowsByIds(ids: string[]): Promise<ConnectorRow[]> {
|
|
|
139
139
|
.orderBy(asc(connectors.id));
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
export interface ConnectorSummary {
|
|
143
|
+
id: string;
|
|
144
|
+
name: string;
|
|
145
|
+
url: string;
|
|
146
|
+
transport: string;
|
|
147
|
+
signIn: ConnectorSignIn;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function connectorSummariesByIds(ids: string[]): Promise<ConnectorSummary[]> {
|
|
151
|
+
return (await rowsByIds(ids)).map((row) => ({
|
|
152
|
+
id: row.id,
|
|
153
|
+
name: row.name,
|
|
154
|
+
url: row.url,
|
|
155
|
+
transport: row.transport,
|
|
156
|
+
signIn: signInState(readConfig(row.config)),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
export async function connectorNamesByIds(
|
|
143
161
|
ids: string[],
|
|
144
162
|
): Promise<{ id: string; name: string }[]> {
|
package/runtime/runtime.json
CHANGED