@stage-labs/metro 0.1.0-beta.302 → 0.1.0-beta.304
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/marketplace/plugin/.claude-plugin/plugin.json +2 -2
- package/runtime/marketplace/plugin/METRO.md +2 -0
- package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +20 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -8
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
- package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +12 -37
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +27 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { bodyField, readJsonBody, sendJson, stringOf } from '@metro-labs/http/api-http';
|
|
4
|
-
import {
|
|
5
|
-
beginOAuth,
|
|
6
|
-
completeOAuth,
|
|
7
|
-
prepareOAuth,
|
|
8
|
-
takePending,
|
|
9
|
-
type PendingAuth,
|
|
10
|
-
} from './oauth.js';
|
|
11
|
-
import { connectorClient, connectorName } from '@metro-labs/http/connector-input';
|
|
12
|
-
import { parseConnectorUrl } from '@metro-labs/http/connector-url';
|
|
13
|
-
import type { OAuthAuth } from '@metro-labs/http/connector-mcp';
|
|
14
|
-
import type { Connector, PendingConnectorInput } from './store.js';
|
|
15
|
-
import { agentsDir } from '../agents/files.js';
|
|
16
|
-
import { withLocalMigrationOperation } from './migration-guard.js';
|
|
17
|
-
import { migrationRecord } from './migration-state.js';
|
|
18
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
19
|
-
|
|
20
|
-
export function hostOf(url: string): string {
|
|
21
|
-
try {
|
|
22
|
-
return new URL(url).hostname;
|
|
23
|
-
} catch {
|
|
24
|
-
return '';
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface OAuthRouteDeps {
|
|
29
|
-
migrationDir?: string;
|
|
30
|
-
createPendingConnector: (input: PendingConnectorInput) => Promise<Connector>;
|
|
31
|
-
reconnectConnector: (id: string, auth: OAuthAuth) => Promise<Connector>;
|
|
32
|
-
getConnector: (id: string) => Promise<Connector>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function startOAuth(
|
|
36
|
-
req: IncomingMessage,
|
|
37
|
-
res: ServerResponse,
|
|
38
|
-
deps: OAuthRouteDeps,
|
|
39
|
-
body: unknown,
|
|
40
|
-
payload: (row: Connector) => Record<string, unknown>,
|
|
41
|
-
): Promise<void> {
|
|
42
|
-
const name = connectorName(bodyField(body, 'name'));
|
|
43
|
-
await withLocalMigrationOperation('', deps.migrationDir ?? agentsDir(), async (operation) => {
|
|
44
|
-
const url = parseConnectorUrl(bodyField(body, 'url'));
|
|
45
|
-
const returnTo = stringOf(bodyField(body, 'returnTo'));
|
|
46
|
-
const clientId = bodyField(body, 'clientId');
|
|
47
|
-
const clientSecret = bodyField(body, 'clientSecret');
|
|
48
|
-
const prepared = await prepareOAuth({
|
|
49
|
-
url,
|
|
50
|
-
client: connectorClient(clientId, clientSecret),
|
|
51
|
-
returnTo,
|
|
52
|
-
}, operation.fetch);
|
|
53
|
-
const row = await deps.createPendingConnector({
|
|
54
|
-
name,
|
|
55
|
-
url: bodyField(body, 'url'),
|
|
56
|
-
clientId,
|
|
57
|
-
clientSecret,
|
|
58
|
-
});
|
|
59
|
-
operation.check();
|
|
60
|
-
const authorize = beginOAuth(prepared, {
|
|
61
|
-
name: row.name,
|
|
62
|
-
url,
|
|
63
|
-
returnTo,
|
|
64
|
-
connectorId: row.id,
|
|
65
|
-
migrationId: migrationRecord(row.id, deps.migrationDir)?.id ?? null,
|
|
66
|
-
});
|
|
67
|
-
log.info(
|
|
68
|
-
{ id: row.id, host: url.hostname },
|
|
69
|
-
'connector-api: stored unconnected, sending the user to sign in',
|
|
70
|
-
);
|
|
71
|
-
sendJson(req, res, 201, { ...payload(row), authorizeUrl: authorize });
|
|
72
|
-
}, true);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function handleConnect(
|
|
76
|
-
req: IncomingMessage,
|
|
77
|
-
res: ServerResponse,
|
|
78
|
-
deps: OAuthRouteDeps,
|
|
79
|
-
id: string,
|
|
80
|
-
): Promise<void> {
|
|
81
|
-
await withLocalMigrationOperation(id, deps.migrationDir ?? agentsDir(), async (operation) => {
|
|
82
|
-
const row = await deps.getConnector(id);
|
|
83
|
-
const url = parseConnectorUrl(row.url);
|
|
84
|
-
const body = await readJsonBody(req);
|
|
85
|
-
const returnTo = stringOf(bodyField(body, 'returnTo'));
|
|
86
|
-
const prepared = await prepareOAuth({ url, client: row.client, returnTo }, operation.fetch);
|
|
87
|
-
operation.check();
|
|
88
|
-
const authorize = beginOAuth(prepared, {
|
|
89
|
-
name: row.name,
|
|
90
|
-
url,
|
|
91
|
-
returnTo,
|
|
92
|
-
connectorId: row.id,
|
|
93
|
-
migrationId: migrationRecord(row.id, deps.migrationDir)?.id ?? null,
|
|
94
|
-
});
|
|
95
|
-
log.info(
|
|
96
|
-
{ id: row.id, host: url.hostname },
|
|
97
|
-
'connector-api: sending the user back to sign in',
|
|
98
|
-
);
|
|
99
|
-
sendJson(req, res, 202, { status: 'oauth', authorizeUrl: authorize });
|
|
100
|
-
}, true);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function landing(entry: PendingAuth, id?: string): string {
|
|
104
|
-
const target = id ?? entry.connectorId;
|
|
105
|
-
return target === undefined ? '#/connectors' : `#/connector/${target}`;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function backTo(entry: PendingAuth, error?: string, id?: string): string {
|
|
109
|
-
const suffix =
|
|
110
|
-
error === undefined
|
|
111
|
-
? ''
|
|
112
|
-
: `?connector_error=${encodeURIComponent(error)}`;
|
|
113
|
-
return `${entry.returnTo}${suffix}${landing(entry, id)}`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function redirect(res: ServerResponse, location: string): void {
|
|
117
|
-
res.writeHead(302, { location, 'cache-control': 'no-store' }).end();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function saveOAuth(
|
|
121
|
-
deps: OAuthRouteDeps,
|
|
122
|
-
entry: PendingAuth,
|
|
123
|
-
auth: OAuthAuth,
|
|
124
|
-
): Promise<Connector> {
|
|
125
|
-
return deps.reconnectConnector(entry.connectorId, auth);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function checkGeneration(entry: PendingAuth, dir: string | undefined): void {
|
|
129
|
-
if ((entry.migrationId ?? null) !== (migrationRecord(entry.connectorId, dir)?.id ?? null)) throw new ApiError('This sign-in predates the connector migration. Start a fresh sign-in.', 409);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async function settleCallback(
|
|
133
|
-
res: ServerResponse,
|
|
134
|
-
deps: OAuthRouteDeps,
|
|
135
|
-
entry: PendingAuth,
|
|
136
|
-
code: string,
|
|
137
|
-
): Promise<void> {
|
|
138
|
-
try {
|
|
139
|
-
const saved = await withLocalMigrationOperation(entry.connectorId, deps.migrationDir ?? agentsDir(), async (operation) => {
|
|
140
|
-
checkGeneration(entry, deps.migrationDir);
|
|
141
|
-
const auth = await completeOAuth(entry, code, operation.fetch);
|
|
142
|
-
operation.check();
|
|
143
|
-
checkGeneration(entry, deps.migrationDir);
|
|
144
|
-
return saveOAuth(deps, entry, auth);
|
|
145
|
-
}, true);
|
|
146
|
-
log.info(
|
|
147
|
-
{ id: saved.id, name: saved.name, host: hostOf(saved.url) },
|
|
148
|
-
'connector-api: oauth sign-in completed',
|
|
149
|
-
);
|
|
150
|
-
redirect(res, backTo(entry, undefined, saved.id));
|
|
151
|
-
} catch (err) {
|
|
152
|
-
log.warn({ err: errMsg(err) }, 'connector-api: oauth sign-in failed');
|
|
153
|
-
redirect(res, backTo(entry, errMsg(err)));
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function handleCallback(
|
|
158
|
-
req: IncomingMessage,
|
|
159
|
-
res: ServerResponse,
|
|
160
|
-
deps: OAuthRouteDeps,
|
|
161
|
-
): void {
|
|
162
|
-
const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
163
|
-
const entry = takePending(query.get('state') ?? '');
|
|
164
|
-
if (entry === undefined) {
|
|
165
|
-
sendJson(req, res, 400, { error: 'that sign-in has expired — start it again' });
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const denied = query.get('error');
|
|
169
|
-
const code = query.get('code') ?? '';
|
|
170
|
-
if (denied !== null || code === '') {
|
|
171
|
-
redirect(res, backTo(entry, denied ?? 'no authorization code came back'));
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
settleCallback(res, deps, entry, code).catch((err: unknown) => {
|
|
175
|
-
log.warn({ err: errMsg(err) }, 'connector-api: oauth callback failed');
|
|
176
|
-
if (!res.headersSent) redirect(res, backTo(entry, 'sign-in failed'));
|
|
177
|
-
});
|
|
178
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { publicBaseOrDefault } from '../files/attach-serve.js';
|
|
2
|
-
import { refused } from '@metro-labs/http/connector-url';
|
|
3
|
-
import type { OAuthAuth, OAuthTokens } from '@metro-labs/http/connector-mcp';
|
|
4
|
-
import { validateReturnTo } from '@metro-labs/http/return-to';
|
|
5
|
-
import {
|
|
6
|
-
authorizeUrl,
|
|
7
|
-
exchangeCode,
|
|
8
|
-
newVerifier,
|
|
9
|
-
refreshTokens,
|
|
10
|
-
registerClient,
|
|
11
|
-
type OAuthClient,
|
|
12
|
-
} from '@metro-labs/http/connector-oauth';
|
|
13
|
-
import { discoverOAuth, type OAuthServer } from '@metro-labs/http/connector-discovery';
|
|
14
|
-
import { ticketStore } from '@metro-labs/core/tickets';
|
|
15
|
-
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
16
|
-
|
|
17
|
-
export interface PendingAuth {
|
|
18
|
-
name: string;
|
|
19
|
-
url: string;
|
|
20
|
-
resource: string;
|
|
21
|
-
returnTo: string;
|
|
22
|
-
verifier: string;
|
|
23
|
-
server: OAuthServer;
|
|
24
|
-
client: OAuthClient;
|
|
25
|
-
connectorId: string;
|
|
26
|
-
migrationId?: string | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const pending = ticketStore<PendingAuth>(10 * 60_000, 100);
|
|
30
|
-
|
|
31
|
-
export const startPending = (entry: PendingAuth, now = Date.now()): string => pending.mint(entry, now).ticket;
|
|
32
|
-
|
|
33
|
-
export const takePending = (state: string, now = Date.now()): PendingAuth | undefined => pending.take(state, now);
|
|
34
|
-
|
|
35
|
-
export const pendingCount = (): number => pending.size();
|
|
36
|
-
|
|
37
|
-
const REFRESH_SKEW_MS = 300_000;
|
|
38
|
-
|
|
39
|
-
const CALLBACK_PATH = '/api/connectors/callback';
|
|
40
|
-
|
|
41
|
-
const callbackUri = (): string =>
|
|
42
|
-
`${publicBaseOrDefault()}${CALLBACK_PATH}`;
|
|
43
|
-
|
|
44
|
-
const resourceOf = (url: URL): string => url.toString();
|
|
45
|
-
|
|
46
|
-
export interface PrepareInput {
|
|
47
|
-
url: URL;
|
|
48
|
-
client: OAuthClient | null;
|
|
49
|
-
returnTo: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface PreparedOAuth {
|
|
53
|
-
server: OAuthServer;
|
|
54
|
-
client: OAuthClient;
|
|
55
|
-
redirectUri: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function prepareOAuth(input: PrepareInput, send: FetchLike = fetch): Promise<PreparedOAuth> {
|
|
59
|
-
if (!validateReturnTo(input.returnTo))
|
|
60
|
-
throw refused('that return address is not one Metro will send you back to');
|
|
61
|
-
const server = await discoverOAuth(input.url, send);
|
|
62
|
-
const redirectUri = callbackUri();
|
|
63
|
-
const client = input.client ?? (await registerClient(server, redirectUri, send));
|
|
64
|
-
return { server, client, redirectUri };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface BeginInput {
|
|
68
|
-
connectorId: string;
|
|
69
|
-
migrationId?: string | null;
|
|
70
|
-
name: string;
|
|
71
|
-
url: URL;
|
|
72
|
-
returnTo: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function beginOAuth(prepared: PreparedOAuth, input: BeginInput): string {
|
|
76
|
-
const { server, client, redirectUri } = prepared;
|
|
77
|
-
const verifier = newVerifier();
|
|
78
|
-
const resource = resourceOf(input.url);
|
|
79
|
-
const state = startPending({
|
|
80
|
-
name: input.name,
|
|
81
|
-
url: input.url.toString(),
|
|
82
|
-
resource,
|
|
83
|
-
returnTo: input.returnTo,
|
|
84
|
-
verifier,
|
|
85
|
-
server,
|
|
86
|
-
client,
|
|
87
|
-
connectorId: input.connectorId,
|
|
88
|
-
migrationId: input.migrationId ?? null,
|
|
89
|
-
});
|
|
90
|
-
return authorizeUrl({
|
|
91
|
-
server,
|
|
92
|
-
client,
|
|
93
|
-
redirectUri,
|
|
94
|
-
state,
|
|
95
|
-
verifier,
|
|
96
|
-
resource,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function authOf(pendingAuth: PendingAuth, tokens: OAuthTokens): OAuthAuth {
|
|
101
|
-
return {
|
|
102
|
-
kind: 'oauth',
|
|
103
|
-
issuer: pendingAuth.server.issuer,
|
|
104
|
-
tokenEndpoint: pendingAuth.server.tokenEndpoint,
|
|
105
|
-
resource: pendingAuth.resource,
|
|
106
|
-
clientId: pendingAuth.client.clientId,
|
|
107
|
-
...(pendingAuth.client.clientSecret === undefined
|
|
108
|
-
? {}
|
|
109
|
-
: { clientSecret: pendingAuth.client.clientSecret }),
|
|
110
|
-
...(pendingAuth.server.scopes.length === 0
|
|
111
|
-
? {}
|
|
112
|
-
: { scope: pendingAuth.server.scopes.join(' ') }),
|
|
113
|
-
...tokens,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function completeOAuth(
|
|
118
|
-
entry: PendingAuth,
|
|
119
|
-
code: string,
|
|
120
|
-
send: FetchLike = fetch,
|
|
121
|
-
): Promise<OAuthAuth> {
|
|
122
|
-
const tokens = await exchangeCode(entry.server, entry.client, {
|
|
123
|
-
code,
|
|
124
|
-
redirectUri: callbackUri(),
|
|
125
|
-
verifier: entry.verifier,
|
|
126
|
-
resource: entry.resource,
|
|
127
|
-
}, send);
|
|
128
|
-
return authOf(entry, tokens);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function oauthExpired(auth: OAuthAuth, now = Date.now()): boolean {
|
|
132
|
-
return auth.expiresAt !== undefined && auth.expiresAt - REFRESH_SKEW_MS <= now;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export async function refreshOAuth(
|
|
136
|
-
auth: OAuthAuth,
|
|
137
|
-
resource: string,
|
|
138
|
-
send: FetchLike = fetch,
|
|
139
|
-
): Promise<OAuthAuth> {
|
|
140
|
-
if (auth.refreshToken === undefined)
|
|
141
|
-
throw refused('that connector needs signing in again');
|
|
142
|
-
const server = {
|
|
143
|
-
issuer: auth.issuer,
|
|
144
|
-
authorizationEndpoint: '',
|
|
145
|
-
tokenEndpoint: auth.tokenEndpoint,
|
|
146
|
-
registrationEndpoint: null,
|
|
147
|
-
supportsS256: true,
|
|
148
|
-
scopes: [],
|
|
149
|
-
};
|
|
150
|
-
const client = {
|
|
151
|
-
clientId: auth.clientId,
|
|
152
|
-
...(auth.clientSecret === undefined
|
|
153
|
-
? {}
|
|
154
|
-
: { clientSecret: auth.clientSecret }),
|
|
155
|
-
};
|
|
156
|
-
const tokens = await refreshTokens(
|
|
157
|
-
server,
|
|
158
|
-
client,
|
|
159
|
-
auth.refreshToken,
|
|
160
|
-
auth.resource ?? resource,
|
|
161
|
-
auth.scope,
|
|
162
|
-
send,
|
|
163
|
-
);
|
|
164
|
-
return {
|
|
165
|
-
...auth,
|
|
166
|
-
...tokens,
|
|
167
|
-
...(tokens.refreshToken === undefined
|
|
168
|
-
? { refreshToken: auth.refreshToken }
|
|
169
|
-
: {}),
|
|
170
|
-
};
|
|
171
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { JSONRPCNotificationSchema, ServerNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
-
import type { MigrationOperation } from './migration-operations.js';
|
|
5
|
-
|
|
6
|
-
export const isRelaySse = (contentType: string | null | undefined): boolean => /^text\/event-stream\s*(?:;|$)/i.test(contentType ?? '');
|
|
7
|
-
const MAX_FRAME = 64 * 1024;
|
|
8
|
-
const NOTIFICATIONS = new Set(['notifications/tools/list_changed', 'notifications/resources/list_changed', 'notifications/prompts/list_changed', 'notifications/resources/updated', 'notifications/message']);
|
|
9
|
-
|
|
10
|
-
export class PassiveSseListener {
|
|
11
|
-
forwarding = false;
|
|
12
|
-
private pending: Buffer[] = [];
|
|
13
|
-
private length = 0;
|
|
14
|
-
private data: string[] = [];
|
|
15
|
-
private fields = false;
|
|
16
|
-
private size = 0;
|
|
17
|
-
private unsafe = false;
|
|
18
|
-
private skipLf = false;
|
|
19
|
-
private first = true;
|
|
20
|
-
private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
21
|
-
|
|
22
|
-
idle(): boolean { return !this.unsafe && this.length === 0 && !this.fields && !this.forwarding; }
|
|
23
|
-
keepaliveAllowed(): boolean { return this.unsafe || this.idle(); }
|
|
24
|
-
|
|
25
|
-
observe(chunk: Uint8Array): void {
|
|
26
|
-
if (this.unsafe) return;
|
|
27
|
-
this.scan(chunk);
|
|
28
|
-
if (this.unsafe) { this.pending = []; this.length = 0; this.data = []; }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
private append(bytes: Uint8Array): void {
|
|
32
|
-
this.length += bytes.length;
|
|
33
|
-
if (this.length + this.size > MAX_FRAME) this.unsafe = true;
|
|
34
|
-
else if (bytes.length > 0) this.pending.push(Buffer.from(bytes));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
private scan(bytes: Uint8Array): void {
|
|
38
|
-
let start = 0;
|
|
39
|
-
for (let at = 0; at < bytes.length; at += 1) {
|
|
40
|
-
if (at - start + this.length + this.size > MAX_FRAME) { this.unsafe = true; return; }
|
|
41
|
-
const byte = bytes[at];
|
|
42
|
-
if (this.skipLf && byte === 10) { this.skipLf = false; start = at + 1; continue; }
|
|
43
|
-
this.skipLf = false;
|
|
44
|
-
if (byte !== 10 && byte !== 13) continue;
|
|
45
|
-
this.append(bytes.subarray(start, at));
|
|
46
|
-
try { this.line(this.decoder.decode(Buffer.concat(this.pending, this.length)), this.length); }
|
|
47
|
-
catch { this.unsafe = true; }
|
|
48
|
-
this.pending = []; this.length = 0;
|
|
49
|
-
this.skipLf = byte === 13;
|
|
50
|
-
start = at + 1;
|
|
51
|
-
if (this.unsafe) return;
|
|
52
|
-
}
|
|
53
|
-
this.append(bytes.subarray(start));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
private line(raw: string, length: number): void {
|
|
57
|
-
const line = this.first && raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
58
|
-
this.first = false;
|
|
59
|
-
if (line === '') {
|
|
60
|
-
if (this.data.length > 0 && !this.notification()) this.unsafe = true;
|
|
61
|
-
this.data = []; this.fields = false; this.size = 0;
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (line.startsWith(':')) return;
|
|
65
|
-
this.size += length;
|
|
66
|
-
if (this.size > MAX_FRAME) { this.unsafe = true; return; }
|
|
67
|
-
this.fields = true;
|
|
68
|
-
const colon = line.indexOf(':');
|
|
69
|
-
const field = colon < 0 ? line : line.slice(0, colon);
|
|
70
|
-
const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
|
|
71
|
-
this.field(field, value);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private field(field: string, value: string): void {
|
|
75
|
-
if (field === 'data') this.data.push(value);
|
|
76
|
-
else if (field === 'event') { if (value !== '' && value !== 'message') this.unsafe = true; }
|
|
77
|
-
else if (field === 'retry') { if (!/^\d+$/.test(value)) this.unsafe = true; }
|
|
78
|
-
else this.unsafe = true;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
private notification(): boolean {
|
|
82
|
-
const text = this.data.join('\n');
|
|
83
|
-
if (text === '') return true;
|
|
84
|
-
const value: unknown = JSON.parse(text);
|
|
85
|
-
return isRecord(value) && typeof value.method === 'string' && NOTIFICATIONS.has(value.method)
|
|
86
|
-
&& JSONRPCNotificationSchema.safeParse(value).success && ServerNotificationSchema.safeParse(value).success;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function passiveRelayListener(req: IncomingMessage, res: ServerResponse, upstream: Response, operation: MigrationOperation, stop: () => void): PassiveSseListener | undefined {
|
|
91
|
-
if (req.method !== 'GET' || req.headers['last-event-id'] !== undefined || req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) !== 0) return undefined;
|
|
92
|
-
if (upstream.status !== 200 || upstream.body === null || !isRelaySse(upstream.headers.get('content-type'))) return undefined;
|
|
93
|
-
const listener = new PassiveSseListener();
|
|
94
|
-
operation.passiveListener({ idle: () => listener.idle() && res.writableLength === 0 && !res.destroyed, stop });
|
|
95
|
-
return listener;
|
|
96
|
-
}
|