neon 4.1.0 → 4.2.1
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 +38 -0
- package/dist/commands/auth.js +7 -1
- package/dist/commands/branches.js +2 -2
- package/dist/commands/index.js +2 -0
- package/dist/commands/mcp.js +212 -0
- package/dist/commands/projects.js +11 -6
- package/dist/commands/snapshots.js +2 -2
- package/dist/context.js +23 -1
- package/dist/human_table.js +18 -51
- package/dist/index.js +1 -0
- package/dist/init/agents.js +2 -51
- package/dist/init/editors.js +2 -1
- package/dist/init/interactive.js +3 -2
- package/dist/init/orchestrate.js +2 -1
- package/dist/init/phases/mcp.js +3 -2
- package/dist/init/phases/setup.js +3 -2
- package/dist/init/phases/status.js +2 -1
- package/dist/mcp/agents.js +64 -0
- package/dist/mcp/install.js +216 -0
- package/dist/mcp/mint.js +46 -0
- package/dist/mcp/plan.js +43 -0
- package/dist/mcp/targets.js +34 -0
- package/dist/mcp/wizard.js +91 -0
- package/dist/test_utils/fixtures.js +7 -4
- package/dist/utils/agent_picker.js +70 -0
- package/package.json +3 -3
- package/dist/init/install_mcp.js +0 -35
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { neonInitAgentCmd } from "../profile_cli.js";
|
|
2
|
-
import {
|
|
2
|
+
import { tryResolveAddMcpAgentId } from "../../mcp/agents.js";
|
|
3
|
+
import { supportsSkills } from "../agents.js";
|
|
3
4
|
import { isAuthenticated } from "../auth.js";
|
|
4
5
|
import { inspectProject } from "../inspect.js";
|
|
5
6
|
//#region src/init/phases/status.ts
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { agents, detectGlobalAgents, getAgentTypes } from "add-mcp";
|
|
2
|
+
//#region src/mcp/agents.ts
|
|
3
|
+
const AGENT_ALIASES = {
|
|
4
|
+
cursor: "cursor",
|
|
5
|
+
copilot: "vscode",
|
|
6
|
+
"github-copilot": "vscode",
|
|
7
|
+
"vs-code": "vscode",
|
|
8
|
+
vscode: "vscode",
|
|
9
|
+
claude: "claude-code",
|
|
10
|
+
"claude-code": "claude-code",
|
|
11
|
+
"claude-desktop": "claude-desktop",
|
|
12
|
+
codex: "codex",
|
|
13
|
+
opencode: "opencode",
|
|
14
|
+
antigravity: "antigravity",
|
|
15
|
+
cline: "cline",
|
|
16
|
+
"cline-cli": "cline-cli",
|
|
17
|
+
"cline-vscode": "cline",
|
|
18
|
+
"gemini-cli": "gemini-cli",
|
|
19
|
+
gemini: "gemini-cli",
|
|
20
|
+
goose: "goose",
|
|
21
|
+
windsurf: "windsurf",
|
|
22
|
+
codeium: "windsurf",
|
|
23
|
+
cascade: "windsurf",
|
|
24
|
+
"github-copilot-cli": "github-copilot-cli",
|
|
25
|
+
mcporter: "mcporter",
|
|
26
|
+
zed: "zed",
|
|
27
|
+
grok: "grok-build",
|
|
28
|
+
"grok-build": "grok-build"
|
|
29
|
+
};
|
|
30
|
+
function listMcpAgentIds() {
|
|
31
|
+
return getAgentTypes();
|
|
32
|
+
}
|
|
33
|
+
function getAgentDisplayName(id) {
|
|
34
|
+
return agents[id].displayName;
|
|
35
|
+
}
|
|
36
|
+
function agentSupportsProjectMcp(id) {
|
|
37
|
+
return Boolean(agents[id].localConfigPath);
|
|
38
|
+
}
|
|
39
|
+
function agentSupportsHttpMcp(id) {
|
|
40
|
+
return agents[id].supportedTransports.includes("http");
|
|
41
|
+
}
|
|
42
|
+
function tryResolveAddMcpAgentId(rawAgent) {
|
|
43
|
+
return AGENT_ALIASES[rawAgent.toLowerCase()];
|
|
44
|
+
}
|
|
45
|
+
function resolveAddMcpAgentId(rawAgent) {
|
|
46
|
+
const resolved = tryResolveAddMcpAgentId(rawAgent);
|
|
47
|
+
if (!resolved) throw new Error(`Unknown agent: "${rawAgent}". Supported agents: ${listMcpAgentIds().join(", ")}`);
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
async function detectInstalledAgents() {
|
|
51
|
+
return detectGlobalAgents();
|
|
52
|
+
}
|
|
53
|
+
function uniqueAgentIds(ids) {
|
|
54
|
+
const seen = /* @__PURE__ */ new Set();
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
if (seen.has(id)) continue;
|
|
58
|
+
seen.add(id);
|
|
59
|
+
out.push(id);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { agentSupportsHttpMcp, agentSupportsProjectMcp, detectInstalledAgents, getAgentDisplayName, listMcpAgentIds, resolveAddMcpAgentId, tryResolveAddMcpAgentId, uniqueAgentIds };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { agents, upsertServer } from "add-mcp";
|
|
5
|
+
//#region src/mcp/install.ts
|
|
6
|
+
const NEON_MCP_URL = "https://mcp.neon.tech/mcp";
|
|
7
|
+
const NEON_MCP_NAME = "Neon";
|
|
8
|
+
const NEON_MCP_CATEGORIES = [
|
|
9
|
+
"projects",
|
|
10
|
+
"branches",
|
|
11
|
+
"schema",
|
|
12
|
+
"querying",
|
|
13
|
+
"neon_auth",
|
|
14
|
+
"data_api",
|
|
15
|
+
"observability",
|
|
16
|
+
"docs"
|
|
17
|
+
];
|
|
18
|
+
function isMcpCategory(value) {
|
|
19
|
+
for (const category of NEON_MCP_CATEGORIES) if (category === value) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function parseMcpCategories(raw) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const value of raw) {
|
|
25
|
+
if (!isMcpCategory(value)) throw new Error(`Unknown MCP category: "${value}". Supported categories: ${NEON_MCP_CATEGORIES.join(", ")}`);
|
|
26
|
+
out.push(value);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function isNeonMcpUrl(value) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(value);
|
|
33
|
+
return url.protocol === "https:" && url.hostname === "mcp.neon.tech" && url.pathname === "/mcp";
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function neonMcpUrl(options = {}) {
|
|
39
|
+
const url = new URL(NEON_MCP_URL);
|
|
40
|
+
if (options.readOnly === true) url.searchParams.set("readonly", "true");
|
|
41
|
+
for (const category of options.categories ?? []) url.searchParams.append("category", category);
|
|
42
|
+
const projectId = options.projectId?.trim();
|
|
43
|
+
if (projectId) url.searchParams.set("projectId", projectId);
|
|
44
|
+
return url.href;
|
|
45
|
+
}
|
|
46
|
+
const BEARER_RE = /Bearer\s+([^\s"]+)/i;
|
|
47
|
+
function nestedValue(root, key) {
|
|
48
|
+
let current = root;
|
|
49
|
+
for (const part of key.split(".")) {
|
|
50
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return;
|
|
51
|
+
current = current[part];
|
|
52
|
+
}
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
function recordOf(value) {
|
|
56
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function bearerFromNeonMcpConfig(config) {
|
|
60
|
+
const record = recordOf(config);
|
|
61
|
+
if (!record || typeof record.url !== "string" || !isNeonMcpUrl(record.url)) return;
|
|
62
|
+
const headerRecord = recordOf(record.headers ?? record.http_headers);
|
|
63
|
+
if (!headerRecord) return;
|
|
64
|
+
const authorization = headerRecord.Authorization ?? headerRecord.authorization;
|
|
65
|
+
if (typeof authorization !== "string") return;
|
|
66
|
+
return BEARER_RE.exec(authorization.trim())?.[1];
|
|
67
|
+
}
|
|
68
|
+
function neonServers(value) {
|
|
69
|
+
const record = recordOf(value);
|
|
70
|
+
if (!record) return;
|
|
71
|
+
return record["Neon"] ?? record.neon;
|
|
72
|
+
}
|
|
73
|
+
function neonApiKeyFromFile(path, configKey) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = readFileSync(path, "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return bearerFromNeonMcpConfig(neonServers(nestedValue(JSON.parse(raw), configKey)));
|
|
82
|
+
} catch {
|
|
83
|
+
const neonBlock = raw.match(/\[mcp_servers\.Neon\][\s\S]*?(?=\n\[|$)/)?.[0];
|
|
84
|
+
if (!neonBlock) return;
|
|
85
|
+
const url = neonBlock.match(/^url\s*=\s*"([^"]*)"/m)?.[1];
|
|
86
|
+
if (!url || !isNeonMcpUrl(url)) return;
|
|
87
|
+
return BEARER_RE.exec(neonBlock)?.[1];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function configPathFor(agent, scope, cwd) {
|
|
91
|
+
const spec = agents[agent];
|
|
92
|
+
if (scope === "project") {
|
|
93
|
+
if (!spec.localConfigPath) return;
|
|
94
|
+
return join(cwd, spec.localConfigPath);
|
|
95
|
+
}
|
|
96
|
+
return spec.configPath;
|
|
97
|
+
}
|
|
98
|
+
function configKeyFor(agent, scope) {
|
|
99
|
+
const spec = agents[agent];
|
|
100
|
+
if (scope === "project" && spec.localConfigKey) return spec.localConfigKey;
|
|
101
|
+
return spec.configKey;
|
|
102
|
+
}
|
|
103
|
+
function existingNeonApiKey(options) {
|
|
104
|
+
for (const agent of options.agents) {
|
|
105
|
+
const path = configPathFor(agent, options.scope, options.cwd);
|
|
106
|
+
if (!path) continue;
|
|
107
|
+
const key = neonApiKeyFromFile(path, configKeyFor(agent, options.scope));
|
|
108
|
+
if (key) return key;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isTrackedByGit(cwd, relativePath) {
|
|
112
|
+
try {
|
|
113
|
+
execFileSync("git", [
|
|
114
|
+
"-C",
|
|
115
|
+
cwd,
|
|
116
|
+
"ls-files",
|
|
117
|
+
"--error-unmatch",
|
|
118
|
+
"--",
|
|
119
|
+
relativePath
|
|
120
|
+
], { stdio: [
|
|
121
|
+
"ignore",
|
|
122
|
+
"pipe",
|
|
123
|
+
"ignore"
|
|
124
|
+
] });
|
|
125
|
+
return true;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function trackedProjectMcpConfig(options) {
|
|
131
|
+
for (const agent of options.agents) {
|
|
132
|
+
const relativePath = agents[agent].localConfigPath;
|
|
133
|
+
if (!relativePath) continue;
|
|
134
|
+
if (isTrackedByGit(options.cwd, relativePath)) return relativePath;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function mcpUnsupportedReason(agent, scope) {
|
|
138
|
+
const spec = agents[agent];
|
|
139
|
+
if (!spec.supportedTransports.includes("http")) return spec.unsupportedTransportMessage ?? `${spec.displayName} does not support remote HTTP MCP servers.`;
|
|
140
|
+
if (scope === "project" && !spec.localConfigPath) return `${spec.displayName} does not support project-level MCP config.`;
|
|
141
|
+
}
|
|
142
|
+
function gitignoreProjectConfig(path) {
|
|
143
|
+
const dir = dirname(path);
|
|
144
|
+
const entry = basename(path);
|
|
145
|
+
const gitignorePath = join(dir, ".gitignore");
|
|
146
|
+
try {
|
|
147
|
+
if (!existsSync(gitignorePath)) {
|
|
148
|
+
writeFileSync(gitignorePath, `${entry}\n`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const current = readFileSync(gitignorePath, "utf8");
|
|
152
|
+
if (current.split(/\r?\n/).some((line) => line.trim() === entry)) return;
|
|
153
|
+
const needsLeadingNewline = current.length > 0 && !current.endsWith("\n");
|
|
154
|
+
writeFileSync(gitignorePath, `${current}${needsLeadingNewline ? "\n" : ""}${entry}\n`);
|
|
155
|
+
return;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return `Could not gitignore ${path}: ${err instanceof Error ? err.message : String(err)}`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function protectWrittenConfig(path, scope) {
|
|
161
|
+
try {
|
|
162
|
+
chmodSync(path, 384);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return `Could not restrict permissions on ${path}: ${err instanceof Error ? err.message : String(err)}`;
|
|
165
|
+
}
|
|
166
|
+
if (scope === "project") return gitignoreProjectConfig(path);
|
|
167
|
+
}
|
|
168
|
+
function redactSecrets(text) {
|
|
169
|
+
return text.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/napi_[A-Za-z0-9_]+/gi, "napi_[redacted]");
|
|
170
|
+
}
|
|
171
|
+
function installNeonMcpServer(options) {
|
|
172
|
+
const auth = options.auth ?? { kind: "oauth" };
|
|
173
|
+
const url = options.url ?? "https://mcp.neon.tech/mcp";
|
|
174
|
+
const unsupported = mcpUnsupportedReason(options.agent, options.scope);
|
|
175
|
+
if (unsupported) return {
|
|
176
|
+
ok: false,
|
|
177
|
+
unsupported: true,
|
|
178
|
+
error: unsupported
|
|
179
|
+
};
|
|
180
|
+
const server = auth.kind === "api-key" ? {
|
|
181
|
+
type: "http",
|
|
182
|
+
url,
|
|
183
|
+
headers: { Authorization: `Bearer ${auth.apiKey}` }
|
|
184
|
+
} : {
|
|
185
|
+
type: "http",
|
|
186
|
+
url
|
|
187
|
+
};
|
|
188
|
+
const result = upsertServer(options.agent, NEON_MCP_NAME, server, {
|
|
189
|
+
local: options.scope === "project",
|
|
190
|
+
cwd: options.cwd
|
|
191
|
+
});
|
|
192
|
+
if (result.success) {
|
|
193
|
+
if (auth.kind === "api-key") {
|
|
194
|
+
const paths = [result.path, ...result.extraPaths ?? []];
|
|
195
|
+
for (const path of paths) {
|
|
196
|
+
const error = protectWrittenConfig(path, options.scope);
|
|
197
|
+
if (error) return {
|
|
198
|
+
ok: false,
|
|
199
|
+
unsupported: false,
|
|
200
|
+
error
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
path: result.path
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
unsupported: false,
|
|
212
|
+
error: redactSecrets(result.error ?? "Failed to write Neon MCP server config")
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
export { NEON_MCP_CATEGORIES, NEON_MCP_NAME, NEON_MCP_URL, existingNeonApiKey, installNeonMcpServer, isMcpCategory, isNeonMcpUrl, mcpUnsupportedReason, neonMcpUrl, parseMcpCategories, trackedProjectMcpConfig };
|
package/dist/mcp/mint.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { log } from "../log.js";
|
|
2
|
+
import { isNeonApiError } from "../api.js";
|
|
3
|
+
import { getCliName } from "../utils/cli_name.js";
|
|
4
|
+
import { mintedKeyName } from "../profile_keys.js";
|
|
5
|
+
//#region src/mcp/mint.ts
|
|
6
|
+
const cannotMintMessage = "This CLI credential cannot mint API keys. Organization and project-scoped keys cannot create other keys. Sign in with `neon auth` or pass a personal API key.";
|
|
7
|
+
const withdraw = async (client, keyId) => {
|
|
8
|
+
if (!Number.isSafeInteger(keyId) || keyId === void 0 || keyId <= 0) return false;
|
|
9
|
+
try {
|
|
10
|
+
const { data } = await client.revokeApiKey(keyId);
|
|
11
|
+
return data.revoked === true && data.id === keyId;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
log.error("Failed to revoke API key %d: %s", keyId, err instanceof Error ? err.message : String(err));
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const assertUsable = async (client, data, fallbackName) => {
|
|
18
|
+
const problem = typeof data.key !== "string" || data.key.trim() === "" ? "Neon returned no key." : data.project_id !== void 0 ? `Neon returned a key scoped to ${data.project_id} rather than the whole account.` : !Number.isSafeInteger(data.id) ? "Neon returned no key id." : null;
|
|
19
|
+
if (problem) {
|
|
20
|
+
const withdrawn = await withdraw(client, data.id);
|
|
21
|
+
throw new Error(`${problem} ${withdrawn ? "The key has been revoked; nothing was issued." : `The key could NOT be revoked and may still be live${data.id === void 0 ? "" : `. Remove it with \`${getCliName()} api-keys revoke ${data.id}\``}.`}`);
|
|
22
|
+
}
|
|
23
|
+
const id = data.id;
|
|
24
|
+
const key = data.key;
|
|
25
|
+
if (id === void 0 || key === void 0) throw new Error("Neon returned no key.");
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
name: data.name ?? fallbackName,
|
|
29
|
+
key
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
async function mintMcpApiKey(options) {
|
|
33
|
+
const name = mintedKeyName("mcp");
|
|
34
|
+
try {
|
|
35
|
+
const { data } = await options.apiClient.createApiKey({ key_name: name });
|
|
36
|
+
return assertUsable(options.apiClient, data, name);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (isNeonApiError(err) && (err.status === 403 || err.status === 404)) throw new Error(cannotMintMessage);
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function withdrawMintedKey(apiClient, key) {
|
|
43
|
+
return withdraw(apiClient, key.id);
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { mintMcpApiKey, withdrawMintedKey };
|
package/dist/mcp/plan.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { tryResolveAddMcpAgentId } from "./agents.js";
|
|
2
|
+
import { agentChoicesFrom, pickAgentsInteractively, resolveAgentSelection } from "../utils/agent_picker.js";
|
|
3
|
+
import { detectMcpAgents, mcpInstallableAgents } from "./targets.js";
|
|
4
|
+
import { pickMcpAuth, pickMcpProjectPin, pickMcpScope } from "./wizard.js";
|
|
5
|
+
//#region src/mcp/plan.ts
|
|
6
|
+
async function resolveMcpPlan(options) {
|
|
7
|
+
const prompt = options.interactive && !options.yes;
|
|
8
|
+
const scope = options.project ? "project" : prompt ? await (options.pickScope ?? pickMcpScope)() : "global";
|
|
9
|
+
const available = mcpInstallableAgents(scope);
|
|
10
|
+
const detected = await detectMcpAgents({
|
|
11
|
+
scope,
|
|
12
|
+
cwd: options.cwd
|
|
13
|
+
});
|
|
14
|
+
const agents = await resolveAgentSelection({
|
|
15
|
+
specified: options.agents,
|
|
16
|
+
choices: agentChoicesFrom(available, detected),
|
|
17
|
+
detected,
|
|
18
|
+
message: "Which coding agents should get the Neon MCP server? (space to toggle, enter to confirm)",
|
|
19
|
+
nonInteractiveMessage: scope === "project" ? `No coding agents detected in this project. Pass --agent <name>. Supported agents: ${available.join(", ")}` : `No coding agents detected. Pass --agent <name>. Supported agents: ${available.join(", ")}`,
|
|
20
|
+
resolveSpecified: (raw) => {
|
|
21
|
+
const id = tryResolveAddMcpAgentId(raw);
|
|
22
|
+
if (!id) throw new Error(`Unknown agent: "${raw}". Supported agents: ${available.join(", ")}`);
|
|
23
|
+
return id;
|
|
24
|
+
},
|
|
25
|
+
pick: prompt ? options.pickAgents ?? pickAgentsInteractively : void 0,
|
|
26
|
+
interactive: prompt
|
|
27
|
+
});
|
|
28
|
+
const auth = options.oauth ? "oauth" : prompt ? await (options.pickAuth ?? pickMcpAuth)() : "api-key";
|
|
29
|
+
let urlProjectId = options.projectId;
|
|
30
|
+
if (urlProjectId === void 0 && prompt && scope === "project" && options.linkedProjectId) {
|
|
31
|
+
if (await (options.pickProjectPin ?? pickMcpProjectPin)(options.linkedProjectId)) urlProjectId = options.linkedProjectId;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
scope,
|
|
35
|
+
agents,
|
|
36
|
+
auth,
|
|
37
|
+
readOnly: options.readOnly,
|
|
38
|
+
urlProjectId,
|
|
39
|
+
categories: options.categories
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { resolveMcpPlan };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mcpUnsupportedReason } from "./install.js";
|
|
2
|
+
import { detectInstalledAgents, listMcpAgentIds, resolveAddMcpAgentId, uniqueAgentIds } from "./agents.js";
|
|
3
|
+
import { detectProjectAgents } from "add-mcp";
|
|
4
|
+
//#region src/mcp/targets.ts
|
|
5
|
+
function mcpInstallableAgents(scope) {
|
|
6
|
+
return listMcpAgentIds().filter((id) => mcpUnsupportedReason(id, scope) === void 0);
|
|
7
|
+
}
|
|
8
|
+
async function detectMcpAgents(options) {
|
|
9
|
+
const detected = options.scope === "project" ? detectProjectAgents(options.cwd) : await detectInstalledAgents();
|
|
10
|
+
return uniqueAgentIds(detected).filter((id) => mcpUnsupportedReason(id, options.scope) === void 0);
|
|
11
|
+
}
|
|
12
|
+
function resolveInstallTargets(options) {
|
|
13
|
+
const requested = uniqueAgentIds(options.agents.map(resolveAddMcpAgentId));
|
|
14
|
+
const install = [];
|
|
15
|
+
const skipped = [];
|
|
16
|
+
for (const agent of requested) {
|
|
17
|
+
const error = mcpUnsupportedReason(agent, options.scope);
|
|
18
|
+
if (error) skipped.push({
|
|
19
|
+
agent,
|
|
20
|
+
error
|
|
21
|
+
});
|
|
22
|
+
else install.push(agent);
|
|
23
|
+
}
|
|
24
|
+
if (install.length === 0) {
|
|
25
|
+
const details = skipped.map((row) => row.error).join(" ");
|
|
26
|
+
throw new Error(details || `None of the selected agents can install the Neon MCP server. Supported agents: ${mcpInstallableAgents(options.scope).join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
install,
|
|
30
|
+
skipped
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { detectMcpAgents, mcpInstallableAgents, resolveInstallTargets };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getAgentDisplayName } from "./agents.js";
|
|
2
|
+
import { canPickAgentsInteractively } from "../utils/agent_picker.js";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
//#region src/mcp/wizard.ts
|
|
5
|
+
const restoreCursorOnAbort = (state) => {
|
|
6
|
+
if (state.aborted) {
|
|
7
|
+
process.stdout.write("\x1B[?25h");
|
|
8
|
+
process.stdout.write("\n");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const pickMcpScope = async () => {
|
|
13
|
+
if (!canPickAgentsInteractively()) throw new Error("No interactive terminal. Pass --project for project config, or -y for global.");
|
|
14
|
+
const { scope } = await prompts({
|
|
15
|
+
onState: restoreCursorOnAbort,
|
|
16
|
+
type: "select",
|
|
17
|
+
name: "scope",
|
|
18
|
+
message: "Where should the Neon MCP server be installed?",
|
|
19
|
+
initial: 0,
|
|
20
|
+
choices: [{
|
|
21
|
+
title: "Global",
|
|
22
|
+
value: "global",
|
|
23
|
+
description: "User-level agent config"
|
|
24
|
+
}, {
|
|
25
|
+
title: "Project",
|
|
26
|
+
value: "project",
|
|
27
|
+
description: "This directory"
|
|
28
|
+
}]
|
|
29
|
+
});
|
|
30
|
+
if (scope !== "global" && scope !== "project") throw new Error("Aborted.");
|
|
31
|
+
return scope;
|
|
32
|
+
};
|
|
33
|
+
const pickMcpAuth = async () => {
|
|
34
|
+
if (!canPickAgentsInteractively()) throw new Error("No interactive terminal. Pass --oauth for OAuth, or -y to mint an API key.");
|
|
35
|
+
const { auth } = await prompts({
|
|
36
|
+
onState: restoreCursorOnAbort,
|
|
37
|
+
type: "select",
|
|
38
|
+
name: "auth",
|
|
39
|
+
message: "How should agents authenticate to Neon?",
|
|
40
|
+
initial: 0,
|
|
41
|
+
choices: [{
|
|
42
|
+
title: "API key",
|
|
43
|
+
value: "api-key",
|
|
44
|
+
description: "Mint a key and write it into agent config"
|
|
45
|
+
}, {
|
|
46
|
+
title: "OAuth",
|
|
47
|
+
value: "oauth",
|
|
48
|
+
description: "The agent signs in on first use"
|
|
49
|
+
}]
|
|
50
|
+
});
|
|
51
|
+
if (auth !== "api-key" && auth !== "oauth") throw new Error("Aborted.");
|
|
52
|
+
return auth;
|
|
53
|
+
};
|
|
54
|
+
const pickMcpProjectPin = async (linkedProjectId) => {
|
|
55
|
+
if (!canPickAgentsInteractively()) return false;
|
|
56
|
+
const { pin } = await prompts({
|
|
57
|
+
onState: restoreCursorOnAbort,
|
|
58
|
+
type: "confirm",
|
|
59
|
+
name: "pin",
|
|
60
|
+
message: `Pin MCP tools to the linked project ${linkedProjectId}?`,
|
|
61
|
+
initial: true
|
|
62
|
+
});
|
|
63
|
+
return pin === true;
|
|
64
|
+
};
|
|
65
|
+
const mcpInstallSummary = (options) => {
|
|
66
|
+
const agents = options.install.map(getAgentDisplayName).join(", ");
|
|
67
|
+
const auth = options.auth === "oauth" ? "OAuth, agent signs in on first use" : options.reuse ? "reuse the API key already in agent config" : "mint an account-wide API key that reaches every organization";
|
|
68
|
+
const rows = [
|
|
69
|
+
["Config", options.scope === "project" ? "this directory" : "user-level"],
|
|
70
|
+
["Agents", agents],
|
|
71
|
+
["Auth", auth],
|
|
72
|
+
["URL", options.url]
|
|
73
|
+
];
|
|
74
|
+
if (options.skipped.length > 0) rows.push(["Skipped", options.skipped.map((row) => `${getAgentDisplayName(row.agent)} (${row.error})`).join("; ")]);
|
|
75
|
+
const labelWidth = Math.max(...rows.map(([label]) => label.length));
|
|
76
|
+
return rows.map(([label, value]) => `${label.padEnd(labelWidth)} ${value}`).join("\n");
|
|
77
|
+
};
|
|
78
|
+
const confirmMcpInstall = async (options) => {
|
|
79
|
+
if (!canPickAgentsInteractively()) return true;
|
|
80
|
+
process.stdout.write(`\n${mcpInstallSummary(options)}\n\n`);
|
|
81
|
+
const { ok } = await prompts({
|
|
82
|
+
onState: restoreCursorOnAbort,
|
|
83
|
+
type: "confirm",
|
|
84
|
+
name: "ok",
|
|
85
|
+
message: "Write the Neon MCP server into these agents?",
|
|
86
|
+
initial: true
|
|
87
|
+
});
|
|
88
|
+
return ok === true;
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { confirmMcpInstall, mcpInstallSummary, pickMcpAuth, pickMcpProjectPin, pickMcpScope };
|
|
@@ -57,9 +57,8 @@ const test = test$1.extend({
|
|
|
57
57
|
apiHost,
|
|
58
58
|
"--output",
|
|
59
59
|
options.output ?? (options.outputTable ? "table" : "yaml"),
|
|
60
|
-
"--api-key",
|
|
61
|
-
"test-key",
|
|
62
60
|
"--no-analytics",
|
|
61
|
+
...options.apiKey === false ? [] : ["--api-key", options.apiKey ?? "test-key"],
|
|
63
62
|
...args
|
|
64
63
|
], {
|
|
65
64
|
stdio: "pipe",
|
|
@@ -84,9 +83,13 @@ const test = test$1.extend({
|
|
|
84
83
|
cp.on("close", (code) => {
|
|
85
84
|
try {
|
|
86
85
|
expect(code).toBe(options?.code ?? 0);
|
|
87
|
-
expect(output).toMatchSnapshot();
|
|
86
|
+
if (options.snapshot !== false) expect(output).toMatchSnapshot();
|
|
88
87
|
if (options.stderr !== void 0) expect(strip(error).replace(/\s+/g, " ").trim()).toEqual(typeof options.stderr === "string" ? options.stderr.toString().replace(/\s+/g, " ") : options.stderr);
|
|
89
|
-
resolve(
|
|
88
|
+
resolve({
|
|
89
|
+
stdout: output,
|
|
90
|
+
stderr: error,
|
|
91
|
+
code
|
|
92
|
+
});
|
|
90
93
|
} catch (err) {
|
|
91
94
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
92
95
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { isCi } from "../env.js";
|
|
2
|
+
import { getAgentDisplayName, resolveAddMcpAgentId, uniqueAgentIds } from "../mcp/agents.js";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
//#region src/utils/agent_picker.ts
|
|
5
|
+
const canPickAgentsInteractively = () => !isCi() && Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
|
|
6
|
+
const pickAgentsInteractively = async (options) => {
|
|
7
|
+
if (!canPickAgentsInteractively()) throw new Error("No interactive terminal. Pass --agent <name>, or run this command in a terminal to pick agents.");
|
|
8
|
+
if (options.choices.length === 0) throw new Error("No coding agents are available to pick.");
|
|
9
|
+
const selected = new Set(options.selected ?? []);
|
|
10
|
+
const { agents } = await prompts({
|
|
11
|
+
onState: (state) => {
|
|
12
|
+
if (state.aborted) {
|
|
13
|
+
process.stdout.write("\x1B[?25h");
|
|
14
|
+
process.stdout.write("\n");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
type: "multiselect",
|
|
19
|
+
name: "agents",
|
|
20
|
+
message: options.message,
|
|
21
|
+
instructions: false,
|
|
22
|
+
min: 1,
|
|
23
|
+
choices: options.choices.map((choice) => ({
|
|
24
|
+
value: choice.id,
|
|
25
|
+
title: choice.title,
|
|
26
|
+
description: choice.description,
|
|
27
|
+
selected: selected.has(choice.id)
|
|
28
|
+
}))
|
|
29
|
+
});
|
|
30
|
+
if (!Array.isArray(agents)) throw new Error("Aborted: no agents selected.");
|
|
31
|
+
const picked = [];
|
|
32
|
+
for (const value of agents) {
|
|
33
|
+
const id = agentIdInChoices(value, options.choices);
|
|
34
|
+
if (id === void 0) throw new Error(`Unknown agent: "${String(value)}".`);
|
|
35
|
+
picked.push(id);
|
|
36
|
+
}
|
|
37
|
+
return uniqueAgentIds(picked);
|
|
38
|
+
};
|
|
39
|
+
const resolveAgentSelection = async (options) => {
|
|
40
|
+
if (options.specified.length > 0) {
|
|
41
|
+
const resolve = options.resolveSpecified ?? resolveAddMcpAgentId;
|
|
42
|
+
return uniqueAgentIds(options.specified.map(resolve));
|
|
43
|
+
}
|
|
44
|
+
const pick = options.pick ?? (options.interactive ?? canPickAgentsInteractively() ? pickAgentsInteractively : void 0);
|
|
45
|
+
if (pick) {
|
|
46
|
+
const selected = await pick({
|
|
47
|
+
message: options.message,
|
|
48
|
+
choices: options.choices,
|
|
49
|
+
selected: options.detected.filter((id) => options.choices.some((choice) => choice.id === id))
|
|
50
|
+
});
|
|
51
|
+
if (selected.length === 0) throw new Error("No agents selected. Pass --agent <name>, or pick at least one agent.");
|
|
52
|
+
return uniqueAgentIds(selected);
|
|
53
|
+
}
|
|
54
|
+
if (options.detected.length > 0) return uniqueAgentIds(options.detected);
|
|
55
|
+
throw new Error(options.nonInteractiveMessage);
|
|
56
|
+
};
|
|
57
|
+
const agentChoicesFrom = (available, detected) => {
|
|
58
|
+
const detectedSet = new Set(detected);
|
|
59
|
+
return [...available.filter((id) => detectedSet.has(id)), ...available.filter((id) => !detectedSet.has(id))].map((id) => ({
|
|
60
|
+
id,
|
|
61
|
+
title: getAgentDisplayName(id),
|
|
62
|
+
...detectedSet.has(id) ? { description: "detected" } : {}
|
|
63
|
+
}));
|
|
64
|
+
};
|
|
65
|
+
const agentIdInChoices = (value, choices) => {
|
|
66
|
+
if (typeof value !== "string") return;
|
|
67
|
+
for (const choice of choices) if (choice.id === value) return choice.id;
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { agentChoicesFrom, canPickAgentsInteractively, pickAgentsInteractively, resolveAgentSelection };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "4.1
|
|
3
|
+
"version": "4.2.1",
|
|
4
4
|
"description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"yargs": "17.7.2",
|
|
65
65
|
"yoctocolors": "^2.1.2",
|
|
66
66
|
"@neon/sdk": "2.2.0",
|
|
67
|
-
"@neon/config
|
|
68
|
-
"@neon/config": "1.0.2"
|
|
67
|
+
"@neon/config": "1.0.2",
|
|
68
|
+
"@neon/config-runtime": "1.0.2"
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
71
|
"@napi-rs/keyring": "1.3.0",
|
package/dist/init/install_mcp.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { agents, upsertServer } from "add-mcp";
|
|
2
|
-
//#region src/init/install_mcp.ts
|
|
3
|
-
const NEON_MCP_URL = "https://mcp.neon.tech/mcp";
|
|
4
|
-
const NEON_MCP_NAME = "Neon";
|
|
5
|
-
function installNeonMcpServer(options) {
|
|
6
|
-
const agent = agents[options.agent];
|
|
7
|
-
if (!agent.supportedTransports.includes("http")) return {
|
|
8
|
-
ok: false,
|
|
9
|
-
unsupported: true,
|
|
10
|
-
error: agent.unsupportedTransportMessage ?? `${agent.displayName} does not support remote HTTP MCP servers.`
|
|
11
|
-
};
|
|
12
|
-
if (options.scope === "project" && !agent.localConfigPath) return {
|
|
13
|
-
ok: false,
|
|
14
|
-
unsupported: true,
|
|
15
|
-
error: `${agent.displayName} does not support project-level MCP config.`
|
|
16
|
-
};
|
|
17
|
-
const result = upsertServer(options.agent, NEON_MCP_NAME, {
|
|
18
|
-
type: "http",
|
|
19
|
-
url: NEON_MCP_URL
|
|
20
|
-
}, {
|
|
21
|
-
local: options.scope === "project",
|
|
22
|
-
cwd: options.cwd
|
|
23
|
-
});
|
|
24
|
-
if (result.success) return {
|
|
25
|
-
ok: true,
|
|
26
|
-
path: result.path
|
|
27
|
-
};
|
|
28
|
-
return {
|
|
29
|
-
ok: false,
|
|
30
|
-
unsupported: false,
|
|
31
|
-
error: result.error ?? "Failed to write Neon MCP server config"
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
//#endregion
|
|
35
|
-
export { NEON_MCP_NAME, NEON_MCP_URL, installNeonMcpServer };
|