@stage-labs/metro 0.1.0-beta.110 → 0.1.0-beta.112

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.
@@ -41,18 +41,24 @@ function readOrNull(path) {
41
41
  return null;
42
42
  }
43
43
  }
44
+ function copyMarketplace(sources, store) {
45
+ const staged = join(sources, MARKETPLACE);
46
+ if (!existsSync(staged))
47
+ return false;
48
+ cpSync(staged, join(store, MARKETPLACE), { recursive: true });
49
+ return true;
50
+ }
44
51
  function syncSources(sources, store) {
45
52
  const shim = join(sources, STORE_ENTRY);
46
53
  if (existsSync(shim))
47
54
  copyFileSync(shim, join(store, STORE_ENTRY));
48
55
  const stamp = readOrNull(join(sources, 'runtime.json'));
49
56
  if (stamp !== null && readOrNull(join(store, 'runtime.json')) === stamp)
50
- return false;
57
+ return existsSync(join(store, MARKETPLACE)) ? false : copyMarketplace(sources, store);
51
58
  rmSync(join(store, METRO_SOURCES), { recursive: true, force: true });
52
59
  cpSync(join(sources, METRO_SOURCES), join(store, METRO_SOURCES), { recursive: true });
53
60
  rmSync(join(store, MARKETPLACE), { recursive: true, force: true });
54
- if (existsSync(join(sources, MARKETPLACE)))
55
- cpSync(join(sources, MARKETPLACE), join(store, MARKETPLACE), { recursive: true });
61
+ copyMarketplace(sources, store);
56
62
  if (stamp !== null)
57
63
  writeFileSync(join(store, 'runtime.json'), stamp);
58
64
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.110",
3
+ "version": "0.1.0-beta.112",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.110",
3
+ "version": "0.1.0-beta.112",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -115,9 +115,21 @@ async function ensureInstalled(run: Run, wanted: string | null): Promise<PluginO
115
115
  return (await step(run, ['plugin', 'update', PLUGIN, '-y'])) ? 'updated' : 'failed';
116
116
  }
117
117
 
