postgresai 0.11.0-alpha.8 → 0.12.0-alpha.13

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.
@@ -0,0 +1,267 @@
1
+ import * as http from "http";
2
+ import { URL } from "url";
3
+
4
+ /**
5
+ * OAuth callback result
6
+ */
7
+ export interface CallbackResult {
8
+ code: string;
9
+ state: string;
10
+ }
11
+
12
+ /**
13
+ * Callback server structure
14
+ */
15
+ export interface CallbackServer {
16
+ server: http.Server;
17
+ promise: Promise<CallbackResult>;
18
+ getPort: () => number;
19
+ }
20
+
21
+ /**
22
+ * Simple HTML escape utility
23
+ * @param str - String to escape
24
+ * @returns Escaped string
25
+ */
26
+ function escapeHtml(str: string | null): string {
27
+ if (!str) return "";
28
+ return String(str)
29
+ .replace(/&/g, "&amp;")
30
+ .replace(/</g, "&lt;")
31
+ .replace(/>/g, "&gt;")
32
+ .replace(/"/g, "&quot;")
33
+ .replace(/'/g, "&#39;");
34
+ }
35
+
36
+ /**
37
+ * Create and start callback server, returning server object and promise
38
+ * @param port - Port to listen on (0 for random available port)
39
+ * @param expectedState - Expected state parameter for CSRF protection
40
+ * @param timeoutMs - Timeout in milliseconds
41
+ * @returns Server object with promise and getPort function
42
+ */
43
+ export function createCallbackServer(
44
+ port: number = 0,
45
+ expectedState: string | null = null,
46
+ timeoutMs: number = 300000
47
+ ): CallbackServer {
48
+ let resolved = false;
49
+ let server: http.Server | null = null;
50
+ let actualPort = port;
51
+ let resolveCallback: (value: CallbackResult) => void;
52
+ let rejectCallback: (reason: Error) => void;
53
+
54
+ const promise = new Promise<CallbackResult>((resolve, reject) => {
55
+ resolveCallback = resolve;
56
+ rejectCallback = reject;
57
+ });
58
+
59
+ // Timeout handler
60
+ const timeout = setTimeout(() => {
61
+ if (!resolved) {
62
+ resolved = true;
63
+ if (server) {
64
+ server.close();
65
+ }
66
+ rejectCallback(new Error("Authentication timeout. Please try again."));
67
+ }
68
+ }, timeoutMs);
69
+
70
+ // Request handler
71
+ const requestHandler = (req: http.IncomingMessage, res: http.ServerResponse): void => {
72
+ if (resolved) {
73
+ return;
74
+ }
75
+
76
+ // Only handle /callback path
77
+ if (!req.url || !req.url.startsWith("/callback")) {
78
+ res.writeHead(404, { "Content-Type": "text/plain" });
79
+ res.end("Not Found");
80
+ return;
81
+ }
82
+
83
+ try {
84
+ const url = new URL(req.url, `http://localhost:${actualPort}`);
85
+ const code = url.searchParams.get("code");
86
+ const state = url.searchParams.get("state");
87
+ const error = url.searchParams.get("error");
88
+ const errorDescription = url.searchParams.get("error_description");
89
+
90
+ // Handle OAuth error
91
+ if (error) {
92
+ resolved = true;
93
+ clearTimeout(timeout);
94
+
95
+ res.writeHead(400, { "Content-Type": "text/html" });
96
+ res.end(`
97
+ <!DOCTYPE html>
98
+ <html>
99
+ <head>
100
+ <title>Authentication failed</title>
101
+ <style>
102
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
103
+ .error { background: #fee; border: 1px solid #fcc; padding: 20px; border-radius: 8px; }
104
+ h1 { color: #c33; margin-top: 0; }
105
+ code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }
106
+ </style>
107
+ </head>
108
+ <body>
109
+ <div class="error">
110
+ <h1>Authentication failed</h1>
111
+ <p><strong>Error:</strong> ${escapeHtml(error)}</p>
112
+ ${errorDescription ? `<p><strong>Description:</strong> ${escapeHtml(errorDescription)}</p>` : ""}
113
+ <p>You can close this window and return to your terminal.</p>
114
+ </div>
115
+ </body>
116
+ </html>
117
+ `);
118
+
119
+ if (server) {
120
+ server.close();
121
+ }
122
+ rejectCallback(new Error(`OAuth error: ${error}${errorDescription ? ` - ${errorDescription}` : ""}`));
123
+ return;
124
+ }
125
+
126
+ // Validate required parameters
127
+ if (!code || !state) {
128
+ res.writeHead(400, { "Content-Type": "text/html" });
129
+ res.end(`
130
+ <!DOCTYPE html>
131
+ <html>
132
+ <head>
133
+ <title>Authentication failed</title>
134
+ <style>
135
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
136
+ .error { background: #fee; border: 1px solid #fcc; padding: 20px; border-radius: 8px; }
137
+ h1 { color: #c33; margin-top: 0; }
138
+ </style>
139
+ </head>
140
+ <body>
141
+ <div class="error">
142
+ <h1>Authentication failed</h1>
143
+ <p>Missing required parameters (code or state).</p>
144
+ <p>You can close this window and return to your terminal.</p>
145
+ </div>
146
+ </body>
147
+ </html>
148
+ `);
149
+ return;
150
+ }
151
+
152
+ // Validate state (CSRF protection)
153
+ if (expectedState && state !== expectedState) {
154
+ resolved = true;
155
+ clearTimeout(timeout);
156
+
157
+ res.writeHead(400, { "Content-Type": "text/html" });
158
+ res.end(`
159
+ <!DOCTYPE html>
160
+ <html>
161
+ <head>
162
+ <title>Authentication failed</title>
163
+ <style>
164
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
165
+ .error { background: #fee; border: 1px solid #fcc; padding: 20px; border-radius: 8px; }
166
+ h1 { color: #c33; margin-top: 0; }
167
+ </style>
168
+ </head>
169
+ <body>
170
+ <div class="error">
171
+ <h1>Authentication failed</h1>
172
+ <p>Invalid state parameter (possible CSRF attack).</p>
173
+ <p>You can close this window and return to your terminal.</p>
174
+ </div>
175
+ </body>
176
+ </html>
177
+ `);
178
+
179
+ if (server) {
180
+ server.close();
181
+ }
182
+ rejectCallback(new Error("State mismatch (possible CSRF attack)"));
183
+ return;
184
+ }
185
+
186
+ // Success!
187
+ resolved = true;
188
+ clearTimeout(timeout);
189
+
190
+ res.writeHead(200, { "Content-Type": "text/html" });
191
+ res.end(`
192
+ <!DOCTYPE html>
193
+ <html>
194
+ <head>
195
+ <title>Authentication successful</title>
196
+ <style>
197
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
198
+ .success { background: #efe; border: 1px solid #cfc; padding: 20px; border-radius: 8px; }
199
+ h1 { color: #3c3; margin-top: 0; }
200
+ </style>
201
+ </head>
202
+ <body>
203
+ <div class="success">
204
+ <h1>Authentication successful</h1>
205
+ <p>You have successfully authenticated the PostgresAI CLI.</p>
206
+ <p>You can close this window and return to your terminal.</p>
207
+ </div>
208
+ </body>
209
+ </html>
210
+ `);
211
+
212
+ if (server) {
213
+ server.close();
214
+ }
215
+ resolveCallback({ code, state });
216
+ } catch (err) {
217
+ if (!resolved) {
218
+ resolved = true;
219
+ clearTimeout(timeout);
220
+ res.writeHead(500, { "Content-Type": "text/plain" });
221
+ res.end("Internal Server Error");
222
+ if (server) {
223
+ server.close();
224
+ }
225
+ rejectCallback(err instanceof Error ? err : new Error(String(err)));
226
+ }
227
+ }
228
+ };
229
+
230
+ // Create server
231
+ server = http.createServer(requestHandler);
232
+
233
+ server.on("error", (err: Error) => {
234
+ if (!resolved) {
235
+ resolved = true;
236
+ clearTimeout(timeout);
237
+ rejectCallback(err);
238
+ }
239
+ });
240
+
241
+ server.listen(port, "127.0.0.1", () => {
242
+ const address = server?.address();
243
+ if (address && typeof address === "object") {
244
+ actualPort = address.port;
245
+ }
246
+ });
247
+
248
+ return {
249
+ server,
250
+ promise,
251
+ getPort: () => {
252
+ const address = server?.address();
253
+ return address && typeof address === "object" ? address.port : 0;
254
+ },
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Get the actual port the server is listening on
260
+ * @param server - HTTP server instance
261
+ * @returns Port number
262
+ */
263
+ export function getServerPort(server: http.Server): number {
264
+ const address = server.address();
265
+ return address && typeof address === "object" ? address.port : 0;
266
+ }
267
+
package/lib/config.ts ADDED
@@ -0,0 +1,161 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+
5
+ /**
6
+ * Configuration object structure
7
+ */
8
+ export interface Config {
9
+ apiKey: string | null;
10
+ baseUrl: string | null;
11
+ orgId: number | null;
12
+ }
13
+
14
+ /**
15
+ * Get the user-level config directory path
16
+ * @returns Path to ~/.config/postgresai
17
+ */
18
+ export function getConfigDir(): string {
19
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
20
+ return path.join(configHome, "postgresai");
21
+ }
22
+
23
+ /**
24
+ * Get the user-level config file path
25
+ * @returns Path to ~/.config/postgresai/config.json
26
+ */
27
+ export function getConfigPath(): string {
28
+ return path.join(getConfigDir(), "config.json");
29
+ }
30
+
31
+ /**
32
+ * Get the legacy project-local config file path
33
+ * @returns Path to .pgwatch-config in current directory
34
+ */
35
+ export function getLegacyConfigPath(): string {
36
+ return path.resolve(process.cwd(), ".pgwatch-config");
37
+ }
38
+
39
+ /**
40
+ * Read configuration from file
41
+ * Tries user-level config first, then falls back to legacy project-local config
42
+ * @returns Configuration object with apiKey, baseUrl, orgId
43
+ */
44
+ export function readConfig(): Config {
45
+ const config: Config = {
46
+ apiKey: null,
47
+ baseUrl: null,
48
+ orgId: null,
49
+ };
50
+
51
+ // Try user-level config first
52
+ const userConfigPath = getConfigPath();
53
+ if (fs.existsSync(userConfigPath)) {
54
+ try {
55
+ const content = fs.readFileSync(userConfigPath, "utf8");
56
+ const parsed = JSON.parse(content);
57
+ config.apiKey = parsed.apiKey || null;
58
+ config.baseUrl = parsed.baseUrl || null;
59
+ config.orgId = parsed.orgId || null;
60
+ return config;
61
+ } catch (err) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ console.error(`Warning: Failed to read config from ${userConfigPath}: ${message}`);
64
+ }
65
+ }
66
+
67
+ // Fall back to legacy project-local config
68
+ const legacyPath = getLegacyConfigPath();
69
+ if (fs.existsSync(legacyPath)) {
70
+ try {
71
+ const stats = fs.statSync(legacyPath);
72
+ if (stats.isFile()) {
73
+ const content = fs.readFileSync(legacyPath, "utf8");
74
+ const lines = content.split(/\r?\n/);
75
+ for (const line of lines) {
76
+ const match = line.match(/^api_key=(.+)$/);
77
+ if (match) {
78
+ config.apiKey = match[1].trim();
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ } catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ console.error(`Warning: Failed to read legacy config from ${legacyPath}: ${message}`);
86
+ }
87
+ }
88
+
89
+ return config;
90
+ }
91
+
92
+ /**
93
+ * Write configuration to user-level config file
94
+ * @param config - Configuration object with apiKey, baseUrl, orgId
95
+ */
96
+ export function writeConfig(config: Partial<Config>): void {
97
+ const configDir = getConfigDir();
98
+ const configPath = getConfigPath();
99
+
100
+ // Ensure config directory exists
101
+ if (!fs.existsSync(configDir)) {
102
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
103
+ }
104
+
105
+ // Read existing config and merge
106
+ let existingConfig: Record<string, unknown> = {};
107
+ if (fs.existsSync(configPath)) {
108
+ try {
109
+ const content = fs.readFileSync(configPath, "utf8");
110
+ existingConfig = JSON.parse(content);
111
+ } catch (err) {
112
+ // Ignore parse errors, will overwrite
113
+ }
114
+ }
115
+
116
+ const mergedConfig = {
117
+ ...existingConfig,
118
+ ...config,
119
+ };
120
+
121
+ // Write config file with restricted permissions
122
+ fs.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2) + "\n", {
123
+ mode: 0o600,
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Delete specific keys from configuration
129
+ * @param keys - Array of keys to delete (e.g., ['apiKey'])
130
+ */
131
+ export function deleteConfigKeys(keys: string[]): void {
132
+ const configPath = getConfigPath();
133
+ if (!fs.existsSync(configPath)) {
134
+ return;
135
+ }
136
+
137
+ try {
138
+ const content = fs.readFileSync(configPath, "utf8");
139
+ const config: Record<string, unknown> = JSON.parse(content);
140
+
141
+ for (const key of keys) {
142
+ delete config[key];
143
+ }
144
+
145
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", {
146
+ mode: 0o600,
147
+ });
148
+ } catch (err) {
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ console.error(`Warning: Failed to update config: ${message}`);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Check if config file exists
156
+ * @returns True if config exists
157
+ */
158
+ export function configExists(): boolean {
159
+ return fs.existsSync(getConfigPath()) || fs.existsSync(getLegacyConfigPath());
160
+ }
161
+
package/lib/issues.ts ADDED
@@ -0,0 +1,83 @@
1
+ import * as https from "https";
2
+ import { URL } from "url";
3
+ import { maskSecret, normalizeBaseUrl } from "./util";
4
+
5
+ export interface FetchIssuesParams {
6
+ apiKey: string;
7
+ apiBaseUrl: string;
8
+ debug?: boolean;
9
+ }
10
+
11
+ export async function fetchIssues(params: FetchIssuesParams): Promise<unknown> {
12
+ const { apiKey, apiBaseUrl, debug } = params;
13
+ if (!apiKey) {
14
+ throw new Error("API key is required");
15
+ }
16
+
17
+ const base = normalizeBaseUrl(apiBaseUrl);
18
+ const url = new URL(`${base}/issues`);
19
+
20
+ const headers: Record<string, string> = {
21
+ "access-token": apiKey,
22
+ "Prefer": "return=representation",
23
+ "Content-Type": "application/json",
24
+ };
25
+
26
+ if (debug) {
27
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
28
+ // eslint-disable-next-line no-console
29
+ console.log(`Debug: Resolved API base URL: ${base}`);
30
+ // eslint-disable-next-line no-console
31
+ console.log(`Debug: GET URL: ${url.toString()}`);
32
+ // eslint-disable-next-line no-console
33
+ console.log(`Debug: Auth scheme: access-token`);
34
+ // eslint-disable-next-line no-console
35
+ console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
36
+ }
37
+
38
+ return new Promise((resolve, reject) => {
39
+ const req = https.request(
40
+ url,
41
+ {
42
+ method: "GET",
43
+ headers,
44
+ },
45
+ (res) => {
46
+ let data = "";
47
+ res.on("data", (chunk) => (data += chunk));
48
+ res.on("end", () => {
49
+ if (debug) {
50
+ // eslint-disable-next-line no-console
51
+ console.log(`Debug: Response status: ${res.statusCode}`);
52
+ // eslint-disable-next-line no-console
53
+ console.log(`Debug: Response headers: ${JSON.stringify(res.headers)}`);
54
+ }
55
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
56
+ try {
57
+ const parsed = JSON.parse(data);
58
+ resolve(parsed);
59
+ } catch {
60
+ resolve(data);
61
+ }
62
+ } else {
63
+ let errMsg = `Failed to fetch issues: HTTP ${res.statusCode}`;
64
+ if (data) {
65
+ try {
66
+ const errObj = JSON.parse(data);
67
+ errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
68
+ } catch {
69
+ errMsg += `\n${data}`;
70
+ }
71
+ }
72
+ reject(new Error(errMsg));
73
+ }
74
+ });
75
+ }
76
+ );
77
+
78
+ req.on("error", (err: Error) => reject(err));
79
+ req.end();
80
+ });
81
+ }
82
+
83
+
@@ -0,0 +1,98 @@
1
+ import * as pkg from "../package.json";
2
+ import * as config from "./config";
3
+ import { fetchIssues } from "./issues";
4
+ import { resolveBaseUrls } from "./util";
5
+
6
+ // MCP SDK imports
7
+ import { Server } from "@modelcontextprotocol/sdk/server";
8
+ import * as path from "path";
9
+ // Types schemas will be loaded dynamically from the SDK's CJS bundle
10
+
11
+ interface RootOptsLike {
12
+ apiKey?: string;
13
+ apiBaseUrl?: string;
14
+ }
15
+
16
+ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?: boolean }): Promise<void> {
17
+ // Resolve stdio transport at runtime to avoid subpath export resolution issues
18
+ const serverEntry = require.resolve("@modelcontextprotocol/sdk/server");
19
+ const stdioPath = path.join(path.dirname(serverEntry), "stdio.js");
20
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
21
+ const { StdioServerTransport } = require(stdioPath);
22
+ // Load schemas dynamically to avoid subpath export resolution issues
23
+ const typesPath = path.resolve(path.dirname(serverEntry), "../types.js");
24
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
25
+ const { CallToolRequestSchema, ListToolsRequestSchema } = require(typesPath);
26
+
27
+ const server = new Server(
28
+ { name: "postgresai-mcp", version: pkg.version },
29
+ { capabilities: { tools: {} } }
30
+ );
31
+
32
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
33
+ return {
34
+ tools: [
35
+ {
36
+ name: "list_issues",
37
+ description: "List issues from PostgresAI API (same as CLI 'issues list')",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
42
+ },
43
+ additionalProperties: false,
44
+ },
45
+ },
46
+ ],
47
+ };
48
+ });
49
+
50
+ server.setRequestHandler(CallToolRequestSchema, async (req: any) => {
51
+ const toolName = req.params.name;
52
+ const args = (req.params.arguments as Record<string, unknown>) || {};
53
+
54
+ if (toolName !== "list_issues") {
55
+ throw new Error(`Unknown tool: ${toolName}`);
56
+ }
57
+
58
+ const cfg = config.readConfig();
59
+ const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
60
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
61
+
62
+ const debug = Boolean(args.debug ?? extra?.debug);
63
+
64
+ if (!apiKey) {
65
+ return {
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: "API key is required. Run 'pgai auth' or set PGAI_API_KEY.",
70
+ },
71
+ ],
72
+ isError: true,
73
+ };
74
+ }
75
+
76
+ try {
77
+ const result = await fetchIssues({ apiKey, apiBaseUrl, debug });
78
+ return {
79
+ content: [
80
+ { type: "text", text: JSON.stringify(result, null, 2) },
81
+ ],
82
+ };
83
+ } catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ return {
86
+ content: [
87
+ { type: "text", text: message },
88
+ ],
89
+ isError: true,
90
+ };
91
+ }
92
+ });
93
+
94
+ const transport = new StdioServerTransport();
95
+ await server.connect(transport);
96
+ }
97
+
98
+
package/lib/pkce.ts ADDED
@@ -0,0 +1,79 @@
1
+ import * as crypto from "crypto";
2
+
3
+ /**
4
+ * PKCE parameters for OAuth 2.0 Authorization Code Flow with PKCE
5
+ */
6
+ export interface PKCEParams {
7
+ codeVerifier: string;
8
+ codeChallenge: string;
9
+ codeChallengeMethod: "S256";
10
+ state: string;
11
+ }
12
+
13
+ /**
14
+ * Generate a cryptographically random string for PKCE
15
+ * @param length - Length of the string (43-128 characters per RFC 7636)
16
+ * @returns Base64URL-encoded random string
17
+ */
18
+ function generateRandomString(length: number = 64): string {
19
+ const bytes = crypto.randomBytes(length);
20
+ return base64URLEncode(bytes);
21
+ }
22
+
23
+ /**
24
+ * Base64URL encode (without padding)
25
+ * @param buffer - Buffer to encode
26
+ * @returns Base64URL-encoded string
27
+ */
28
+ function base64URLEncode(buffer: Buffer): string {
29
+ return buffer
30
+ .toString("base64")
31
+ .replace(/\+/g, "-")
32
+ .replace(/\//g, "_")
33
+ .replace(/=/g, "");
34
+ }
35
+
36
+ /**
37
+ * Generate PKCE code verifier
38
+ * @returns Random code verifier (43-128 characters)
39
+ */
40
+ export function generateCodeVerifier(): string {
41
+ return generateRandomString(32); // 32 bytes = 43 chars after base64url encoding
42
+ }
43
+
44
+ /**
45
+ * Generate PKCE code challenge from verifier
46
+ * Uses S256 method (SHA256)
47
+ * @param verifier - Code verifier string
48
+ * @returns Base64URL-encoded SHA256 hash of verifier
49
+ */
50
+ export function generateCodeChallenge(verifier: string): string {
51
+ const hash = crypto.createHash("sha256").update(verifier).digest();
52
+ return base64URLEncode(hash);
53
+ }
54
+
55
+ /**
56
+ * Generate random state for CSRF protection
57
+ * @returns Random state string
58
+ */
59
+ export function generateState(): string {
60
+ return generateRandomString(16); // 16 bytes = 22 chars
61
+ }
62
+
63
+ /**
64
+ * Generate complete PKCE parameters
65
+ * @returns Object with verifier, challenge, challengeMethod, and state
66
+ */
67
+ export function generatePKCEParams(): PKCEParams {
68
+ const verifier = generateCodeVerifier();
69
+ const challenge = generateCodeChallenge(verifier);
70
+ const state = generateState();
71
+
72
+ return {
73
+ codeVerifier: verifier,
74
+ codeChallenge: challenge,
75
+ codeChallengeMethod: "S256",
76
+ state: state,
77
+ };
78
+ }
79
+