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.
- package/README.md +149 -19
- package/bin/postgres-ai.ts +1095 -0
- package/dist/bin/postgres-ai.d.ts +3 -0
- package/dist/bin/postgres-ai.d.ts.map +1 -0
- package/dist/bin/postgres-ai.js +972 -0
- package/dist/bin/postgres-ai.js.map +1 -0
- package/dist/lib/auth-server.d.ts +31 -0
- package/dist/lib/auth-server.d.ts.map +1 -0
- package/dist/lib/auth-server.js +263 -0
- package/dist/lib/auth-server.js.map +1 -0
- package/dist/lib/config.d.ts +45 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +181 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/issues.d.ts +7 -0
- package/dist/lib/issues.d.ts.map +1 -0
- package/dist/lib/issues.js +105 -0
- package/dist/lib/issues.js.map +1 -0
- package/dist/lib/mcp-server.d.ts +9 -0
- package/dist/lib/mcp-server.d.ts.map +1 -0
- package/dist/lib/mcp-server.js +114 -0
- package/dist/lib/mcp-server.js.map +1 -0
- package/dist/lib/pkce.d.ts +32 -0
- package/dist/lib/pkce.d.ts.map +1 -0
- package/dist/lib/pkce.js +101 -0
- package/dist/lib/pkce.js.map +1 -0
- package/dist/lib/util.d.ts +27 -0
- package/dist/lib/util.d.ts.map +1 -0
- package/dist/lib/util.js +46 -0
- package/dist/lib/util.js.map +1 -0
- package/dist/package.json +43 -0
- package/lib/auth-server.ts +267 -0
- package/lib/config.ts +161 -0
- package/lib/issues.ts +83 -0
- package/lib/mcp-server.ts +98 -0
- package/lib/pkce.ts +79 -0
- package/lib/util.ts +60 -0
- package/package.json +18 -10
- package/tsconfig.json +28 -0
- package/bin/postgres-ai.js +0 -703
|
@@ -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, "&")
|
|
30
|
+
.replace(/</g, "<")
|
|
31
|
+
.replace(/>/g, ">")
|
|
32
|
+
.replace(/"/g, """)
|
|
33
|
+
.replace(/'/g, "'");
|
|
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
|
+
|