118
+ function explainMissingMarketplace(env: NodeJS.ProcessEnv = process.env): void {
119
+ const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
120
+ if (store === '') return;
121
+ log.warn(
122
+ { store },
123
+ 'plugin: the runtime store holds no plugin marketplace, so metro cannot install its Claude Code plugin; a `metro serve` started before beta.97 never copied it, restart the service once',
124
+ );
125
+ }
126
+
118
127
  export async function ensureMetroPlugin(deps?: Partial<PluginInstallDeps>): Promise<PluginOutcome> {
119
128
  const dir = deps?.marketplaceDir === undefined ? stagedMarketplaceDir() : deps.marketplaceDir;
120
- if (dir === null) return 'skipped';
129
+ if (dir === null) {
130
+ if (deps?.marketplaceDir === undefined) explainMissingMarketplace();
131
+ return 'skipped';
132
+ }
121
133
  const run = deps?.run === undefined ? await findClaude() : deps.run;
122
134
  if (run === null) {
123
135
  log.info('plugin: claude is not on this machine, so metro leaves its Claude Code plugin alone');
@@ -93,6 +93,7 @@ function connectorPayload(
93
93
  transport: row.transport,
94
94
  auth: row.auth,
95
95
  header: row.header,
96
+ clientId: row.client?.clientId ?? null,
96
97
  signIn: row.signIn,
97
98
  verified: detail ? { ...summary, catalog } : summary,
98
99
  };
@@ -134,6 +135,8 @@ async function handleCreate(
134
135
  url: bodyField(body, 'url'),
135
136
  header: bodyField(body, 'header'),
136
137
  value: bodyField(body, 'value'),
138
+ clientId: bodyField(body, 'clientId'),
139
+ clientSecret: bodyField(body, 'clientSecret'),
137
140
  });
138
141
  log.info(
139
142
  { id: created.id, name: created.name, host: hostOf(created.url) },
@@ -5,11 +5,14 @@ import type {
5
5
  VerifiedServer,
6
6
  } from './verify.js';
7
7
  import { readStoredTools } from './tools.js';
8
+ import type { OAuthClient } from './oauth-client.js';
8
9
  import { isRecord } from '@metro-labs/core/is-record';
9
10
 
10
11
  const CONNECTOR_NAME_MAX = 64;
11
12
  const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
12
13
  const HEADER_VALUE_RE = /^[\x20-\x7e]{1,4096}$/;
14
+ const CLIENT_ID_RE = /^[\x21-\x7e]{1,256}$/;
15
+ const CLIENT_SECRET_RE = /^[\x21-\x7e]{1,1024}$/;
13
16
  const DEFAULT_HEADER = 'Authorization';
14
17
 
15
18
  export class ConnectorError extends ApiError {}
@@ -21,6 +24,7 @@ export interface ConnectorConfig {
21
24
  createdAt: string;
22
25
  verified: VerifiedRecord;
23
26
  oauth: boolean;
27
+ client: OAuthClient | null;
24
28
  }
25
29
 
26
30
  function text(value: unknown): string {
@@ -71,6 +75,30 @@ export function connectorAuth(
71
75
  return { kind: 'header', name, value };
72
76
  }
73
77
 
78
+ export function connectorClient(
79
+ rawId: unknown,
80
+ rawSecret: unknown,
81
+ ): OAuthClient | null {
82
+ const clientId = text(rawId).trim();
83
+ const clientSecret = text(rawSecret).trim();
84
+ if (clientId === '' && clientSecret === '') return null;
85
+ if (clientId === '')
86
+ throw new ConnectorError('a client secret needs the client ID it belongs to', 400);
87
+ if (!CLIENT_ID_RE.test(clientId))
88
+ throw new ConnectorError('that is not a client ID Metro can send', 400);
89
+ if (clientSecret !== '' && !CLIENT_SECRET_RE.test(clientSecret))
90
+ throw new ConnectorError('that client secret is not sendable', 400);
91
+ return { clientId, ...(clientSecret === '' ? {} : { clientSecret }) };
92
+ }
93
+
94
+ function readClient(raw: unknown): OAuthClient | null {
95
+ if (!isRecord(raw)) return null;
96
+ const clientId = text(raw.clientId);
97
+ if (clientId === '') return null;
98
+ const clientSecret = text(raw.clientSecret);
99
+ return { clientId, ...(clientSecret === '' ? {} : { clientSecret }) };
100
+ }
101
+
74
102
  function readOAuth(raw: Record<string, unknown>): ConnectorAuth {
75
103
  const accessToken = text(raw.accessToken);
76
104
  const clientId = text(raw.clientId);
@@ -79,6 +107,7 @@ function readOAuth(raw: Record<string, unknown>): ConnectorAuth {
79
107
  return { kind: 'none' };
80
108
  const refreshToken = text(raw.refreshToken);
81
109
  const clientSecret = text(raw.clientSecret);
110
+ const scope = text(raw.scope);
82
111
  return {
83
112
  kind: 'oauth',
84
113
  accessToken,
@@ -87,6 +116,7 @@ function readOAuth(raw: Record<string, unknown>): ConnectorAuth {
87
116
  issuer: text(raw.issuer),
88
117
  ...(refreshToken === '' ? {} : { refreshToken }),
89
118
  ...(clientSecret === '' ? {} : { clientSecret }),
119
+ ...(scope === '' ? {} : { scope }),
90
120
  ...(typeof raw.expiresAt === 'number' ? { expiresAt: raw.expiresAt } : {}),
91
121
  };
92
122
  }
@@ -123,6 +153,7 @@ export function readConfig(raw: unknown): ConnectorConfig {
123
153
  createdAt: text(record.createdAt),
124
154
  verified: readVerified(record.verified),
125
155
  oauth: record.oauth === true || auth.kind === 'oauth',
156
+ client: readClient(record.client),
126
157
  };
127
158
  }
128
159
 
@@ -1,4 +1,5 @@
1
1
  import type { ConnectorAuth, VerifiedRecord } from './verify.js';
2
+ import type { OAuthClient } from './oauth-client.js';
2
3
  import { readConfig, signInState, type ConnectorSignIn } from './config.js';
3
4
  import type { ConnectorTransport } from '@metro-labs/core/station-names';
4
5
 
@@ -14,11 +15,17 @@ export interface Connector {
14
15
  expiresAt: number | null;
15
16
  signIn: ConnectorSignIn;
16
17
  verified: VerifiedRecord;
18
+ client: OAuthClient | null;
17
19
  }
18
20
 
19
- export interface ConnectorInput {
21
+ export interface PendingConnectorInput {
20
22
  name: unknown;
21
23
  url: unknown;
24
+ clientId: unknown;
25
+ clientSecret: unknown;
26
+ }
27
+
28
+ export interface ConnectorInput extends PendingConnectorInput {
22
29
  header: unknown;
23
30
  value: unknown;
24
31
  }
@@ -55,6 +62,7 @@ export function connectorFromRow(row: ConnectorLike): Connector {
55
62
  expiresAt: auth.kind === 'oauth' ? (auth.expiresAt ?? null) : null,
56
63
  signIn: signInState(config),
57
64
  verified: config.verified,
65
+ client: config.client,
58
66
  };
59
67
  }
60
68
 
@@ -32,7 +32,9 @@ export async function registerClient(
32
32
  ): Promise<OAuthClient> {
33
33
  if (server.registrationEndpoint === null)
34
34
  throw refused(
35
- 'That server requires OAuth but does not accept client registration, so Metro cannot sign in to it.',
35
+ 'That server signs in with OAuth but registers no clients on its own. ' +
36
+ `Register an app with it yourself, with ${redirectUri} as its redirect URL, ` +
37
+ "and add the connector again with that app's client ID.",
36
38
  );
37
39
  let res: Response;
38
40
  try {
@@ -80,6 +82,8 @@ export function authorizeUrl(input: AuthorizeInput): string {
80
82
  url.searchParams.set('code_challenge', challengeOf(input.verifier));
81
83
  url.searchParams.set('code_challenge_method', 'S256');
82
84
  url.searchParams.set('resource', input.resource);
85
+ if (input.server.scopes.length > 0)
86
+ url.searchParams.set('scope', input.server.scopes.join(' '));
83
87
  return url.toString();
84
88
  }
85
89
 
@@ -150,10 +154,12 @@ export function refreshTokens(
150
154
  client: OAuthClient,
151
155
  refreshToken: string,
152
156
  resource: string,
157
+ scope?: string,
153
158
  ): Promise<OAuthTokens> {
154
159
  return postToken(server, client, {
155
160
  grant_type: 'refresh_token',
156
161
  refresh_token: refreshToken,
157
162
  resource,
163
+ ...(scope === undefined ? {} : { scope }),
158
164
  });
159
165
  }
@@ -9,6 +9,12 @@ export interface OAuthServer {
9
9
  tokenEndpoint: string;
10
10
  registrationEndpoint: string | null;
11
11
  supportsS256: boolean;
12
+ scopes: string[];
13
+ }
14
+
15
+ interface ResourceMetadata {
16
+ issuer: URL;
17
+ scopes: string[];
12
18
  }
13
19
 
14
20
  function refused(message: string): ConnectorVerifyError {
@@ -65,18 +71,33 @@ export async function advertisesOAuth(resource: URL): Promise<boolean> {
65
71
  return false;
66
72
  }
67
73
 
68
- async function authServerFor(resource: URL): Promise<URL> {
74
+ function scopesOf(body: Record<string, unknown> | null): string[] {
75
+ const raw: unknown = body?.scopes_supported;
76
+ if (!Array.isArray(raw)) return [];
77
+ const out: string[] = [];
78
+ for (const entry of raw) {
79
+ const scope = str(entry).trim();
80
+ if (scope !== '' && !/\s/.test(scope) && !out.includes(scope)) out.push(scope);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ async function resourceMetadata(resource: URL): Promise<ResourceMetadata> {
69
86
  for (const candidate of resourceMetadataUrls(resource)) {
70
87
  const body = await getJson(candidate);
71
88
  const servers: unknown = body?.authorization_servers;
72
89
  const first: unknown = Array.isArray(servers) ? servers[0] : undefined;
73
90
  if (typeof first === 'string' && first !== '')
74
- return parseConnectorUrl(first);
91
+ return { issuer: parseConnectorUrl(first), scopes: scopesOf(body) };
75
92
  }
76
- return parseConnectorUrl(resource.origin);
93
+ return { issuer: parseConnectorUrl(resource.origin), scopes: [] };
77
94
  }
78
95
 
79
- function toServer(body: Record<string, unknown>, issuer: URL): OAuthServer {
96
+ function toServer(
97
+ body: Record<string, unknown>,
98
+ issuer: URL,
99
+ scopes: string[],
100
+ ): OAuthServer {
80
101
  const authorizationEndpoint = str(body.authorization_endpoint);
81
102
  const tokenEndpoint = str(body.token_endpoint);
82
103
  if (authorizationEndpoint === '' || tokenEndpoint === '')
@@ -91,6 +112,7 @@ function toServer(body: Record<string, unknown>, issuer: URL): OAuthServer {
91
112
  tokenEndpoint,
92
113
  registrationEndpoint: registration === '' ? null : registration,
93
114
  supportsS256: Array.isArray(methods) ? methods.includes('S256') : false,
115
+ scopes,
94
116
  };
95
117
  }
96
118
 
@@ -106,10 +128,10 @@ export function authServerMetadataUrls(issuer: URL): URL[] {
106
128
  }
107
129
 
108
130
  export async function discoverOAuth(resource: URL): Promise<OAuthServer> {
109
- const issuer = await authServerFor(resource);
131
+ const { issuer, scopes } = await resourceMetadata(resource);
110
132
  for (const candidate of authServerMetadataUrls(issuer)) {
111
133
  const body = await getJson(candidate);
112
- if (body !== null) return toServer(body, issuer);
134
+ if (body !== null) return toServer(body, issuer, scopes);
113
135
  }
114
136
  throw refused(
115
137
  `${resource.hostname} needs authorization, but publishes no OAuth metadata Metro can use.`,
@@ -4,12 +4,14 @@ import { bodyField, readJsonBody, sendJson, type ApiSession } from '@metro-labs/
4
4
  import {
5
5
  beginOAuth,
6
6
  completeOAuth,
7
+ prepareOAuth,
7
8
  takePending,
8
9
  type PendingAuth,
9
10
  } from './oauth.js';
11
+ import { connectorClient } from './config.js';
10
12
  import { parseConnectorUrl } from './verify.js';
11
13
  import type { OAuthAuth } from './verify.js';
12
- import type { Connector } from './model.js';
14
+ import type { Connector, PendingConnectorInput } from './model.js';
13
15
 
14
16
  const asText = (value: unknown): string =>
15
17
  typeof value === 'string' ? value : '';
@@ -26,7 +28,7 @@ export interface OAuthRouteDeps {
26
28
  createPendingConnector: (
27
29
  subject: string,
28
30
  project: string,
29
- input: { name: unknown; url: unknown },
31
+ input: PendingConnectorInput,
30
32
  ) => Promise<Connector>;
31
33
  reconnectConnector: (
32
34
  subject: string,
@@ -46,15 +48,25 @@ export async function startOAuth(
46
48
  payload: (row: Connector) => Record<string, unknown>,
47
49
  ): Promise<void> {
48
50
  const url = parseConnectorUrl(bodyField(body, 'url'));
51
+ const returnTo = asText(bodyField(body, 'returnTo'));
52
+ const clientId = bodyField(body, 'clientId');
53
+ const clientSecret = bodyField(body, 'clientSecret');
54
+ const prepared = await prepareOAuth({
55
+ url,
56
+ client: connectorClient(clientId, clientSecret),
57
+ returnTo,
58
+ });
49
59
  const row = await deps.createPendingConnector(session.subject, project, {
50
60
  name: bodyField(body, 'name'),
51
61
  url: bodyField(body, 'url'),
62
+ clientId,
63
+ clientSecret,
52
64
  });
53
- const authorize = await beginOAuth({
65
+ const authorize = beginOAuth(prepared, {
54
66
  subject: session.subject,
55
67
  name: row.name,
56
68
  url,
57
- returnTo: asText(bodyField(body, 'returnTo')),
69
+ returnTo,
58
70
  connectorId: row.id,
59
71
  });
60
72
  log.info(
@@ -74,11 +86,13 @@ export async function handleConnect(
74
86
  const row = await deps.getConnector(session.subject, id);
75
87
  const url = parseConnectorUrl(row.url);
76
88
  const body = await readJsonBody(req);
77
- const authorize = await beginOAuth({
89
+ const returnTo = asText(bodyField(body, 'returnTo'));
90
+ const prepared = await prepareOAuth({ url, client: row.client, returnTo });
91
+ const authorize = beginOAuth(prepared, {
78
92
  subject: session.subject,
79
93
  name: row.name,
80
94
  url,
81
- returnTo: asText(bodyField(body, 'returnTo')),
95
+ returnTo,
82
96
  connectorId: row.id,
83
97
  });
84
98
  log.info(
@@ -11,8 +11,9 @@ import {
11
11
  newVerifier,
12
12
  refreshTokens,
13
13
  registerClient,
14
+ type OAuthClient,
14
15
  } from './oauth-client.js';
15
- import { discoverOAuth } from './oauth-discovery.js';
16
+ import { discoverOAuth, type OAuthServer } from './oauth-discovery.js';
16
17
  import { startPending, takePending, type PendingAuth } from './oauth-pending.js';
17
18
 
18
19
  export { takePending, type PendingAuth };
@@ -30,20 +31,37 @@ function refused(message: string): ConnectorVerifyError {
30
31
 
31
32
  const resourceOf = (url: URL): string => url.toString();
32
33
 
33
- export interface BeginInput {
34
- connectorId: string;
35
- subject: string;
36
- name: string;
34
+ export interface PrepareInput {
37
35
  url: URL;
36
+ client: OAuthClient | null;
38
37
  returnTo: string;
39
38
  }
40
39
 
41
- export async function beginOAuth(input: BeginInput): Promise<string> {
40
+ export interface PreparedOAuth {
41
+ server: OAuthServer;
42
+ client: OAuthClient;
43
+ redirectUri: string;
44
+ }
45
+
46
+ export async function prepareOAuth(input: PrepareInput): Promise<PreparedOAuth> {
42
47
  if (!validateReturnTo(input.returnTo))
43
48
  throw refused('that return address is not one Metro will send you back to');
44
49
  const server = await discoverOAuth(input.url);
45
50
  const redirectUri = callbackUri();
46
- const client = await registerClient(server, redirectUri);
51
+ const client = input.client ?? (await registerClient(server, redirectUri));
52
+ return { server, client, redirectUri };
53
+ }
54
+
55
+ export interface BeginInput {
56
+ connectorId: string;
57
+ subject: string;
58
+ name: string;
59
+ url: URL;
60
+ returnTo: string;
61
+ }
62
+
63
+ export function beginOAuth(prepared: PreparedOAuth, input: BeginInput): string {
64
+ const { server, client, redirectUri } = prepared;
47
65
  const verifier = newVerifier();
48
66
  const resource = resourceOf(input.url);
49
67
  const state = startPending({
@@ -76,6 +94,9 @@ function authOf(pendingAuth: PendingAuth, tokens: OAuthTokens): OAuthAuth {
76
94
  ...(pendingAuth.client.clientSecret === undefined
77
95
  ? {}
78
96
  : { clientSecret: pendingAuth.client.clientSecret }),
97
+ ...(pendingAuth.server.scopes.length === 0
98
+ ? {}
99
+ : { scope: pendingAuth.server.scopes.join(' ') }),
79
100
  ...tokens,
80
101
  };
81
102
  }
@@ -109,6 +130,7 @@ export async function refreshOAuth(
109
130
  tokenEndpoint: auth.tokenEndpoint,
110
131
  registrationEndpoint: null,
111
132
  supportsS256: true,
133
+ scopes: [],
112
134
  };
113
135
  const client = {
114
136
  clientId: auth.clientId,
@@ -116,7 +138,13 @@ export async function refreshOAuth(
116
138
  ? {}
117
139
  : { clientSecret: auth.clientSecret }),
118
140
  };
119
- const tokens = await refreshTokens(server, client, auth.refreshToken, resource);
141
+ const tokens = await refreshTokens(
142
+ server,
143
+ client,
144
+ auth.refreshToken,
145
+ resource,
146
+ auth.scope,
147
+ );
120
148
  return {
121
149
  ...auth,
122
150
  ...tokens,
@@ -14,6 +14,7 @@ import { advertisesOAuth } from './oauth-discovery.js';
14
14
  import {
15
15
  ConnectorError,
16
16
  connectorAuth,
17
+ connectorClient,
17
18
  connectorName,
18
19
  readConfig,
19
20
  stamp,
@@ -32,6 +33,7 @@ import {
32
33
  type ConnectorCheck,
33
34
  type ConnectorInput,
34
35
  type DeletedConnector,
36
+ type PendingConnectorInput,
35
37
  } from './model.js';
36
38
  import { agentsDir } from '../agents/files.js';
37
39
  import { assertLocalOwner, LOCAL_PROJECT_ID } from '../agents/file-admin.js';
@@ -155,26 +157,29 @@ export async function localCreateConnector(
155
157
  const name = connectorName(input.name);
156
158
  const url = parseConnectorUrl(input.url);
157
159
  const auth = connectorAuth(input.header, input.value);
160
+ const client = connectorClient(input.clientId, input.clientSecret);
158
161
  const verified = stamp(await verifyRemoteMcp(url, auth));
159
162
  return insert(dir, name, url, {
160
163
  auth,
161
164
  createdAt: new Date().toISOString(),
162
165
  verified,
163
166
  oauth: await oauthCapable(url, auth),
167
+ client,
164
168
  });
165
169
  }
166
170
 
167
171
  export async function localCreatePendingConnector(
168
172
  subject: string,
169
173
  project: string,
170
- input: { name: unknown; url: unknown },
174
+ input: PendingConnectorInput,
171
175
  dir = agentsDir(),
172
176
  ): Promise<Connector> {
173
177
  ownedRows(subject, project, dir);
174
178
  const name = connectorName(input.name);
175
179
  const url = parseConnectorUrl(input.url);
180
+ const client = connectorClient(input.clientId, input.clientSecret);
176
181
  return Promise.resolve(
177
- insert(dir, name, url, { auth: { kind: 'none' }, createdAt: new Date().toISOString(), verified: UNVERIFIED, oauth: true }),
182
+ insert(dir, name, url, { auth: { kind: 'none' }, createdAt: new Date().toISOString(), verified: UNVERIFIED, oauth: true, client }),
178
183
  );
179
184
  }
180
185
 
@@ -27,6 +27,7 @@ export interface OAuthAuth extends OAuthTokens {
27
27
  tokenEndpoint: string;
28
28
  clientId: string;
29
29
  clientSecret?: string;
30
+ scope?: string;
30
31
  }
31
32
 
32
33
  export type ConnectorAuth =
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.110"
2
+ "version": "0.1.0-beta.112"
3
3
  }