@aexol/spectral 0.2.3 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +18 -49
- package/dist/commands/login-oauth.js +116 -0
- package/dist/commands/serve.js +1 -0
- package/dist/config.js +5 -1
- package/dist/mcp/agent-dir.js +18 -0
- package/dist/mcp/app-bridge.bundle.js +67 -0
- package/dist/mcp/commands.js +263 -0
- package/dist/mcp/config.js +532 -0
- package/dist/mcp/consent-manager.js +59 -0
- package/dist/mcp/direct-tools.js +354 -0
- package/dist/mcp/errors.js +165 -0
- package/dist/mcp/glimpse-ui.js +67 -0
- package/dist/mcp/host-html-template.js +412 -0
- package/dist/mcp/index.js +291 -0
- package/dist/mcp/init.js +280 -0
- package/dist/mcp/lifecycle.js +79 -0
- package/dist/mcp/logger.js +130 -0
- package/dist/mcp/mcp-auth-flow.js +283 -0
- package/dist/mcp/mcp-auth.js +226 -0
- package/dist/mcp/mcp-callback-server.js +225 -0
- package/dist/mcp/mcp-oauth-provider.js +243 -0
- package/dist/mcp/mcp-panel.js +646 -0
- package/dist/mcp/mcp-setup-panel.js +485 -0
- package/dist/mcp/metadata-cache.js +158 -0
- package/dist/mcp/npx-resolver.js +385 -0
- package/dist/mcp/oauth-handler.js +54 -0
- package/dist/mcp/onboarding-state.js +56 -0
- package/dist/mcp/proxy-modes.js +714 -0
- package/dist/mcp/resource-tools.js +14 -0
- package/dist/mcp/sampling-handler.js +206 -0
- package/dist/mcp/server-manager.js +301 -0
- package/dist/mcp/state.js +1 -0
- package/dist/mcp/tool-metadata.js +128 -0
- package/dist/mcp/tool-registrar.js +43 -0
- package/dist/mcp/types.js +93 -0
- package/dist/mcp/ui-resource-handler.js +113 -0
- package/dist/mcp/ui-server.js +522 -0
- package/dist/mcp/ui-session.js +306 -0
- package/dist/mcp/ui-stream-types.js +58 -0
- package/dist/mcp/utils.js +104 -0
- package/dist/mcp/vitest.config.js +13 -0
- package/dist/relay/machine-store.js +4 -0
- package/dist/relay/registration.js +12 -7
- package/package.json +9 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized logging for MCP UI operations.
|
|
3
|
+
* Provides structured, contextual logs with levels.
|
|
4
|
+
*/
|
|
5
|
+
const LEVEL_PRIORITY = {
|
|
6
|
+
debug: 0,
|
|
7
|
+
info: 1,
|
|
8
|
+
warn: 2,
|
|
9
|
+
error: 3,
|
|
10
|
+
};
|
|
11
|
+
const LEVEL_PREFIX = {
|
|
12
|
+
debug: "[MCP-UI:DEBUG]",
|
|
13
|
+
info: "[MCP-UI]",
|
|
14
|
+
warn: "[MCP-UI:WARN]",
|
|
15
|
+
error: "[MCP-UI:ERROR]",
|
|
16
|
+
};
|
|
17
|
+
class Logger {
|
|
18
|
+
minLevel = "info";
|
|
19
|
+
handlers = [];
|
|
20
|
+
defaultContext = {};
|
|
21
|
+
setLevel(level) {
|
|
22
|
+
this.minLevel = level;
|
|
23
|
+
}
|
|
24
|
+
setDefaultContext(context) {
|
|
25
|
+
this.defaultContext = context;
|
|
26
|
+
}
|
|
27
|
+
addHandler(handler) {
|
|
28
|
+
this.handlers.push(handler);
|
|
29
|
+
}
|
|
30
|
+
clearHandlers() {
|
|
31
|
+
this.handlers = [];
|
|
32
|
+
}
|
|
33
|
+
shouldLog(level) {
|
|
34
|
+
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.minLevel];
|
|
35
|
+
}
|
|
36
|
+
emit(level, message, context, error) {
|
|
37
|
+
if (!this.shouldLog(level))
|
|
38
|
+
return;
|
|
39
|
+
const entry = {
|
|
40
|
+
level,
|
|
41
|
+
message,
|
|
42
|
+
context: { ...this.defaultContext, ...context },
|
|
43
|
+
error,
|
|
44
|
+
timestamp: new Date(),
|
|
45
|
+
};
|
|
46
|
+
// Default console output
|
|
47
|
+
const prefix = LEVEL_PREFIX[level];
|
|
48
|
+
const contextStr = formatContext(entry.context);
|
|
49
|
+
const fullMessage = contextStr ? `${prefix} ${message} ${contextStr}` : `${prefix} ${message}`;
|
|
50
|
+
if (level === "error") {
|
|
51
|
+
console.error(fullMessage, error ?? "");
|
|
52
|
+
}
|
|
53
|
+
else if (level === "warn") {
|
|
54
|
+
console.warn(fullMessage);
|
|
55
|
+
}
|
|
56
|
+
else if (level === "debug") {
|
|
57
|
+
console.debug(fullMessage);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.log(fullMessage);
|
|
61
|
+
}
|
|
62
|
+
// Custom handlers
|
|
63
|
+
for (const handler of this.handlers) {
|
|
64
|
+
try {
|
|
65
|
+
handler(entry);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Ignore handler errors
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
debug(message, context) {
|
|
73
|
+
this.emit("debug", message, context);
|
|
74
|
+
}
|
|
75
|
+
info(message, context) {
|
|
76
|
+
this.emit("info", message, context);
|
|
77
|
+
}
|
|
78
|
+
warn(message, context) {
|
|
79
|
+
this.emit("warn", message, context);
|
|
80
|
+
}
|
|
81
|
+
error(message, error, context) {
|
|
82
|
+
this.emit("error", message, context, error);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Create a child logger with additional default context.
|
|
86
|
+
*/
|
|
87
|
+
child(context) {
|
|
88
|
+
return new ChildLogger(this, context);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
class ChildLogger {
|
|
92
|
+
parent;
|
|
93
|
+
context;
|
|
94
|
+
constructor(parent, context) {
|
|
95
|
+
this.parent = parent;
|
|
96
|
+
this.context = context;
|
|
97
|
+
}
|
|
98
|
+
debug(message, context) {
|
|
99
|
+
this.parent.debug(message, { ...this.context, ...context });
|
|
100
|
+
}
|
|
101
|
+
info(message, context) {
|
|
102
|
+
this.parent.info(message, { ...this.context, ...context });
|
|
103
|
+
}
|
|
104
|
+
warn(message, context) {
|
|
105
|
+
this.parent.warn(message, { ...this.context, ...context });
|
|
106
|
+
}
|
|
107
|
+
error(message, error, context) {
|
|
108
|
+
this.parent.error(message, error, { ...this.context, ...context });
|
|
109
|
+
}
|
|
110
|
+
child(context) {
|
|
111
|
+
return new ChildLogger(this.parent, { ...this.context, ...context });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function formatContext(context) {
|
|
115
|
+
if (!context || Object.keys(context).length === 0)
|
|
116
|
+
return "";
|
|
117
|
+
const parts = [];
|
|
118
|
+
for (const [key, value] of Object.entries(context)) {
|
|
119
|
+
if (value !== undefined && value !== null) {
|
|
120
|
+
parts.push(`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
|
124
|
+
}
|
|
125
|
+
// Singleton instance
|
|
126
|
+
export const logger = new Logger();
|
|
127
|
+
// Enable debug mode via environment variable
|
|
128
|
+
if (process.env.MCP_UI_DEBUG === "1" || process.env.MCP_UI_DEBUG === "true") {
|
|
129
|
+
logger.setLevel("debug");
|
|
130
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Auth Flow
|
|
3
|
+
*
|
|
4
|
+
* High-level OAuth flow management using the MCP SDK's built-in auth functions.
|
|
5
|
+
*/
|
|
6
|
+
import { auth as runSdkAuth, UnauthorizedError, } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import open from "open";
|
|
9
|
+
import { McpOAuthProvider } from "./mcp-oauth-provider.js";
|
|
10
|
+
import { ensureCallbackServer, waitForCallback, cancelPendingCallback, stopCallbackServer, } from "./mcp-callback-server.js";
|
|
11
|
+
import { getAuthForUrl, isTokenExpired, hasStoredTokens, clearAllCredentials, updateOAuthState, getOAuthState, clearOAuthState, } from "./mcp-auth.js";
|
|
12
|
+
// Track pending transports for auth completion
|
|
13
|
+
const pendingTransports = new Map();
|
|
14
|
+
// Deduplicate concurrent authenticate() calls per server.
|
|
15
|
+
const pendingAuthentications = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* Generate a cryptographically secure random state parameter.
|
|
18
|
+
*/
|
|
19
|
+
function generateState() {
|
|
20
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
21
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
22
|
+
.join("");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extract OAuth configuration from a ServerEntry.
|
|
26
|
+
*/
|
|
27
|
+
function extractOAuthConfig(definition) {
|
|
28
|
+
// If oauth is explicitly false, return empty config
|
|
29
|
+
if (definition.oauth === false) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
grantType: definition.oauth?.grantType,
|
|
34
|
+
clientId: definition.oauth?.clientId,
|
|
35
|
+
clientSecret: definition.oauth?.clientSecret,
|
|
36
|
+
scope: definition.oauth?.scope,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Start OAuth authentication flow for a server.
|
|
41
|
+
* Returns the authorization URL when browser authorization is required.
|
|
42
|
+
*/
|
|
43
|
+
export async function startAuth(serverName, serverUrl, definition) {
|
|
44
|
+
const config = definition ? extractOAuthConfig(definition) : {};
|
|
45
|
+
if (config.grantType === "client_credentials") {
|
|
46
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
|
47
|
+
onRedirect: async () => {
|
|
48
|
+
throw new Error("Browser redirect is not used for client_credentials flow");
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const result = await runSdkAuth(authProvider, { serverUrl });
|
|
52
|
+
if (result !== "AUTHORIZED") {
|
|
53
|
+
throw new UnauthorizedError("Failed to authorize");
|
|
54
|
+
}
|
|
55
|
+
return { authorizationUrl: "" };
|
|
56
|
+
}
|
|
57
|
+
// Start the callback server.
|
|
58
|
+
// Pre-registered OAuth clients require an exact redirect URI, so enforce strict port binding.
|
|
59
|
+
await ensureCallbackServer({ strictPort: Boolean(config.clientId) });
|
|
60
|
+
const oauthState = generateState();
|
|
61
|
+
await updateOAuthState(serverName, oauthState);
|
|
62
|
+
let capturedUrl;
|
|
63
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
|
64
|
+
onRedirect: async (url) => {
|
|
65
|
+
capturedUrl = url;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
const result = await runSdkAuth(authProvider, { serverUrl });
|
|
70
|
+
if (result === "AUTHORIZED") {
|
|
71
|
+
await clearOAuthState(serverName);
|
|
72
|
+
return { authorizationUrl: "" };
|
|
73
|
+
}
|
|
74
|
+
if (!capturedUrl) {
|
|
75
|
+
throw new UnauthorizedError("OAuth authorization URL was not provided");
|
|
76
|
+
}
|
|
77
|
+
pendingTransports.set(serverName, new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider }));
|
|
78
|
+
return { authorizationUrl: capturedUrl.toString() };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
await clearOAuthState(serverName);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Complete OAuth authentication with the authorization code.
|
|
87
|
+
*/
|
|
88
|
+
export async function completeAuth(serverName, authorizationCode) {
|
|
89
|
+
const transport = pendingTransports.get(serverName);
|
|
90
|
+
if (!transport) {
|
|
91
|
+
throw new Error(`No pending OAuth flow for server: ${serverName}`);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
// Complete the auth using the transport's finishAuth method
|
|
95
|
+
await transport.finishAuth(authorizationCode);
|
|
96
|
+
return "authenticated";
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
pendingTransports.delete(serverName);
|
|
100
|
+
await transport.close().catch(() => { });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Perform the complete OAuth authentication flow for a server.
|
|
105
|
+
*
|
|
106
|
+
* @param serverName - The name of the MCP server
|
|
107
|
+
* @param serverUrl - The URL of the MCP server
|
|
108
|
+
* @param definition - The server definition (optional)
|
|
109
|
+
* @returns The final auth status
|
|
110
|
+
*/
|
|
111
|
+
export async function authenticate(serverName, serverUrl, definition) {
|
|
112
|
+
const inFlight = pendingAuthentications.get(serverName);
|
|
113
|
+
if (inFlight) {
|
|
114
|
+
return inFlight;
|
|
115
|
+
}
|
|
116
|
+
const operation = (async () => {
|
|
117
|
+
// Start auth flow
|
|
118
|
+
const { authorizationUrl } = await startAuth(serverName, serverUrl, definition);
|
|
119
|
+
// If no auth URL needed, already authenticated
|
|
120
|
+
if (!authorizationUrl) {
|
|
121
|
+
return "authenticated";
|
|
122
|
+
}
|
|
123
|
+
// Get the state that was already generated and stored in startAuth()
|
|
124
|
+
const oauthState = await getOAuthState(serverName);
|
|
125
|
+
if (!oauthState) {
|
|
126
|
+
throw new Error("OAuth state not found - this should not happen");
|
|
127
|
+
}
|
|
128
|
+
// Register the callback BEFORE opening the browser
|
|
129
|
+
const callbackPromise = waitForCallback(oauthState);
|
|
130
|
+
try {
|
|
131
|
+
// Open browser
|
|
132
|
+
console.log(`MCP Auth: Opening browser for ${serverName}`);
|
|
133
|
+
try {
|
|
134
|
+
await open(authorizationUrl);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.warn(`MCP Auth: Failed to open browser for ${serverName}`, { error });
|
|
138
|
+
throw new Error(`Could not open browser. Please open this URL manually: ${authorizationUrl}`, { cause: error });
|
|
139
|
+
}
|
|
140
|
+
// Wait for callback
|
|
141
|
+
const code = await callbackPromise;
|
|
142
|
+
// Validate state
|
|
143
|
+
const storedState = await getOAuthState(serverName);
|
|
144
|
+
if (storedState !== oauthState) {
|
|
145
|
+
await clearOAuthState(serverName);
|
|
146
|
+
throw new Error("OAuth state mismatch - potential CSRF attack");
|
|
147
|
+
}
|
|
148
|
+
await clearOAuthState(serverName);
|
|
149
|
+
// Complete the auth
|
|
150
|
+
return await completeAuth(serverName, code);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
cancelPendingCallback(oauthState);
|
|
154
|
+
await clearOAuthState(serverName);
|
|
155
|
+
const pendingTransport = pendingTransports.get(serverName);
|
|
156
|
+
if (pendingTransport) {
|
|
157
|
+
pendingTransports.delete(serverName);
|
|
158
|
+
await pendingTransport.close().catch(() => { });
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
pendingAuthentications.set(serverName, operation);
|
|
164
|
+
try {
|
|
165
|
+
return await operation;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (pendingAuthentications.get(serverName) === operation) {
|
|
169
|
+
pendingAuthentications.delete(serverName);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Get a valid access token for a server, refreshing if necessary.
|
|
175
|
+
*
|
|
176
|
+
* @param serverName - The name of the MCP server
|
|
177
|
+
* @param serverUrl - The URL of the MCP server
|
|
178
|
+
* @returns The valid tokens or null if not authenticated
|
|
179
|
+
*/
|
|
180
|
+
export async function getValidToken(serverName, serverUrl) {
|
|
181
|
+
// Check if we have valid tokens
|
|
182
|
+
const entry = await getAuthForUrl(serverName, serverUrl);
|
|
183
|
+
if (!entry?.tokens) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
// Check expiration
|
|
187
|
+
const expired = await isTokenExpired(serverName);
|
|
188
|
+
if (expired === false) {
|
|
189
|
+
return entry.tokens;
|
|
190
|
+
}
|
|
191
|
+
if (expired === true && entry.tokens.refreshToken) {
|
|
192
|
+
// Token is expired, try to refresh
|
|
193
|
+
console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`);
|
|
194
|
+
try {
|
|
195
|
+
// Create auth provider for token refresh
|
|
196
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
|
|
197
|
+
onRedirect: async () => { },
|
|
198
|
+
});
|
|
199
|
+
const clientInfo = await authProvider.clientInformation();
|
|
200
|
+
if (!clientInfo) {
|
|
201
|
+
console.log(`MCP Auth: No client info for refresh for ${serverName}`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const result = await runSdkAuth(authProvider, { serverUrl });
|
|
205
|
+
if (result !== "AUTHORIZED") {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const refreshed = await getAuthForUrl(serverName, serverUrl);
|
|
209
|
+
return refreshed?.tokens ?? null;
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error });
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// No expiration info or no refresh token, assume valid
|
|
217
|
+
return entry.tokens;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Check the authentication status for a server.
|
|
221
|
+
*
|
|
222
|
+
* @param serverName - The name of the MCP server
|
|
223
|
+
* @returns The current auth status
|
|
224
|
+
*/
|
|
225
|
+
export async function getAuthStatus(serverName) {
|
|
226
|
+
const hasTokens = await hasStoredTokens(serverName);
|
|
227
|
+
if (!hasTokens)
|
|
228
|
+
return "not_authenticated";
|
|
229
|
+
const expired = await isTokenExpired(serverName);
|
|
230
|
+
return expired ? "expired" : "authenticated";
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Remove all OAuth credentials for a server.
|
|
234
|
+
*
|
|
235
|
+
* @param serverName - The name of the MCP server
|
|
236
|
+
*/
|
|
237
|
+
export async function removeAuth(serverName) {
|
|
238
|
+
const oauthState = await getOAuthState(serverName);
|
|
239
|
+
if (oauthState) {
|
|
240
|
+
cancelPendingCallback(oauthState);
|
|
241
|
+
}
|
|
242
|
+
const pendingTransport = pendingTransports.get(serverName);
|
|
243
|
+
if (pendingTransport) {
|
|
244
|
+
pendingTransports.delete(serverName);
|
|
245
|
+
await pendingTransport.close().catch(() => { });
|
|
246
|
+
}
|
|
247
|
+
clearAllCredentials(serverName);
|
|
248
|
+
await clearOAuthState(serverName);
|
|
249
|
+
console.log(`MCP Auth: Removed credentials for ${serverName}`);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Check if OAuth is supported for a server configuration.
|
|
253
|
+
* OAuth is supported for HTTP servers unless explicitly disabled.
|
|
254
|
+
*
|
|
255
|
+
* @param definition - The server definition
|
|
256
|
+
* @returns True if OAuth is supported
|
|
257
|
+
*/
|
|
258
|
+
export function supportsOAuth(definition) {
|
|
259
|
+
// OAuth requires a URL
|
|
260
|
+
if (!definition.url)
|
|
261
|
+
return false;
|
|
262
|
+
// Explicitly disabled via auth: false or oauth: false
|
|
263
|
+
if (definition.auth === false)
|
|
264
|
+
return false;
|
|
265
|
+
if (definition.oauth === false)
|
|
266
|
+
return false;
|
|
267
|
+
// OAuth is enabled if auth is 'oauth' or not specified (auto-detect)
|
|
268
|
+
return definition.auth === "oauth" || definition.auth === undefined;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Initialize the OAuth system on startup.
|
|
272
|
+
* Starts the callback server if there are any OAuth servers configured.
|
|
273
|
+
*/
|
|
274
|
+
export async function initializeOAuth() {
|
|
275
|
+
await ensureCallbackServer();
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Shutdown the OAuth system.
|
|
279
|
+
* Stops the callback server and cancels pending auths.
|
|
280
|
+
*/
|
|
281
|
+
export async function shutdownOAuth() {
|
|
282
|
+
await stopCallbackServer();
|
|
283
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Auth Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Handles secure storage of OAuth credentials, tokens, client information,
|
|
5
|
+
* and PKCE state for MCP servers. Maintains backward compatibility with
|
|
6
|
+
* per-server directory structure.
|
|
7
|
+
*
|
|
8
|
+
* Token storage location: $MCP_OAUTH_DIR/<server>/tokens.json when set,
|
|
9
|
+
* otherwise <Pi agent dir>/mcp-oauth/<server>/tokens.json
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { getAgentPath } from './agent-dir.js';
|
|
14
|
+
// Base directory for auth storage - can be overridden via env var for testing
|
|
15
|
+
function getAuthBaseDir() {
|
|
16
|
+
const override = process.env.MCP_OAUTH_DIR?.trim();
|
|
17
|
+
return override ? override : getAgentPath('mcp-oauth');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get the server-specific directory path.
|
|
21
|
+
*/
|
|
22
|
+
function getServerDir(serverName) {
|
|
23
|
+
return join(getAuthBaseDir(), serverName);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get the tokens file path for a server.
|
|
27
|
+
*/
|
|
28
|
+
function getTokensFilePath(serverName) {
|
|
29
|
+
return join(getServerDir(serverName), 'tokens.json');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Ensure the server directory exists with secure permissions.
|
|
33
|
+
*/
|
|
34
|
+
function ensureServerDir(serverName) {
|
|
35
|
+
const dir = getServerDir(serverName);
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Read the auth entry for a server from disk.
|
|
42
|
+
* Returns undefined if file doesn't exist.
|
|
43
|
+
*/
|
|
44
|
+
function readAuthEntry(serverName) {
|
|
45
|
+
try {
|
|
46
|
+
const filePath = getTokensFilePath(serverName);
|
|
47
|
+
if (!existsSync(filePath)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const data = readFileSync(filePath, 'utf-8');
|
|
51
|
+
return JSON.parse(data);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error(`Failed to read auth entry for ${serverName}:`, error);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Write the auth entry for a server to disk with secure permissions.
|
|
60
|
+
*/
|
|
61
|
+
function writeAuthEntry(serverName, entry) {
|
|
62
|
+
ensureServerDir(serverName);
|
|
63
|
+
const filePath = getTokensFilePath(serverName);
|
|
64
|
+
writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get auth entry for a server.
|
|
68
|
+
*/
|
|
69
|
+
export function getAuthEntry(serverName) {
|
|
70
|
+
return readAuthEntry(serverName);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get auth entry and validate it's for the correct URL.
|
|
74
|
+
* Returns undefined if URL has changed (credentials are invalid).
|
|
75
|
+
*/
|
|
76
|
+
export function getAuthForUrl(serverName, serverUrl) {
|
|
77
|
+
const entry = getAuthEntry(serverName);
|
|
78
|
+
if (!entry)
|
|
79
|
+
return undefined;
|
|
80
|
+
// If no serverUrl is stored, this is from an old version - consider it invalid
|
|
81
|
+
if (!entry.serverUrl)
|
|
82
|
+
return undefined;
|
|
83
|
+
// If URL has changed, credentials are invalid
|
|
84
|
+
if (entry.serverUrl !== serverUrl)
|
|
85
|
+
return undefined;
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Save auth entry for a server.
|
|
90
|
+
*/
|
|
91
|
+
export function saveAuthEntry(serverName, entry, serverUrl) {
|
|
92
|
+
// Always update serverUrl if provided
|
|
93
|
+
if (serverUrl) {
|
|
94
|
+
entry.serverUrl = serverUrl;
|
|
95
|
+
}
|
|
96
|
+
writeAuthEntry(serverName, entry);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Remove auth entry for a server.
|
|
100
|
+
* Also removes the server directory if empty.
|
|
101
|
+
*/
|
|
102
|
+
export function removeAuthEntry(serverName) {
|
|
103
|
+
try {
|
|
104
|
+
const filePath = getTokensFilePath(serverName);
|
|
105
|
+
if (existsSync(filePath)) {
|
|
106
|
+
writeFileSync(filePath, '{}', { mode: 0o600 });
|
|
107
|
+
}
|
|
108
|
+
// Try to remove the directory
|
|
109
|
+
const dir = getServerDir(serverName);
|
|
110
|
+
if (existsSync(dir)) {
|
|
111
|
+
try {
|
|
112
|
+
rmSync(dir, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Directory may not be empty, ignore
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
console.error(`Failed to remove auth entry for ${serverName}:`, error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Update tokens for a server.
|
|
125
|
+
*/
|
|
126
|
+
export function updateTokens(serverName, tokens, serverUrl) {
|
|
127
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
128
|
+
entry.tokens = tokens;
|
|
129
|
+
saveAuthEntry(serverName, entry, serverUrl);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Update client info for a server.
|
|
133
|
+
*/
|
|
134
|
+
export function updateClientInfo(serverName, clientInfo, serverUrl) {
|
|
135
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
136
|
+
entry.clientInfo = clientInfo;
|
|
137
|
+
saveAuthEntry(serverName, entry, serverUrl);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Update code verifier for a server.
|
|
141
|
+
*/
|
|
142
|
+
export function updateCodeVerifier(serverName, codeVerifier) {
|
|
143
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
144
|
+
entry.codeVerifier = codeVerifier;
|
|
145
|
+
saveAuthEntry(serverName, entry);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Clear code verifier for a server.
|
|
149
|
+
*/
|
|
150
|
+
export function clearCodeVerifier(serverName) {
|
|
151
|
+
const entry = getAuthEntry(serverName);
|
|
152
|
+
if (entry) {
|
|
153
|
+
delete entry.codeVerifier;
|
|
154
|
+
saveAuthEntry(serverName, entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Update OAuth state for a server.
|
|
159
|
+
*/
|
|
160
|
+
export function updateOAuthState(serverName, state) {
|
|
161
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
162
|
+
entry.oauthState = state;
|
|
163
|
+
saveAuthEntry(serverName, entry);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get OAuth state for a server.
|
|
167
|
+
*/
|
|
168
|
+
export function getOAuthState(serverName) {
|
|
169
|
+
const entry = getAuthEntry(serverName);
|
|
170
|
+
return entry?.oauthState;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Clear OAuth state for a server.
|
|
174
|
+
*/
|
|
175
|
+
export function clearOAuthState(serverName) {
|
|
176
|
+
const entry = getAuthEntry(serverName);
|
|
177
|
+
if (entry) {
|
|
178
|
+
delete entry.oauthState;
|
|
179
|
+
saveAuthEntry(serverName, entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Check if stored tokens are expired.
|
|
184
|
+
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
|
|
185
|
+
*/
|
|
186
|
+
export function isTokenExpired(serverName) {
|
|
187
|
+
const entry = getAuthEntry(serverName);
|
|
188
|
+
if (!entry?.tokens)
|
|
189
|
+
return null;
|
|
190
|
+
if (!entry.tokens.expiresAt)
|
|
191
|
+
return false;
|
|
192
|
+
return entry.tokens.expiresAt < Date.now() / 1000;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check if a server has stored tokens.
|
|
196
|
+
*/
|
|
197
|
+
export function hasStoredTokens(serverName) {
|
|
198
|
+
const entry = getAuthEntry(serverName);
|
|
199
|
+
return !!entry?.tokens;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Clear all credentials for a server.
|
|
203
|
+
*/
|
|
204
|
+
export function clearAllCredentials(serverName) {
|
|
205
|
+
removeAuthEntry(serverName);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Clear only client info for a server.
|
|
209
|
+
*/
|
|
210
|
+
export function clearClientInfo(serverName) {
|
|
211
|
+
const entry = getAuthEntry(serverName);
|
|
212
|
+
if (entry) {
|
|
213
|
+
delete entry.clientInfo;
|
|
214
|
+
saveAuthEntry(serverName, entry);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Clear only tokens for a server.
|
|
219
|
+
*/
|
|
220
|
+
export function clearTokens(serverName) {
|
|
221
|
+
const entry = getAuthEntry(serverName);
|
|
222
|
+
if (entry) {
|
|
223
|
+
delete entry.tokens;
|
|
224
|
+
saveAuthEntry(serverName, entry);
|
|
225
|
+
}
|
|
226
|
+
}
|