crewly 1.20.265 → 1.20.267
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/config/skills/_common/lib.sh +15 -2
- package/config/skills/agent/core/app-data/SKILL.md +13 -1
- package/config/skills/agent/core/app-data/execute.sh +41 -5
- package/config/skills/agent/core/app-data/execute.test.sh +47 -1
- package/config/skills/agent/core/publish-app/SKILL.md +1 -1
- package/config/skills/agent/core/slack-file/SKILL.md +53 -0
- package/config/skills/agent/core/slack-file/execute.sh +72 -0
- package/config/skills/agent/core/slack-file/execute.test.sh +34 -0
- package/dist/backend/backend/src/constants.d.ts +59 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +59 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +7 -0
- package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.controller.js +23 -0
- package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +9 -0
- package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.routes.js +19 -1
- package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/connector/connector.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/connector/connector.routes.js +6 -1
- package/dist/backend/backend/src/controllers/connector/connector.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts +69 -0
- package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js +203 -0
- package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/connector/remote-mcp.controller.d.ts +16 -0
- package/dist/backend/backend/src/controllers/connector/remote-mcp.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/connector/remote-mcp.controller.js +126 -5
- package/dist/backend/backend/src/controllers/connector/remote-mcp.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.js +37 -0
- package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +5 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +2 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts +2 -0
- package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/apps-cloud.client.js +1 -0
- package/dist/backend/backend/src/services/apps/apps-cloud.client.js.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.service.d.ts +17 -0
- package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.service.js +24 -0
- package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +4 -3
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/connector/remote-mcp-auth-card.d.ts +43 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-auth-card.d.ts.map +1 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-auth-card.js +98 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-auth-card.js.map +1 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-auth.service.d.ts +277 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-auth.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-auth.service.js +719 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-auth.service.js.map +1 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-launch.service.d.ts +29 -5
- package/dist/backend/backend/src/services/connector/remote-mcp-launch.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/connector/remote-mcp-launch.service.js +40 -12
- package/dist/backend/backend/src/services/connector/remote-mcp-launch.service.js.map +1 -1
- package/dist/backend/backend/src/services/connector/remote-mcp-oauth.d.ts +196 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-oauth.d.ts.map +1 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-oauth.js +351 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-oauth.js.map +1 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-probe.service.d.ts +3 -1
- package/dist/backend/backend/src/services/connector/remote-mcp-probe.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/connector/remote-mcp-probe.service.js +8 -0
- package/dist/backend/backend/src/services/connector/remote-mcp-probe.service.js.map +1 -1
- package/dist/backend/backend/src/services/core/credential-files.d.ts.map +1 -1
- package/dist/backend/backend/src/services/core/credential-files.js +2 -0
- package/dist/backend/backend/src/services/core/credential-files.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-file-fetch.service.d.ts +119 -0
- package/dist/backend/backend/src/services/slack/slack-file-fetch.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-file-fetch.service.js +415 -0
- package/dist/backend/backend/src/services/slack/slack-file-fetch.service.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-file-ref.d.ts +69 -0
- package/dist/backend/backend/src/services/slack/slack-file-ref.d.ts.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-file-ref.js +177 -0
- package/dist/backend/backend/src/services/slack/slack-file-ref.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js +16 -0
- package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +15 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +84 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/types/slack.types.d.ts +11 -0
- package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/slack.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +59 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +59 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +2 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/connector/remote-mcp-launch.service.d.ts +29 -5
- package/dist/cli/backend/src/services/connector/remote-mcp-launch.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/connector/remote-mcp-launch.service.js +40 -12
- package/dist/cli/backend/src/services/connector/remote-mcp-launch.service.js.map +1 -1
- package/dist/cli/backend/src/services/core/credential-files.d.ts.map +1 -1
- package/dist/cli/backend/src/services/core/credential-files.js +2 -0
- package/dist/cli/backend/src/services/core/credential-files.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-file-fetch.service.d.ts +119 -0
- package/dist/cli/backend/src/services/slack/slack-file-fetch.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-file-fetch.service.js +415 -0
- package/dist/cli/backend/src/services/slack/slack-file-fetch.service.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-file-ref.d.ts +69 -0
- package/dist/cli/backend/src/services/slack/slack-file-ref.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-file-ref.js +177 -0
- package/dist/cli/backend/src/services/slack/slack-file-ref.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +15 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +84 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/cli/backend/src/types/slack.types.d.ts +11 -0
- package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/slack.types.js.map +1 -1
- package/frontend/dist/assets/{index-83188f0b.js → index-7e5ebf78.js} +111 -111
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth for remote MCP servers — the owner signs in once, from a phone.
|
|
3
|
+
*
|
|
4
|
+
* Flow (owner-away: nothing runs on `localhost` in the owner's browser):
|
|
5
|
+
*
|
|
6
|
+
* 1. A server answers 401 + `WWW-Authenticate: Bearer resource_metadata=…`
|
|
7
|
+
* (on add, on test, or through the agent proxy). Discovery, dynamic
|
|
8
|
+
* client registration and PKCE run here (see remote-mcp-oauth).
|
|
9
|
+
* 2. The authorize URL (minus `state`) goes to the Crewly Cloud broker,
|
|
10
|
+
* which adds `state`, hosts the public redirect URI and returns a
|
|
11
|
+
* single-use ticket link. The PKCE verifier never leaves this machine.
|
|
12
|
+
* 3. The link reaches the owner as a Slack card (throttled), in the API
|
|
13
|
+
* response and on the Connections / portal card.
|
|
14
|
+
* 4. This service polls the broker; when the code arrives it exchanges it
|
|
15
|
+
* with the verifier, stores the tokens and tells the broker (the page on
|
|
16
|
+
* the owner's phone then says "connected") and Slack.
|
|
17
|
+
* 5. {@link RemoteMcpAuthService.getAccessToken} hands the agent proxy a
|
|
18
|
+
* fresh token, refreshing before expiry (single-flight per server).
|
|
19
|
+
*
|
|
20
|
+
* State lives in `<CREWLY_HOME>/remote-mcp-oauth.enc`: AES-256-GCM under
|
|
21
|
+
* the install's master key, mode 0600. Nothing secret is logged.
|
|
22
|
+
*
|
|
23
|
+
* @module services/connector/remote-mcp-auth.service
|
|
24
|
+
*/
|
|
25
|
+
import * as fs from 'fs/promises';
|
|
26
|
+
import * as path from 'path';
|
|
27
|
+
import { randomBytes } from 'crypto';
|
|
28
|
+
import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
|
|
29
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
30
|
+
import { CloudClientService } from '../cloud/cloud-client.service.js';
|
|
31
|
+
import { REMOTE_MCP_CONSTANTS } from '../../constants.js';
|
|
32
|
+
import { decrypt, encrypt, ensureMasterKey } from '../../utils/encryption.utils.js';
|
|
33
|
+
import { RemoteMcpOAuthError, buildAuthorizeUrl, createPkcePair, discoverAuthorization, exchangeCode, parseWwwAuthenticate, refreshTokens, registerClient, } from './remote-mcp-oauth.js';
|
|
34
|
+
import { RemoteMcpService } from './remote-mcp.service.js';
|
|
35
|
+
import { probeRemoteMcp } from './remote-mcp-probe.service.js';
|
|
36
|
+
const C = REMOTE_MCP_CONSTANTS;
|
|
37
|
+
/** Owner-facing failure of an authorization start. */
|
|
38
|
+
export class RemoteMcpAuthError extends Error {
|
|
39
|
+
code;
|
|
40
|
+
constructor(code, message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.name = 'RemoteMcpAuthError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** `{ jsonrpc, id, method }` initialize body used to detect OAuth. */
|
|
47
|
+
const DETECT_BODY = JSON.stringify({
|
|
48
|
+
jsonrpc: '2.0',
|
|
49
|
+
id: 0,
|
|
50
|
+
method: 'initialize',
|
|
51
|
+
params: { protocolVersion: C.PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'crewly', version: '1.0.0' } },
|
|
52
|
+
});
|
|
53
|
+
let notifier = null;
|
|
54
|
+
/**
|
|
55
|
+
* Wire (or clear) the Slack notifier.
|
|
56
|
+
*
|
|
57
|
+
* @param next - Notifier, or null
|
|
58
|
+
*/
|
|
59
|
+
export function setRemoteMcpAuthNotifier(next) {
|
|
60
|
+
notifier = next;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Remote MCP OAuth — see module docs.
|
|
64
|
+
*/
|
|
65
|
+
export class RemoteMcpAuthService {
|
|
66
|
+
static instance = null;
|
|
67
|
+
logger;
|
|
68
|
+
crewlyHome;
|
|
69
|
+
fetchImpl;
|
|
70
|
+
cloud;
|
|
71
|
+
now;
|
|
72
|
+
setTimer;
|
|
73
|
+
clearTimer;
|
|
74
|
+
deps;
|
|
75
|
+
cache = null;
|
|
76
|
+
writeChain = Promise.resolve();
|
|
77
|
+
refreshing = new Map();
|
|
78
|
+
timers = new Map();
|
|
79
|
+
redirectUri = null;
|
|
80
|
+
lastUnauthorized = new Map();
|
|
81
|
+
constructor(deps = {}) {
|
|
82
|
+
this.deps = deps;
|
|
83
|
+
this.logger = LoggerService.getInstance().createComponentLogger('RemoteMcpAuth');
|
|
84
|
+
this.crewlyHome = deps.crewlyHome || getCrewlyHomePath();
|
|
85
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
86
|
+
this.cloud = deps.cloud ?? CloudClientService.getInstance();
|
|
87
|
+
this.now = deps.now ?? Date.now;
|
|
88
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => {
|
|
89
|
+
const t = setTimeout(fn, ms);
|
|
90
|
+
t.unref?.();
|
|
91
|
+
return t;
|
|
92
|
+
});
|
|
93
|
+
this.clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
|
|
94
|
+
}
|
|
95
|
+
static getInstance() {
|
|
96
|
+
if (!RemoteMcpAuthService.instance) {
|
|
97
|
+
RemoteMcpAuthService.instance = new RemoteMcpAuthService({
|
|
98
|
+
getServer: (id) => RemoteMcpService.getInstance().get(id),
|
|
99
|
+
countTools: async (server, headers) => {
|
|
100
|
+
const r = await probeRemoteMcp({ url: server.url, headers: { ...(server.headers ?? {}), ...headers } });
|
|
101
|
+
return r.ok ? r.toolCount : null;
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return RemoteMcpAuthService.instance;
|
|
106
|
+
}
|
|
107
|
+
/** Reset the singleton (tests). */
|
|
108
|
+
static resetInstance() {
|
|
109
|
+
RemoteMcpAuthService.instance?.stop();
|
|
110
|
+
RemoteMcpAuthService.instance = null;
|
|
111
|
+
}
|
|
112
|
+
/** Install an instance (tests / wiring). */
|
|
113
|
+
static setInstance(service) {
|
|
114
|
+
RemoteMcpAuthService.instance = service;
|
|
115
|
+
}
|
|
116
|
+
/** Absolute path of the encrypted store. */
|
|
117
|
+
getFilePath() {
|
|
118
|
+
return path.join(this.crewlyHome, C.OAUTH_STORE_FILE);
|
|
119
|
+
}
|
|
120
|
+
masterKeyPath() {
|
|
121
|
+
return path.join(this.crewlyHome, C.OAUTH_MASTER_KEY_FILE);
|
|
122
|
+
}
|
|
123
|
+
/** Stop every poll timer. */
|
|
124
|
+
stop() {
|
|
125
|
+
for (const h of this.timers.values())
|
|
126
|
+
this.clearTimer(h);
|
|
127
|
+
this.timers.clear();
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------- store
|
|
130
|
+
async load() {
|
|
131
|
+
if (this.cache)
|
|
132
|
+
return this.cache;
|
|
133
|
+
let data = null;
|
|
134
|
+
try {
|
|
135
|
+
const buf = await fs.readFile(this.getFilePath());
|
|
136
|
+
data = JSON.parse(await decrypt(buf, this.masterKeyPath()));
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
if (err.code !== 'ENOENT') {
|
|
140
|
+
// Never the content: only that it could not be read.
|
|
141
|
+
this.logger.error('Remote MCP OAuth store unreadable — starting empty (owners will be asked to sign in again)', {
|
|
142
|
+
error: err instanceof Error ? err.name : 'unknown',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!data || typeof data.instanceKey !== 'string' || !data.servers || typeof data.servers !== 'object') {
|
|
147
|
+
data = { instanceKey: randomBytes(18).toString('base64url'), servers: {} };
|
|
148
|
+
}
|
|
149
|
+
this.cache = data;
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
152
|
+
async mutate(fn) {
|
|
153
|
+
const run = this.writeChain.then(async () => {
|
|
154
|
+
const data = await this.load();
|
|
155
|
+
const result = await fn(data);
|
|
156
|
+
await ensureMasterKey(this.masterKeyPath());
|
|
157
|
+
const enc = await encrypt(JSON.stringify(data), this.masterKeyPath());
|
|
158
|
+
const file = this.getFilePath();
|
|
159
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
160
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
161
|
+
await fs.writeFile(tmp, enc, { mode: 0o600 });
|
|
162
|
+
await fs.chmod(tmp, 0o600);
|
|
163
|
+
await fs.rename(tmp, file);
|
|
164
|
+
return result;
|
|
165
|
+
});
|
|
166
|
+
this.writeChain = run.catch(() => undefined);
|
|
167
|
+
return run;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The record for a server, if it uses OAuth.
|
|
171
|
+
*
|
|
172
|
+
* @param id - Server id
|
|
173
|
+
* @returns A copy of the record, or undefined
|
|
174
|
+
*/
|
|
175
|
+
async getRecord(id) {
|
|
176
|
+
const rec = (await this.load()).servers[id];
|
|
177
|
+
return rec ? JSON.parse(JSON.stringify(rec)) : undefined;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether a server signs in with OAuth.
|
|
181
|
+
*
|
|
182
|
+
* @param id - Server id
|
|
183
|
+
* @returns True when it has an OAuth record
|
|
184
|
+
*/
|
|
185
|
+
async usesOAuth(id) {
|
|
186
|
+
return !!(await this.load()).servers[id];
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The API view of a server's sign-in.
|
|
190
|
+
*
|
|
191
|
+
* @param id - Server id
|
|
192
|
+
* @returns The view, or undefined for a static-key server
|
|
193
|
+
*/
|
|
194
|
+
async view(id) {
|
|
195
|
+
const rec = (await this.load()).servers[id];
|
|
196
|
+
if (!rec)
|
|
197
|
+
return undefined;
|
|
198
|
+
const now = this.now();
|
|
199
|
+
const live = rec.pending && rec.pending.expiresAt > now && !rec.pending.opened ? rec.pending : undefined;
|
|
200
|
+
let asHost;
|
|
201
|
+
try {
|
|
202
|
+
asHost = rec.discovered ? new URL(rec.discovered.metadata.authorization_endpoint).host : undefined;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
asHost = undefined;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
mode: 'oauth',
|
|
209
|
+
status: rec.status,
|
|
210
|
+
...(asHost ? { authorizationServer: asHost } : {}),
|
|
211
|
+
...(rec.tokens?.scope ? { scopes: rec.tokens.scope.split(/[\s,]+/).filter(Boolean) } : {}),
|
|
212
|
+
...(rec.tokens ? { expiresAt: new Date(rec.tokens.expiresAt).toISOString() } : {}),
|
|
213
|
+
...(rec.connectedAt ? { connectedAt: rec.connectedAt } : {}),
|
|
214
|
+
...(rec.lastError ? { error: rec.lastError } : {}),
|
|
215
|
+
...(live ? { authorizeUrl: live.url, authorizeExpiresAt: new Date(live.expiresAt).toISOString() } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Forget a server's sign-in (server removed).
|
|
220
|
+
*
|
|
221
|
+
* @param id - Server id
|
|
222
|
+
*/
|
|
223
|
+
async forget(id) {
|
|
224
|
+
const h = this.timers.get(id);
|
|
225
|
+
if (h)
|
|
226
|
+
this.clearTimer(h);
|
|
227
|
+
this.timers.delete(id);
|
|
228
|
+
if (!(await this.load()).servers[id])
|
|
229
|
+
return;
|
|
230
|
+
await this.mutate((d) => {
|
|
231
|
+
delete d.servers[id];
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Set an owner-entered client (servers without dynamic registration).
|
|
236
|
+
*
|
|
237
|
+
* @param id - Server id
|
|
238
|
+
* @param client - Client id and optional secret
|
|
239
|
+
*/
|
|
240
|
+
async setManualClient(id, client) {
|
|
241
|
+
await this.mutate((d) => {
|
|
242
|
+
const rec = d.servers[id] ?? { status: 'needs_auth' };
|
|
243
|
+
rec.manualClient = true;
|
|
244
|
+
// The redirect URI is filled in when the authorization starts.
|
|
245
|
+
rec.client = {
|
|
246
|
+
clientId: client.clientId,
|
|
247
|
+
...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
|
|
248
|
+
tokenAuthMethod: client.clientSecret ? 'client_secret_post' : 'none',
|
|
249
|
+
redirectUri: '',
|
|
250
|
+
};
|
|
251
|
+
d.servers[id] = rec;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// ------------------------------------------------------------ detection
|
|
255
|
+
/**
|
|
256
|
+
* Ask the server (no credentials beyond its static headers) whether it
|
|
257
|
+
* wants OAuth.
|
|
258
|
+
*
|
|
259
|
+
* @param server - Stored server
|
|
260
|
+
* @returns The Bearer challenge when it answered 401, else null
|
|
261
|
+
*/
|
|
262
|
+
async detect(server) {
|
|
263
|
+
try {
|
|
264
|
+
const res = await this.fetchImpl(server.url, {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: { ...(server.headers ?? {}), 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
|
267
|
+
body: DETECT_BODY,
|
|
268
|
+
signal: AbortSignal.timeout(C.TEST_TIMEOUT_MS),
|
|
269
|
+
});
|
|
270
|
+
if (res.status !== 401)
|
|
271
|
+
return null;
|
|
272
|
+
return parseWwwAuthenticate(res.headers.get('www-authenticate')) ?? {};
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Record that a server needs OAuth and run discovery.
|
|
280
|
+
*
|
|
281
|
+
* @param server - Stored server
|
|
282
|
+
* @param challenge - What its 401 said
|
|
283
|
+
* @returns The record
|
|
284
|
+
* @throws RemoteMcpAuthError oauth (discovery failed)
|
|
285
|
+
*/
|
|
286
|
+
async markOAuth(server, challenge) {
|
|
287
|
+
let discovered;
|
|
288
|
+
try {
|
|
289
|
+
discovered = await discoverAuthorization(server.url, challenge, this.fetchImpl);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
const message = err instanceof RemoteMcpOAuthError ? err.message : 'Could not discover how to sign in to this server.';
|
|
293
|
+
await this.mutate((d) => {
|
|
294
|
+
d.servers[server.id] = { ...(d.servers[server.id] ?? {}), status: 'error', lastError: message };
|
|
295
|
+
});
|
|
296
|
+
throw new RemoteMcpAuthError('oauth', message);
|
|
297
|
+
}
|
|
298
|
+
return this.mutate((d) => {
|
|
299
|
+
const prev = d.servers[server.id];
|
|
300
|
+
const rec = { ...(prev ?? {}), status: prev?.tokens ? prev.status : 'needs_auth', discovered };
|
|
301
|
+
delete rec.lastError;
|
|
302
|
+
// A server that moved to another authorization server needs a new client.
|
|
303
|
+
if (prev?.discovered && prev.discovered.issuer !== discovered.issuer && !prev.manualClient)
|
|
304
|
+
delete rec.client;
|
|
305
|
+
d.servers[server.id] = rec;
|
|
306
|
+
this.logger.info('Remote MCP server uses OAuth', { id: server.id, authorizationServer: safeHost(discovered.metadata.authorization_endpoint) });
|
|
307
|
+
return rec;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// --------------------------------------------------------- authorization
|
|
311
|
+
async cloudRequest(method, suffix, body) {
|
|
312
|
+
const token = this.cloud.getToken();
|
|
313
|
+
const base = this.cloud.getCloudUrl();
|
|
314
|
+
if (!token || !base)
|
|
315
|
+
throw new RemoteMcpAuthError('not_signed_in', 'Sign in to Crewly Cloud first (Settings → Cloud & devices): it hosts the sign-in page your phone opens.');
|
|
316
|
+
let res;
|
|
317
|
+
try {
|
|
318
|
+
res = await this.fetchImpl(`${base.replace(/\/$/, '')}${C.OAUTH_CLOUD_PATH}${suffix}`, {
|
|
319
|
+
method,
|
|
320
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
321
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
322
|
+
signal: AbortSignal.timeout(C.OAUTH_REQUEST_TIMEOUT_MS),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
throw new RemoteMcpAuthError('cloud_error', 'Crewly Cloud is unreachable.');
|
|
327
|
+
}
|
|
328
|
+
const parsed = (await res.text().then((t) => JSON.parse(t)).catch(() => ({})));
|
|
329
|
+
if (res.status === 404 && !parsed.code) {
|
|
330
|
+
throw new RemoteMcpAuthError('cloud_error', 'Crewly Cloud does not support remote MCP sign-in yet (update pending).');
|
|
331
|
+
}
|
|
332
|
+
if (!res.ok || parsed.success !== true) {
|
|
333
|
+
const err = new RemoteMcpAuthError('cloud_error', `Crewly Cloud refused: ${String(parsed.message ?? parsed.code ?? res.status).slice(0, 120)}`);
|
|
334
|
+
err.status = res.status;
|
|
335
|
+
err.cloudCode = parsed.code;
|
|
336
|
+
throw err;
|
|
337
|
+
}
|
|
338
|
+
return (parsed.data ?? {});
|
|
339
|
+
}
|
|
340
|
+
async getRedirectUri() {
|
|
341
|
+
if (this.redirectUri)
|
|
342
|
+
return this.redirectUri;
|
|
343
|
+
const data = await this.cloudRequest('GET', '/config');
|
|
344
|
+
if (!data.redirectUri)
|
|
345
|
+
throw new RemoteMcpAuthError('cloud_error', 'Crewly Cloud did not name a redirect URI.');
|
|
346
|
+
this.redirectUri = data.redirectUri;
|
|
347
|
+
return data.redirectUri;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Start (or reuse) a sign-in and return the link for the owner.
|
|
351
|
+
*
|
|
352
|
+
* @param server - Stored server
|
|
353
|
+
* @param options - Who needed it, and whether to post the Slack card
|
|
354
|
+
* @returns The ticket link and its expiry
|
|
355
|
+
* @throws RemoteMcpAuthError
|
|
356
|
+
*/
|
|
357
|
+
async startAuthorization(server, options = {}) {
|
|
358
|
+
let rec = (await this.load()).servers[server.id];
|
|
359
|
+
if (!rec?.discovered) {
|
|
360
|
+
const challenge = options.challenge ?? (await this.detect(server));
|
|
361
|
+
// A server that never asked for OAuth is left alone (no record).
|
|
362
|
+
if (!challenge && !rec)
|
|
363
|
+
throw new RemoteMcpAuthError('not_oauth', 'This server does not ask for a sign-in (it did not answer 401).');
|
|
364
|
+
rec = await this.markOAuth(server, challenge);
|
|
365
|
+
}
|
|
366
|
+
const now = this.now();
|
|
367
|
+
// An untapped, live link is reused: one link per server at a time.
|
|
368
|
+
if (rec.pending && !rec.pending.opened && rec.pending.expiresAt - now > 5 * 60_000) {
|
|
369
|
+
this.schedulePoll(server.id, C.OAUTH_POLL_SLOW_MS);
|
|
370
|
+
const posted = options.notify ? await this.maybePostCard(server, rec.pending, options.agentSession) : false;
|
|
371
|
+
return { url: rec.pending.url, expiresAt: new Date(rec.pending.expiresAt).toISOString(), posted };
|
|
372
|
+
}
|
|
373
|
+
const redirectUri = await this.getRedirectUri();
|
|
374
|
+
const discovered = rec.discovered;
|
|
375
|
+
let client = rec.client;
|
|
376
|
+
if (client && rec.manualClient) {
|
|
377
|
+
client = { ...client, redirectUri };
|
|
378
|
+
}
|
|
379
|
+
else if (!client || client.redirectUri !== redirectUri) {
|
|
380
|
+
try {
|
|
381
|
+
client = await registerClient(discovered.metadata, redirectUri, this.fetchImpl);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
const message = err instanceof RemoteMcpOAuthError ? err.message : 'Could not register Crewly with the server.';
|
|
385
|
+
await this.mutate((d) => {
|
|
386
|
+
d.servers[server.id] = { ...d.servers[server.id], status: 'error', lastError: message };
|
|
387
|
+
});
|
|
388
|
+
throw new RemoteMcpAuthError('oauth', message);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const { verifier, challenge } = createPkcePair();
|
|
392
|
+
const authorizeUrl = buildAuthorizeUrl(discovered, client, challenge);
|
|
393
|
+
const instanceKey = (await this.load()).instanceKey;
|
|
394
|
+
const session = await this.cloudRequest('POST', '/sessions', {
|
|
395
|
+
instanceKey,
|
|
396
|
+
serverId: server.id,
|
|
397
|
+
label: server.label,
|
|
398
|
+
authorizeUrl,
|
|
399
|
+
});
|
|
400
|
+
const pending = {
|
|
401
|
+
state: session.state,
|
|
402
|
+
verifier,
|
|
403
|
+
url: session.url,
|
|
404
|
+
startedAt: now,
|
|
405
|
+
expiresAt: new Date(session.expiresAt).getTime() || now + 24 * 60 * 60_000,
|
|
406
|
+
...(options.agentSession ? { agentSession: options.agentSession } : {}),
|
|
407
|
+
};
|
|
408
|
+
const finalClient = client;
|
|
409
|
+
await this.mutate((d) => {
|
|
410
|
+
const r = d.servers[server.id] ?? rec;
|
|
411
|
+
r.client = finalClient;
|
|
412
|
+
r.pending = pending;
|
|
413
|
+
if (r.status !== 'connected')
|
|
414
|
+
r.status = 'needs_auth';
|
|
415
|
+
d.servers[server.id] = r;
|
|
416
|
+
});
|
|
417
|
+
this.logger.info('Remote MCP sign-in link created', { id: server.id, authorizationServer: safeHost(discovered.metadata.authorization_endpoint) });
|
|
418
|
+
this.schedulePoll(server.id, C.OAUTH_POLL_SLOW_MS);
|
|
419
|
+
const posted = options.notify ? await this.maybePostCard(server, pending, options.agentSession) : false;
|
|
420
|
+
return { url: pending.url, expiresAt: new Date(pending.expiresAt).toISOString(), posted };
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Post the Slack card unless one carrying this same live link went out
|
|
424
|
+
* within the throttle window.
|
|
425
|
+
*
|
|
426
|
+
* @param server - Server
|
|
427
|
+
* @param pending - The live sign-in
|
|
428
|
+
* @param agentSession - Agent whose DM should carry it
|
|
429
|
+
* @returns Whether a card was posted
|
|
430
|
+
*/
|
|
431
|
+
async maybePostCard(server, pending, agentSession) {
|
|
432
|
+
const rec = (await this.load()).servers[server.id];
|
|
433
|
+
const now = this.now();
|
|
434
|
+
if (rec?.lastCard && rec.lastCard.state === pending.state && now - rec.lastCard.at < C.OAUTH_CARD_THROTTLE_MS)
|
|
435
|
+
return false;
|
|
436
|
+
if (!notifier)
|
|
437
|
+
return false;
|
|
438
|
+
let posted = false;
|
|
439
|
+
try {
|
|
440
|
+
posted = await notifier.postAuthCard({ serverLabel: server.label, url: pending.url, expiresAt: new Date(pending.expiresAt).toISOString(), ...(agentSession ? { agentSession } : {}) });
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
this.logger.warn('Remote MCP sign-in card not posted', { id: server.id, error: err instanceof Error ? err.message : String(err) });
|
|
444
|
+
}
|
|
445
|
+
if (posted) {
|
|
446
|
+
await this.mutate((d) => {
|
|
447
|
+
if (d.servers[server.id])
|
|
448
|
+
d.servers[server.id].lastCard = { at: now, state: pending.state };
|
|
449
|
+
});
|
|
450
|
+
this.logger.info('Remote MCP sign-in card posted', { id: server.id, agentSession });
|
|
451
|
+
}
|
|
452
|
+
return posted;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The proxy got a 401 it could not fix: ask the owner (throttled).
|
|
456
|
+
*
|
|
457
|
+
* @param server - Server
|
|
458
|
+
* @param agentSession - The calling agent
|
|
459
|
+
*/
|
|
460
|
+
async onUnauthorized(server, agentSession) {
|
|
461
|
+
// Many agent calls can fail at once: one attempt per server per minute.
|
|
462
|
+
const last = this.lastUnauthorized.get(server.id) ?? 0;
|
|
463
|
+
if (this.now() - last < 60_000)
|
|
464
|
+
return;
|
|
465
|
+
this.lastUnauthorized.set(server.id, this.now());
|
|
466
|
+
await this.mutate((d) => {
|
|
467
|
+
const r = d.servers[server.id];
|
|
468
|
+
if (r && r.status === 'connected') {
|
|
469
|
+
r.status = 'needs_auth';
|
|
470
|
+
r.lastError = 'The server no longer accepts the sign-in.';
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
try {
|
|
474
|
+
await this.startAuthorization(server, { agentSession, notify: true });
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
this.logger.warn('Remote MCP: could not start a sign-in after a refusal', { id: server.id, error: err instanceof Error ? err.message : String(err) });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// ---------------------------------------------------------------- polls
|
|
481
|
+
/**
|
|
482
|
+
* Resume polls for sign-ins in flight (boot).
|
|
483
|
+
*/
|
|
484
|
+
async resumePending() {
|
|
485
|
+
const data = await this.load();
|
|
486
|
+
for (const [id, rec] of Object.entries(data.servers)) {
|
|
487
|
+
if (rec.pending && rec.pending.expiresAt > this.now())
|
|
488
|
+
this.schedulePoll(id, 1000);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
schedulePoll(id, ms) {
|
|
492
|
+
const prev = this.timers.get(id);
|
|
493
|
+
if (prev)
|
|
494
|
+
this.clearTimer(prev);
|
|
495
|
+
this.timers.set(id, this.setTimer(() => {
|
|
496
|
+
this.timers.delete(id);
|
|
497
|
+
void this.pollOnce(id).catch((err) => this.logger.warn('Remote MCP poll failed', { id, error: err instanceof Error ? err.message : String(err) }));
|
|
498
|
+
}, ms));
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* One broker poll for a server's pending sign-in (exposed for tests).
|
|
502
|
+
*
|
|
503
|
+
* @param id - Server id
|
|
504
|
+
* @returns The broker status seen
|
|
505
|
+
*/
|
|
506
|
+
async pollOnce(id) {
|
|
507
|
+
const rec = (await this.load()).servers[id];
|
|
508
|
+
const pending = rec?.pending;
|
|
509
|
+
if (!pending)
|
|
510
|
+
return 'none';
|
|
511
|
+
if (pending.expiresAt <= this.now()) {
|
|
512
|
+
await this.clearPending(id, pending.state);
|
|
513
|
+
return 'expired';
|
|
514
|
+
}
|
|
515
|
+
const instanceKey = (await this.load()).instanceKey;
|
|
516
|
+
let status;
|
|
517
|
+
try {
|
|
518
|
+
status = await this.cloudRequest('GET', `/sessions/${encodeURIComponent(pending.state)}?instanceKey=${encodeURIComponent(instanceKey)}`);
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
if (err.status === 404) {
|
|
522
|
+
await this.clearPending(id, pending.state);
|
|
523
|
+
return 'gone';
|
|
524
|
+
}
|
|
525
|
+
this.schedulePoll(id, C.OAUTH_POLL_SLOW_MS);
|
|
526
|
+
return 'error';
|
|
527
|
+
}
|
|
528
|
+
switch (status.status) {
|
|
529
|
+
case 'waiting':
|
|
530
|
+
this.schedulePoll(id, C.OAUTH_POLL_SLOW_MS);
|
|
531
|
+
break;
|
|
532
|
+
case 'opened':
|
|
533
|
+
if (!pending.opened) {
|
|
534
|
+
await this.mutate((d) => {
|
|
535
|
+
if (d.servers[id]?.pending?.state === pending.state)
|
|
536
|
+
d.servers[id].pending.opened = true;
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
this.schedulePoll(id, C.OAUTH_POLL_FAST_MS);
|
|
540
|
+
break;
|
|
541
|
+
case 'authorized':
|
|
542
|
+
if (status.code)
|
|
543
|
+
await this.complete(id, pending, status.code);
|
|
544
|
+
break;
|
|
545
|
+
case 'declined':
|
|
546
|
+
await this.mutate((d) => {
|
|
547
|
+
const r = d.servers[id];
|
|
548
|
+
if (r?.pending?.state !== pending.state)
|
|
549
|
+
return;
|
|
550
|
+
delete r.pending;
|
|
551
|
+
if (r.status !== 'connected')
|
|
552
|
+
r.lastError = 'Access was not granted.';
|
|
553
|
+
});
|
|
554
|
+
await this.receipt(id, pending, `Access was not granted, so nothing changed.`);
|
|
555
|
+
break;
|
|
556
|
+
default:
|
|
557
|
+
await this.clearPending(id, pending.state);
|
|
558
|
+
}
|
|
559
|
+
return status.status;
|
|
560
|
+
}
|
|
561
|
+
async clearPending(id, state) {
|
|
562
|
+
await this.mutate((d) => {
|
|
563
|
+
if (d.servers[id]?.pending?.state === state)
|
|
564
|
+
delete d.servers[id].pending;
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Exchange the code and store the tokens; tell Cloud and Slack.
|
|
569
|
+
*
|
|
570
|
+
* @param id - Server id
|
|
571
|
+
* @param pending - The sign-in
|
|
572
|
+
* @param code - Authorization code
|
|
573
|
+
*/
|
|
574
|
+
async complete(id, pending, code) {
|
|
575
|
+
const rec = (await this.load()).servers[id];
|
|
576
|
+
const server = await this.deps.getServer?.(id);
|
|
577
|
+
const label = server?.label ?? id;
|
|
578
|
+
let ok = false;
|
|
579
|
+
let message;
|
|
580
|
+
try {
|
|
581
|
+
if (!rec?.discovered || !rec.client)
|
|
582
|
+
throw new RemoteMcpOAuthError('token_failed', 'The sign-in was set up on another version of Crewly; try again.');
|
|
583
|
+
const tokens = await exchangeCode({
|
|
584
|
+
tokenEndpoint: rec.discovered.metadata.token_endpoint,
|
|
585
|
+
client: rec.client,
|
|
586
|
+
code,
|
|
587
|
+
verifier: pending.verifier,
|
|
588
|
+
resource: rec.discovered.resource,
|
|
589
|
+
}, this.fetchImpl, this.now);
|
|
590
|
+
await this.mutate((d) => {
|
|
591
|
+
const r = d.servers[id];
|
|
592
|
+
if (!r)
|
|
593
|
+
return;
|
|
594
|
+
r.tokens = tokens;
|
|
595
|
+
r.status = 'connected';
|
|
596
|
+
r.connectedAt = new Date(this.now()).toISOString();
|
|
597
|
+
delete r.lastError;
|
|
598
|
+
if (r.pending?.state === pending.state)
|
|
599
|
+
delete r.pending;
|
|
600
|
+
});
|
|
601
|
+
ok = true;
|
|
602
|
+
let tools = null;
|
|
603
|
+
if (server && this.deps.countTools) {
|
|
604
|
+
tools = await this.deps.countTools(server, { Authorization: `Bearer ${tokens.accessToken}` }).catch(() => null);
|
|
605
|
+
}
|
|
606
|
+
message = tools !== null ? `${tools} tool${tools === 1 ? '' : 's'} ready for your agents.` : 'Your agents can use it now.';
|
|
607
|
+
this.logger.info('Remote MCP server signed in', { id, tools });
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
message = err instanceof RemoteMcpOAuthError ? err.message : 'Crewly could not finish the sign-in.';
|
|
611
|
+
await this.mutate((d) => {
|
|
612
|
+
const r = d.servers[id];
|
|
613
|
+
if (!r)
|
|
614
|
+
return;
|
|
615
|
+
if (r.pending?.state === pending.state)
|
|
616
|
+
delete r.pending;
|
|
617
|
+
r.status = r.tokens ? r.status : 'error';
|
|
618
|
+
r.lastError = message;
|
|
619
|
+
});
|
|
620
|
+
this.logger.warn('Remote MCP sign-in exchange failed', { id, error: message });
|
|
621
|
+
}
|
|
622
|
+
const instanceKey = (await this.load()).instanceKey;
|
|
623
|
+
await this.cloudRequest('POST', `/sessions/${encodeURIComponent(pending.state)}/result`, { instanceKey, ok, message }).catch(() => undefined);
|
|
624
|
+
await this.receipt(id, pending, ok ? `${label} is connected — ${message} Agents started before now pick it up when they restart.` : `${label} sign-in did not finish: ${message}`, label);
|
|
625
|
+
}
|
|
626
|
+
async receipt(id, pending, text, label) {
|
|
627
|
+
if (!notifier)
|
|
628
|
+
return;
|
|
629
|
+
const serverLabel = label ?? (await this.deps.getServer?.(id))?.label ?? id;
|
|
630
|
+
await notifier.postReceipt({ serverLabel, text, ...(pending.agentSession ? { agentSession: pending.agentSession } : {}) }).catch(() => false);
|
|
631
|
+
}
|
|
632
|
+
// ---------------------------------------------------------------- tokens
|
|
633
|
+
/**
|
|
634
|
+
* A usable access token for a server, refreshed when it is about to
|
|
635
|
+
* expire (or when `force`).
|
|
636
|
+
*
|
|
637
|
+
* @param id - Server id
|
|
638
|
+
* @param options - `force` refreshes even when the token looks fresh
|
|
639
|
+
* @returns The access token, or null when the owner must sign in
|
|
640
|
+
*/
|
|
641
|
+
async getAccessToken(id, options = {}) {
|
|
642
|
+
const rec = (await this.load()).servers[id];
|
|
643
|
+
if (!rec?.tokens)
|
|
644
|
+
return null;
|
|
645
|
+
const fresh = rec.tokens.expiresAt - this.now() > C.OAUTH_REFRESH_MARGIN_MS;
|
|
646
|
+
if (fresh && !options.force)
|
|
647
|
+
return rec.tokens.accessToken;
|
|
648
|
+
const inflight = this.refreshing.get(id);
|
|
649
|
+
if (inflight)
|
|
650
|
+
return inflight;
|
|
651
|
+
const run = this.refresh(id).finally(() => this.refreshing.delete(id));
|
|
652
|
+
this.refreshing.set(id, run);
|
|
653
|
+
return run;
|
|
654
|
+
}
|
|
655
|
+
async refresh(id) {
|
|
656
|
+
const rec = (await this.load()).servers[id];
|
|
657
|
+
if (!rec?.tokens)
|
|
658
|
+
return null;
|
|
659
|
+
if (!rec.tokens.refreshToken || !rec.discovered || !rec.client) {
|
|
660
|
+
if (rec.tokens.expiresAt > this.now())
|
|
661
|
+
return rec.tokens.accessToken;
|
|
662
|
+
await this.mutate((d) => {
|
|
663
|
+
if (d.servers[id]) {
|
|
664
|
+
d.servers[id].status = 'needs_auth';
|
|
665
|
+
d.servers[id].lastError = 'The sign-in expired.';
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const next = await refreshTokens({
|
|
672
|
+
tokenEndpoint: rec.discovered.metadata.token_endpoint,
|
|
673
|
+
client: rec.client,
|
|
674
|
+
refreshToken: rec.tokens.refreshToken,
|
|
675
|
+
resource: rec.discovered.resource,
|
|
676
|
+
}, this.fetchImpl, this.now);
|
|
677
|
+
const merged = { ...next, refreshToken: next.refreshToken ?? rec.tokens.refreshToken, scope: next.scope ?? rec.tokens.scope };
|
|
678
|
+
await this.mutate((d) => {
|
|
679
|
+
if (!d.servers[id])
|
|
680
|
+
return;
|
|
681
|
+
d.servers[id].tokens = merged;
|
|
682
|
+
d.servers[id].status = 'connected';
|
|
683
|
+
delete d.servers[id].lastError;
|
|
684
|
+
});
|
|
685
|
+
this.logger.info('Remote MCP token refreshed', { id, expiresAt: new Date(merged.expiresAt).toISOString() });
|
|
686
|
+
return merged.accessToken;
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
const dead = err instanceof RemoteMcpOAuthError && err.code === 'invalid_grant';
|
|
690
|
+
this.logger.warn('Remote MCP token refresh failed', { id, dead, error: err instanceof Error ? err.message : String(err) });
|
|
691
|
+
if (dead) {
|
|
692
|
+
await this.mutate((d) => {
|
|
693
|
+
if (!d.servers[id])
|
|
694
|
+
return;
|
|
695
|
+
d.servers[id].status = 'needs_auth';
|
|
696
|
+
d.servers[id].lastError = 'The sign-in is no longer valid.';
|
|
697
|
+
});
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
// A network blip: the old token may still work for a while.
|
|
701
|
+
return rec.tokens.expiresAt > this.now() ? rec.tokens.accessToken : null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Host of a URL, for logs.
|
|
707
|
+
*
|
|
708
|
+
* @param url - URL
|
|
709
|
+
* @returns Host, or `(unknown)`
|
|
710
|
+
*/
|
|
711
|
+
function safeHost(url) {
|
|
712
|
+
try {
|
|
713
|
+
return new URL(url).host;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return '(unknown)';
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
//# sourceMappingURL=remote-mcp-auth.service.js.map
|