kontexted 0.1.5
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/README.md +75 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +48 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.js +33 -0
- package/dist/commands/mcp.d.ts +15 -0
- package/dist/commands/mcp.js +65 -0
- package/dist/commands/server/doctor.d.ts +4 -0
- package/dist/commands/server/doctor.js +33 -0
- package/dist/commands/server/index.d.ts +6 -0
- package/dist/commands/server/index.js +125 -0
- package/dist/commands/server/init.d.ts +6 -0
- package/dist/commands/server/init.js +112 -0
- package/dist/commands/server/logs.d.ts +7 -0
- package/dist/commands/server/logs.js +39 -0
- package/dist/commands/server/migrate.d.ts +4 -0
- package/dist/commands/server/migrate.js +29 -0
- package/dist/commands/server/show-invite.d.ts +4 -0
- package/dist/commands/server/show-invite.js +29 -0
- package/dist/commands/server/start.d.ts +6 -0
- package/dist/commands/server/start.js +44 -0
- package/dist/commands/server/status.d.ts +4 -0
- package/dist/commands/server/status.js +23 -0
- package/dist/commands/server/stop.d.ts +6 -0
- package/dist/commands/server/stop.js +32 -0
- package/dist/commands/show-config.d.ts +5 -0
- package/dist/commands/show-config.js +19 -0
- package/dist/commands/skill.d.ts +5 -0
- package/dist/commands/skill.js +211 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25 -0
- package/dist/lib/api-client.d.ts +36 -0
- package/dist/lib/api-client.js +130 -0
- package/dist/lib/config.d.ts +17 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/index.d.ts +6 -0
- package/dist/lib/index.js +6 -0
- package/dist/lib/logger.d.ts +24 -0
- package/dist/lib/logger.js +76 -0
- package/dist/lib/mcp-client.d.ts +14 -0
- package/dist/lib/mcp-client.js +62 -0
- package/dist/lib/oauth.d.ts +42 -0
- package/dist/lib/oauth.js +383 -0
- package/dist/lib/profile.d.ts +37 -0
- package/dist/lib/profile.js +49 -0
- package/dist/lib/proxy-server.d.ts +12 -0
- package/dist/lib/proxy-server.js +131 -0
- package/dist/lib/server/binary.d.ts +32 -0
- package/dist/lib/server/binary.js +127 -0
- package/dist/lib/server/config.d.ts +57 -0
- package/dist/lib/server/config.js +136 -0
- package/dist/lib/server/constants.d.ts +29 -0
- package/dist/lib/server/constants.js +35 -0
- package/dist/lib/server/daemon.d.ts +34 -0
- package/dist/lib/server/daemon.js +199 -0
- package/dist/lib/server/index.d.ts +5 -0
- package/dist/lib/server/index.js +5 -0
- package/dist/lib/server/migrate.d.ts +9 -0
- package/dist/lib/server/migrate.js +51 -0
- package/dist/lib/server-url.d.ts +8 -0
- package/dist/lib/server-url.js +25 -0
- package/dist/skill-init/index.d.ts +3 -0
- package/dist/skill-init/index.js +3 -0
- package/dist/skill-init/providers/base.d.ts +26 -0
- package/dist/skill-init/providers/base.js +1 -0
- package/dist/skill-init/providers/index.d.ts +13 -0
- package/dist/skill-init/providers/index.js +17 -0
- package/dist/skill-init/providers/opencode.d.ts +5 -0
- package/dist/skill-init/providers/opencode.js +48 -0
- package/dist/skill-init/templates/index.d.ts +3 -0
- package/dist/skill-init/templates/index.js +3 -0
- package/dist/skill-init/templates/kontexted-cli.d.ts +2 -0
- package/dist/skill-init/templates/kontexted-cli.js +169 -0
- package/dist/skill-init/utils.d.ts +66 -0
- package/dist/skill-init/utils.js +122 -0
- package/dist/types/index.d.ts +67 -0
- package/dist/types/index.js +4 -0
- package/package.json +50 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const LOG_DIR = join(homedir(), ".kontexted");
|
|
5
|
+
const LOG_FILE = join(LOG_DIR, "log.txt");
|
|
6
|
+
let logEnabled = true;
|
|
7
|
+
/**
|
|
8
|
+
* Set whether logging is enabled
|
|
9
|
+
*/
|
|
10
|
+
export function setLogEnabled(enabled) {
|
|
11
|
+
logEnabled = enabled;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Format a timestamp for log entries
|
|
15
|
+
*/
|
|
16
|
+
function formatTimestamp() {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Write a log entry to the log file (fire-and-forget)
|
|
21
|
+
*/
|
|
22
|
+
function writeLog(level, message, data) {
|
|
23
|
+
if (!logEnabled)
|
|
24
|
+
return;
|
|
25
|
+
// Fire and forget - don't await, handle errors internally
|
|
26
|
+
(async () => {
|
|
27
|
+
try {
|
|
28
|
+
await mkdir(LOG_DIR, { recursive: true });
|
|
29
|
+
const timestamp = formatTimestamp();
|
|
30
|
+
let logLine = `[${timestamp}] [${level}] ${message}`;
|
|
31
|
+
if (data !== undefined) {
|
|
32
|
+
if (data instanceof Error) {
|
|
33
|
+
logLine += ` | Error: ${data.message}\n${data.stack}`;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
logLine += ` | ${JSON.stringify(data)}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
logLine += "\n";
|
|
40
|
+
await appendFile(LOG_FILE, logLine, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Silently fail if logging fails
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Log an info message (file only)
|
|
49
|
+
*/
|
|
50
|
+
export function logInfo(message, data) {
|
|
51
|
+
writeLog("INFO", message, data);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Log a debug message (file only)
|
|
55
|
+
*/
|
|
56
|
+
export function logDebug(message, data) {
|
|
57
|
+
writeLog("DEBUG", message, data);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Log a warning message (file only)
|
|
61
|
+
*/
|
|
62
|
+
export function logWarn(message, data) {
|
|
63
|
+
writeLog("WARN", message, data);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Log an error message (file only)
|
|
67
|
+
*/
|
|
68
|
+
export function logError(message, data) {
|
|
69
|
+
writeLog("ERROR", message, data);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get the log file path
|
|
73
|
+
*/
|
|
74
|
+
export function getLogFilePath() {
|
|
75
|
+
return LOG_FILE;
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import type { OAuthState } from "../types/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Connect to remote MCP server with OAuth authentication
|
|
7
|
+
*/
|
|
8
|
+
export declare function connectRemoteClient(serverUrl: string, oauth: OAuthState, persist: () => Promise<void>, options: {
|
|
9
|
+
allowInteractive: boolean;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
client: Client;
|
|
12
|
+
transport: StreamableHTTPClientTransport;
|
|
13
|
+
}>;
|
|
14
|
+
export { UnauthorizedError };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { URL } from "node:url";
|
|
5
|
+
import { createOAuthProvider, waitForOAuthCallback, ensureValidTokens } from "../lib/oauth.js";
|
|
6
|
+
import { logDebug, logWarn, logError } from "../lib/logger.js";
|
|
7
|
+
/**
|
|
8
|
+
* Connect to remote MCP server with OAuth authentication
|
|
9
|
+
*/
|
|
10
|
+
export async function connectRemoteClient(serverUrl, oauth, persist, options) {
|
|
11
|
+
// Construct MCP URL from base server URL
|
|
12
|
+
const mcpUrl = `${serverUrl}/mcp`;
|
|
13
|
+
logDebug(`[MCP CLIENT] Connecting to MCP server: ${mcpUrl}`);
|
|
14
|
+
// Proactively refresh token if needed (non-interactive)
|
|
15
|
+
const tokensValid = await ensureValidTokens(oauth, persist, serverUrl);
|
|
16
|
+
if (!tokensValid) {
|
|
17
|
+
logWarn(`[MCP CLIENT] Token refresh failed or no tokens available`);
|
|
18
|
+
if (!options.allowInteractive) {
|
|
19
|
+
throw new Error("Authentication required. Run 'kontexted login' to authenticate.");
|
|
20
|
+
}
|
|
21
|
+
// Will fall through to interactive OAuth flow below
|
|
22
|
+
}
|
|
23
|
+
const oauthProvider = createOAuthProvider(oauth, persist, serverUrl);
|
|
24
|
+
const client = new Client({
|
|
25
|
+
name: "kontexted-cli",
|
|
26
|
+
version: "0.1.0",
|
|
27
|
+
});
|
|
28
|
+
const createTransport = () => new StreamableHTTPClientTransport(new URL(mcpUrl), {
|
|
29
|
+
authProvider: oauthProvider,
|
|
30
|
+
});
|
|
31
|
+
let transport = createTransport();
|
|
32
|
+
try {
|
|
33
|
+
logDebug(`[MCP CLIENT] Attempting initial connection...`);
|
|
34
|
+
await client.connect(transport);
|
|
35
|
+
logDebug(`[MCP CLIENT] Initial connection successful`);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof UnauthorizedError) {
|
|
39
|
+
logWarn(`[MCP CLIENT] UnauthorizedError - authentication required`);
|
|
40
|
+
if (!options.allowInteractive) {
|
|
41
|
+
logError(`[MCP CLIENT] Interactive OAuth not allowed, throwing error`);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
// Interactive OAuth flow
|
|
45
|
+
logDebug(`[MCP CLIENT] Starting interactive OAuth flow...`);
|
|
46
|
+
const authCode = await waitForOAuthCallback();
|
|
47
|
+
await transport.finishAuth(authCode);
|
|
48
|
+
await transport.close();
|
|
49
|
+
// Reconnect with new tokens
|
|
50
|
+
logDebug(`[MCP CLIENT] Reconnecting with new OAuth tokens...`);
|
|
51
|
+
transport = createTransport();
|
|
52
|
+
await client.connect(transport);
|
|
53
|
+
logDebug(`[MCP CLIENT] Reconnection successful with OAuth tokens`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
logError(`[MCP CLIENT] Connection error:`, error);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { client, transport };
|
|
61
|
+
}
|
|
62
|
+
export { UnauthorizedError };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
import type { OAuthState, OAuthTokens, OAuthClientInfo } from "../types/index.js";
|
|
3
|
+
import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
4
|
+
/**
|
|
5
|
+
* Open the user's browser to the authorization URL
|
|
6
|
+
*/
|
|
7
|
+
export declare function openBrowser(url: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Wait for OAuth callback on local server
|
|
10
|
+
*/
|
|
11
|
+
export declare function waitForOAuthCallback(): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* OAuth provider that interfaces with MCP SDK
|
|
14
|
+
*/
|
|
15
|
+
export interface OAuthProvider {
|
|
16
|
+
readonly redirectUrl: string | URL;
|
|
17
|
+
readonly clientMetadata: OAuthClientMetadata;
|
|
18
|
+
clientInformation(): OAuthClientInfo | undefined | Promise<OAuthClientInfo | undefined>;
|
|
19
|
+
saveClientInformation?(clientInformation: OAuthClientInfo): void | Promise<void>;
|
|
20
|
+
tokens(): OAuthTokens | undefined | Promise<OAuthTokens | undefined>;
|
|
21
|
+
saveTokens(tokens: OAuthTokens): void | Promise<void>;
|
|
22
|
+
redirectToAuthorization(authorizationUrl: URL): void | Promise<void>;
|
|
23
|
+
saveCodeVerifier(codeVerifier: string): void | Promise<void>;
|
|
24
|
+
codeVerifier(): string | Promise<string>;
|
|
25
|
+
invalidateCredentials?(scope: "all" | "tokens" | "client" | "verifier"): void | Promise<void>;
|
|
26
|
+
getAuthorizationUrl(): Promise<URL>;
|
|
27
|
+
exchangeCodeForToken(authCode: string): Promise<OAuthTokens>;
|
|
28
|
+
refreshAccessToken(): Promise<OAuthTokens>;
|
|
29
|
+
registerClient(): Promise<OAuthClientInfo>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create an OAuth provider from stored state
|
|
33
|
+
*/
|
|
34
|
+
export declare function createOAuthProvider(oauth: OAuthState, persist: () => Promise<void>, serverUrl: string): OAuthProvider;
|
|
35
|
+
/**
|
|
36
|
+
* Check if tokens need refresh and refresh them proactively
|
|
37
|
+
* Uses refresh token (non-interactive) - no browser popup
|
|
38
|
+
*
|
|
39
|
+
* @returns true if tokens are valid (either already valid or successfully refreshed)
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureValidTokens(oauth: OAuthState, persist: () => Promise<void>, serverUrl: string): Promise<boolean>;
|
|
42
|
+
export declare function generateCodeVerifier(): string;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { URL } from "node:url";
|
|
5
|
+
import { logInfo, logDebug, logWarn, logError } from "../lib/logger.js";
|
|
6
|
+
const CALLBACK_PORT = 8788;
|
|
7
|
+
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
8
|
+
const DEFAULT_CLIENT_METADATA = {
|
|
9
|
+
client_name: "Kontexted CLI",
|
|
10
|
+
redirect_uris: [CALLBACK_URL],
|
|
11
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
12
|
+
response_types: ["code"],
|
|
13
|
+
token_endpoint_auth_method: "none",
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Open the user's browser to the authorization URL
|
|
17
|
+
*/
|
|
18
|
+
export function openBrowser(url) {
|
|
19
|
+
const platform = process.platform;
|
|
20
|
+
const command = platform === "darwin"
|
|
21
|
+
? `open "${url}"`
|
|
22
|
+
: platform === "win32"
|
|
23
|
+
? `start "" "${url}"`
|
|
24
|
+
: `xdg-open "${url}"`;
|
|
25
|
+
exec(command, async (error) => {
|
|
26
|
+
if (error) {
|
|
27
|
+
logError(`Failed to open browser: ${error.message}`);
|
|
28
|
+
console.error(`Open this URL manually: ${url}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Wait for OAuth callback on local server
|
|
34
|
+
*/
|
|
35
|
+
export function waitForOAuthCallback() {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const server = createServer((req, res) => {
|
|
38
|
+
if (req.url === "/favicon.ico") {
|
|
39
|
+
res.writeHead(404);
|
|
40
|
+
res.end();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const parsedUrl = new URL(req.url ?? "", "http://localhost");
|
|
44
|
+
const code = parsedUrl.searchParams.get("code");
|
|
45
|
+
const error = parsedUrl.searchParams.get("error");
|
|
46
|
+
if (code) {
|
|
47
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
48
|
+
res.end("<p>Authorization complete. You can close this window.</p>");
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
server.closeAllConnections();
|
|
51
|
+
server.close(() => resolve(code));
|
|
52
|
+
}, 100);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (error) {
|
|
56
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
57
|
+
res.end(`<p>Authorization failed: ${error}</p>`);
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
server.closeAllConnections();
|
|
60
|
+
server.close(() => reject(new Error(`OAuth authorization failed: ${error}`)));
|
|
61
|
+
}, 100);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
65
|
+
res.end("Missing authorization code.");
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
server.closeAllConnections();
|
|
68
|
+
server.close(() => reject(new Error("Missing authorization code.")));
|
|
69
|
+
}, 100);
|
|
70
|
+
});
|
|
71
|
+
server.listen(CALLBACK_PORT, () => {
|
|
72
|
+
console.log(`Waiting for OAuth callback on ${CALLBACK_URL}`);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create an OAuth provider from stored state
|
|
78
|
+
*/
|
|
79
|
+
export function createOAuthProvider(oauth, persist, serverUrl) {
|
|
80
|
+
return {
|
|
81
|
+
get redirectUrl() {
|
|
82
|
+
return oauth?.redirectUrl ?? CALLBACK_URL;
|
|
83
|
+
},
|
|
84
|
+
get clientMetadata() {
|
|
85
|
+
return oauth?.clientMetadata ?? DEFAULT_CLIENT_METADATA;
|
|
86
|
+
},
|
|
87
|
+
clientInformation() {
|
|
88
|
+
return oauth?.clientInformation;
|
|
89
|
+
},
|
|
90
|
+
saveClientInformation: async (clientInformation) => {
|
|
91
|
+
oauth.clientInformation = clientInformation;
|
|
92
|
+
await persist();
|
|
93
|
+
},
|
|
94
|
+
tokens() {
|
|
95
|
+
return oauth?.tokens;
|
|
96
|
+
},
|
|
97
|
+
async saveTokens(tokens) {
|
|
98
|
+
logDebug("saveTokens called", {
|
|
99
|
+
expiresIn: tokens.expires_in,
|
|
100
|
+
expiresAt: tokens.expires_at,
|
|
101
|
+
hasRefreshToken: !!tokens.refresh_token,
|
|
102
|
+
});
|
|
103
|
+
// Calculate absolute expiry time if expires_in is provided
|
|
104
|
+
if (tokens.expires_in && !tokens.expires_at) {
|
|
105
|
+
tokens.expires_at = Math.floor(Date.now() / 1000) + tokens.expires_in;
|
|
106
|
+
logDebug("Calculated expires_at", { expiresAt: tokens.expires_at });
|
|
107
|
+
}
|
|
108
|
+
oauth.tokens = tokens;
|
|
109
|
+
oauth.serverUrl = serverUrl;
|
|
110
|
+
await persist();
|
|
111
|
+
logInfo("Tokens saved successfully");
|
|
112
|
+
},
|
|
113
|
+
redirectToAuthorization(authorizationUrl) {
|
|
114
|
+
logDebug(`[OAUTH] Opening browser for OAuth at: ${authorizationUrl.toString()}`);
|
|
115
|
+
openBrowser(authorizationUrl.toString());
|
|
116
|
+
},
|
|
117
|
+
async saveCodeVerifier(codeVerifier) {
|
|
118
|
+
oauth.codeVerifier = codeVerifier;
|
|
119
|
+
await persist();
|
|
120
|
+
},
|
|
121
|
+
codeVerifier() {
|
|
122
|
+
if (!oauth?.codeVerifier) {
|
|
123
|
+
throw new Error("Missing PKCE code verifier.");
|
|
124
|
+
}
|
|
125
|
+
return oauth.codeVerifier;
|
|
126
|
+
},
|
|
127
|
+
async invalidateCredentials(scope) {
|
|
128
|
+
if (!oauth)
|
|
129
|
+
return;
|
|
130
|
+
switch (scope) {
|
|
131
|
+
case "all":
|
|
132
|
+
case "tokens":
|
|
133
|
+
delete oauth.tokens;
|
|
134
|
+
if (scope === "tokens")
|
|
135
|
+
break;
|
|
136
|
+
// fallthrough for 'all'
|
|
137
|
+
case "client":
|
|
138
|
+
delete oauth.clientInformation;
|
|
139
|
+
if (scope === "client")
|
|
140
|
+
break;
|
|
141
|
+
// fallthrough for 'all'
|
|
142
|
+
case "verifier":
|
|
143
|
+
delete oauth.codeVerifier;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
await persist();
|
|
147
|
+
},
|
|
148
|
+
async getAuthorizationUrl() {
|
|
149
|
+
const state = generateState();
|
|
150
|
+
const verifier = generateCodeVerifier();
|
|
151
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
152
|
+
// Store the code verifier for later token exchange
|
|
153
|
+
await this.saveCodeVerifier(verifier);
|
|
154
|
+
const baseUrl = new URL("/api/auth/oauth2/authorize", new URL(serverUrl).origin);
|
|
155
|
+
baseUrl.searchParams.set("response_type", "code");
|
|
156
|
+
baseUrl.searchParams.set("client_id", oauth.clientInformation?.client_id ?? "");
|
|
157
|
+
baseUrl.searchParams.set("redirect_uri", this.redirectUrl.toString());
|
|
158
|
+
baseUrl.searchParams.set("state", state);
|
|
159
|
+
baseUrl.searchParams.set("code_challenge", challenge);
|
|
160
|
+
baseUrl.searchParams.set("code_challenge_method", "S256");
|
|
161
|
+
baseUrl.searchParams.set("resource", serverUrl);
|
|
162
|
+
baseUrl.searchParams.set("scope", "openid profile email offline_access");
|
|
163
|
+
return baseUrl;
|
|
164
|
+
},
|
|
165
|
+
async exchangeCodeForToken(authCode) {
|
|
166
|
+
const verifier = await this.codeVerifier();
|
|
167
|
+
const tokenUrl = new URL("/api/auth/oauth2/token", new URL(serverUrl).origin);
|
|
168
|
+
logDebug(`[OAUTH] Exchanging code for token - URL: ${tokenUrl.toString()}`);
|
|
169
|
+
const response = await fetch(tokenUrl.toString(), {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: {
|
|
172
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
173
|
+
},
|
|
174
|
+
body: new URLSearchParams({
|
|
175
|
+
grant_type: "authorization_code",
|
|
176
|
+
code: authCode,
|
|
177
|
+
redirect_uri: this.redirectUrl.toString(),
|
|
178
|
+
client_id: oauth.clientInformation?.client_id ?? "",
|
|
179
|
+
client_secret: oauth.clientInformation?.client_secret ?? "",
|
|
180
|
+
code_verifier: verifier,
|
|
181
|
+
resource: serverUrl,
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
logDebug(`[OAUTH] Token exchange response status: ${response.status}`);
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
const errorText = await response.text();
|
|
187
|
+
logError(`[OAUTH] Failed to exchange code for token - Status: ${response.status}, Error: ${errorText}`);
|
|
188
|
+
throw new Error(`Failed to exchange code for token: ${response.status} ${errorText}`);
|
|
189
|
+
}
|
|
190
|
+
const tokens = (await response.json());
|
|
191
|
+
logDebug("Token exchange response", {
|
|
192
|
+
hasAccessToken: !!tokens.access_token,
|
|
193
|
+
hasRefreshToken: !!tokens.refresh_token,
|
|
194
|
+
expiresIn: tokens.expires_in,
|
|
195
|
+
expiresAt: tokens.expires_at,
|
|
196
|
+
tokenType: tokens.token_type,
|
|
197
|
+
scope: tokens.scope,
|
|
198
|
+
fullResponse: JSON.stringify(tokens),
|
|
199
|
+
});
|
|
200
|
+
logDebug(`[OAUTH] Successfully exchanged code for token - Access token present: ${!!tokens.access_token}`);
|
|
201
|
+
await this.saveTokens(tokens);
|
|
202
|
+
return tokens;
|
|
203
|
+
},
|
|
204
|
+
async refreshAccessToken() {
|
|
205
|
+
const currentTokens = await this.tokens();
|
|
206
|
+
if (!currentTokens?.refresh_token) {
|
|
207
|
+
throw new Error("No refresh token available");
|
|
208
|
+
}
|
|
209
|
+
const tokenUrl = new URL("/api/auth/oauth2/token", new URL(serverUrl).origin);
|
|
210
|
+
logDebug(`[OAUTH] Refreshing access token - URL: ${tokenUrl.toString()}`);
|
|
211
|
+
const response = await fetch(tokenUrl.toString(), {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: {
|
|
214
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
215
|
+
},
|
|
216
|
+
body: new URLSearchParams({
|
|
217
|
+
grant_type: "refresh_token",
|
|
218
|
+
refresh_token: currentTokens.refresh_token,
|
|
219
|
+
client_id: oauth.clientInformation?.client_id ?? "",
|
|
220
|
+
client_secret: oauth.clientInformation?.client_secret ?? "",
|
|
221
|
+
resource: serverUrl,
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
logDebug(`[OAUTH] Token refresh response status: ${response.status}`);
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
const errorText = await response.text();
|
|
227
|
+
logError(`[OAUTH] Failed to refresh access token - Status: ${response.status}, Error: ${errorText}`);
|
|
228
|
+
throw new Error(`Failed to refresh access token: ${response.status} ${errorText}`);
|
|
229
|
+
}
|
|
230
|
+
const tokens = (await response.json());
|
|
231
|
+
logDebug(`[OAUTH] Successfully refreshed access token`);
|
|
232
|
+
await this.saveTokens(tokens);
|
|
233
|
+
return tokens;
|
|
234
|
+
},
|
|
235
|
+
async registerClient() {
|
|
236
|
+
const registerUrl = new URL("/api/auth/oauth2/register", new URL(serverUrl).origin);
|
|
237
|
+
logDebug(`[OAUTH] Registering OAuth client - URL: ${registerUrl.toString()}`);
|
|
238
|
+
const response = await fetch(registerUrl.toString(), {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: {
|
|
241
|
+
"Content-Type": "application/json",
|
|
242
|
+
},
|
|
243
|
+
body: JSON.stringify(this.clientMetadata),
|
|
244
|
+
});
|
|
245
|
+
logDebug(`[OAUTH] Client registration response status: ${response.status}`);
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const errorText = await response.text();
|
|
248
|
+
logError(`[OAUTH] Failed to register OAuth client - Status: ${response.status}, Error: ${errorText}`);
|
|
249
|
+
throw new Error(`Failed to register OAuth client: ${response.status} ${errorText}`);
|
|
250
|
+
}
|
|
251
|
+
const clientInfo = (await response.json());
|
|
252
|
+
logDebug(`[OAUTH] Successfully registered OAuth client - Client ID: ${clientInfo.client_id}`);
|
|
253
|
+
if (this.saveClientInformation) {
|
|
254
|
+
await this.saveClientInformation(clientInfo);
|
|
255
|
+
}
|
|
256
|
+
return clientInfo;
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Buffer time in seconds before token is considered expired
|
|
262
|
+
* (refresh 5 minutes before actual expiry)
|
|
263
|
+
*/
|
|
264
|
+
const TOKEN_REFRESH_BUFFER_SECONDS = 30;
|
|
265
|
+
/**
|
|
266
|
+
* Check if tokens need refresh and refresh them proactively
|
|
267
|
+
* Uses refresh token (non-interactive) - no browser popup
|
|
268
|
+
*
|
|
269
|
+
* @returns true if tokens are valid (either already valid or successfully refreshed)
|
|
270
|
+
*/
|
|
271
|
+
export async function ensureValidTokens(oauth, persist, serverUrl) {
|
|
272
|
+
const tokens = oauth.tokens;
|
|
273
|
+
logDebug("ensureValidTokens called", {
|
|
274
|
+
hasTokens: !!tokens,
|
|
275
|
+
hasAccessToken: !!tokens?.access_token,
|
|
276
|
+
hasRefreshToken: !!tokens?.refresh_token,
|
|
277
|
+
expiresAt: tokens?.expires_at,
|
|
278
|
+
expiresIn: tokens?.expires_in,
|
|
279
|
+
currentTime: Math.floor(Date.now() / 1000),
|
|
280
|
+
serverUrl,
|
|
281
|
+
});
|
|
282
|
+
// No tokens at all - need interactive login
|
|
283
|
+
if (!tokens?.access_token) {
|
|
284
|
+
logWarn("ensureValidTokens: No access token");
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
// Check if token is still valid (with buffer)
|
|
288
|
+
const now = Math.floor(Date.now() / 1000);
|
|
289
|
+
const bufferTime = now + TOKEN_REFRESH_BUFFER_SECONDS;
|
|
290
|
+
const isExpired = tokens.expires_at ? tokens.expires_at <= bufferTime : true;
|
|
291
|
+
logDebug("Token expiry check", {
|
|
292
|
+
expiresAt: tokens.expires_at,
|
|
293
|
+
currentTime: now,
|
|
294
|
+
bufferTime,
|
|
295
|
+
isExpired,
|
|
296
|
+
});
|
|
297
|
+
if (tokens.expires_at && !isExpired) {
|
|
298
|
+
logInfo("Access token still valid", {
|
|
299
|
+
expiresAt: tokens.expires_at,
|
|
300
|
+
remainingSeconds: tokens.expires_at - now
|
|
301
|
+
});
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
// Token is expired or expiring soon - try to refresh
|
|
305
|
+
if (!tokens.refresh_token) {
|
|
306
|
+
logWarn("ensureValidTokens: Token expired and no refresh token available");
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
logInfo("Access token expired or expiring soon, refreshing...", {
|
|
310
|
+
expiresAt: tokens.expires_at,
|
|
311
|
+
clientId: oauth.clientInformation?.client_id,
|
|
312
|
+
});
|
|
313
|
+
try {
|
|
314
|
+
const tokenUrl = new URL("/api/auth/oauth2/token", new URL(serverUrl).origin);
|
|
315
|
+
const requestBody = {
|
|
316
|
+
grant_type: "refresh_token",
|
|
317
|
+
refresh_token: tokens.refresh_token,
|
|
318
|
+
client_id: oauth.clientInformation?.client_id ?? "",
|
|
319
|
+
client_secret: oauth.clientInformation?.client_secret ?? "",
|
|
320
|
+
resource: serverUrl,
|
|
321
|
+
};
|
|
322
|
+
logDebug("Sending refresh token request", {
|
|
323
|
+
url: tokenUrl.toString(),
|
|
324
|
+
hasClientSecret: !!oauth.clientInformation?.client_secret,
|
|
325
|
+
});
|
|
326
|
+
const response = await fetch(tokenUrl.toString(), {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: {
|
|
329
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
330
|
+
},
|
|
331
|
+
body: new URLSearchParams(requestBody),
|
|
332
|
+
});
|
|
333
|
+
logDebug("Refresh token response", {
|
|
334
|
+
status: response.status,
|
|
335
|
+
statusText: response.statusText,
|
|
336
|
+
});
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
const errorText = await response.text();
|
|
339
|
+
logError("Token refresh failed", {
|
|
340
|
+
status: response.status,
|
|
341
|
+
error: errorText
|
|
342
|
+
});
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
const newTokens = (await response.json());
|
|
346
|
+
// Calculate absolute expiry time
|
|
347
|
+
if (newTokens.expires_in && !newTokens.expires_at) {
|
|
348
|
+
newTokens.expires_at = Math.floor(Date.now() / 1000) + newTokens.expires_in;
|
|
349
|
+
}
|
|
350
|
+
oauth.tokens = newTokens;
|
|
351
|
+
await persist();
|
|
352
|
+
logInfo("Token refresh successful", {
|
|
353
|
+
newExpiresAt: newTokens.expires_at,
|
|
354
|
+
newExpiresIn: newTokens.expires_in,
|
|
355
|
+
});
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
logError("Error refreshing token", error);
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function generateState() {
|
|
364
|
+
return Math.random().toString(36).substring(2, 15) +
|
|
365
|
+
Math.random().toString(36).substring(2, 15);
|
|
366
|
+
}
|
|
367
|
+
export function generateCodeVerifier() {
|
|
368
|
+
const array = new Uint8Array(32);
|
|
369
|
+
crypto.getRandomValues(array);
|
|
370
|
+
return btoa(String.fromCharCode(...array))
|
|
371
|
+
.replace(/\+/g, "-")
|
|
372
|
+
.replace(/\//g, "_")
|
|
373
|
+
.replace(/=/g, "");
|
|
374
|
+
}
|
|
375
|
+
async function generateCodeChallenge(verifier) {
|
|
376
|
+
const encoder = new TextEncoder();
|
|
377
|
+
const data = encoder.encode(verifier);
|
|
378
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
379
|
+
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
380
|
+
.replace(/\+/g, "-")
|
|
381
|
+
.replace(/\//g, "_")
|
|
382
|
+
.replace(/=+$/, "");
|
|
383
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Config, Profile } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Get a profile by alias
|
|
4
|
+
* @param config - The configuration object
|
|
5
|
+
* @param alias - The profile alias to look up
|
|
6
|
+
* @returns The profile if found, null otherwise
|
|
7
|
+
*/
|
|
8
|
+
export declare function getProfile(config: Config, alias: string): Profile | null;
|
|
9
|
+
/**
|
|
10
|
+
* Add or update a profile by alias
|
|
11
|
+
* @param config - The configuration object
|
|
12
|
+
* @param alias - The profile alias
|
|
13
|
+
* @param profile - The profile data to store
|
|
14
|
+
*/
|
|
15
|
+
export declare function addProfile(config: Config, alias: string, profile: Profile): void;
|
|
16
|
+
/**
|
|
17
|
+
* Remove a profile by alias
|
|
18
|
+
* @param config - The configuration object
|
|
19
|
+
* @param alias - The profile alias to remove
|
|
20
|
+
*/
|
|
21
|
+
export declare function removeProfile(config: Config, alias: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* List all profiles with their aliases
|
|
24
|
+
* @param config - The configuration object
|
|
25
|
+
* @returns Array of profile entries with alias and profile data
|
|
26
|
+
*/
|
|
27
|
+
export declare function listProfiles(config: Config): Array<{
|
|
28
|
+
alias: string;
|
|
29
|
+
profile: Profile;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Check if a profile exists by alias
|
|
33
|
+
* @param config - The configuration object
|
|
34
|
+
* @param alias - The profile alias to check
|
|
35
|
+
* @returns True if the profile exists, false otherwise
|
|
36
|
+
*/
|
|
37
|
+
export declare function profileExists(config: Config, alias: string): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get a profile by alias
|
|
3
|
+
* @param config - The configuration object
|
|
4
|
+
* @param alias - The profile alias to look up
|
|
5
|
+
* @returns The profile if found, null otherwise
|
|
6
|
+
*/
|
|
7
|
+
export function getProfile(config, alias) {
|
|
8
|
+
if (!config.profiles[alias]) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return config.profiles[alias];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Add or update a profile by alias
|
|
15
|
+
* @param config - The configuration object
|
|
16
|
+
* @param alias - The profile alias
|
|
17
|
+
* @param profile - The profile data to store
|
|
18
|
+
*/
|
|
19
|
+
export function addProfile(config, alias, profile) {
|
|
20
|
+
config.profiles[alias] = profile;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Remove a profile by alias
|
|
24
|
+
* @param config - The configuration object
|
|
25
|
+
* @param alias - The profile alias to remove
|
|
26
|
+
*/
|
|
27
|
+
export function removeProfile(config, alias) {
|
|
28
|
+
delete config.profiles[alias];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* List all profiles with their aliases
|
|
32
|
+
* @param config - The configuration object
|
|
33
|
+
* @returns Array of profile entries with alias and profile data
|
|
34
|
+
*/
|
|
35
|
+
export function listProfiles(config) {
|
|
36
|
+
return Object.entries(config.profiles).map(([alias, profile]) => ({
|
|
37
|
+
alias,
|
|
38
|
+
profile,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if a profile exists by alias
|
|
43
|
+
* @param config - The configuration object
|
|
44
|
+
* @param alias - The profile alias to check
|
|
45
|
+
* @returns True if the profile exists, false otherwise
|
|
46
|
+
*/
|
|
47
|
+
export function profileExists(config, alias) {
|
|
48
|
+
return alias in config.profiles;
|
|
49
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import type { McpTool } from "../types/index.js";
|
|
3
|
+
export interface ProxyOptions {
|
|
4
|
+
client: Client;
|
|
5
|
+
workspaceSlug: string;
|
|
6
|
+
tools: McpTool[];
|
|
7
|
+
writeEnabled: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Start the MCP proxy server that bridges stdio to HTTP
|
|
11
|
+
*/
|
|
12
|
+
export declare function startProxyServer(options: ProxyOptions): Promise<void>;
|