@nestpilot/mcp-app 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/init.js +79 -31
- package/dist/host-configs/cowork.json +8 -2
- package/dist/host-configs/goose.yaml +15 -7
- package/dist/host-configs/openclaw-manifest.json +12 -2
- package/dist/main.js +8 -2
- package/dist/server.js +17 -17
- package/dist/src/local/device-auth.d.ts +52 -0
- package/dist/src/local/device-auth.js +148 -0
- package/dist/src/local/keychain.js +33 -15
- package/dist/src/local/local-config.js +1 -1
- package/dist/src/local/local-data-layer.d.ts +2 -0
- package/dist/src/local/local-data-layer.js +1 -0
- package/dist/tools/local-plan-tools.js +1 -1
- package/dist/tools/mcp-helpers.js +9 -2
- package/dist/tools/optimize-roth-tools.js +2 -2
- package/host-configs/cowork.json +8 -2
- package/host-configs/goose.yaml +15 -7
- package/host-configs/openclaw-manifest.json +12 -2
- package/package.json +1 -1
package/dist/cli/init.js
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* Steps:
|
|
5
5
|
* 1. Create ~/.nestpilot/ directory structure
|
|
6
6
|
* 2. Generate encryption key and store in OS keychain
|
|
7
|
-
* 3.
|
|
7
|
+
* 3. Authenticate via Device Code Flow and provision API key
|
|
8
8
|
* 4. Write config.json with preferences
|
|
9
9
|
* 5. Copy host config templates
|
|
10
10
|
* 6. Print success message with next steps
|
|
11
11
|
*
|
|
12
12
|
* @feature FEAT-0088
|
|
13
|
+
* @feature FEAT-0091
|
|
13
14
|
*/
|
|
14
15
|
import fs from "node:fs/promises";
|
|
15
16
|
import path from "node:path";
|
|
@@ -18,6 +19,7 @@ import { stdin, stdout } from "node:process";
|
|
|
18
19
|
import { loadLocalConfig, DATA_SUBDIRS, configFilePath, } from "../src/local/local-config.js";
|
|
19
20
|
import { createKeychainProvider } from "../src/local/keychain.js";
|
|
20
21
|
import { EncryptionService } from "../src/local/encryption.js";
|
|
22
|
+
import { DeviceAuthClient } from "../src/local/device-auth.js";
|
|
21
23
|
// ── Init command ─────────────────────────────────────────────────────────
|
|
22
24
|
export async function initCommand() {
|
|
23
25
|
console.log("\n🏠 NestPilot Local Setup");
|
|
@@ -48,42 +50,36 @@ export async function initCommand() {
|
|
|
48
50
|
console.log(" ✓ Encryption key generated and stored in OS keychain");
|
|
49
51
|
}
|
|
50
52
|
console.log();
|
|
51
|
-
// Step 3: API Key provisioning (
|
|
53
|
+
// Step 3: API Key provisioning via Device Code Flow (FEAT-0091)
|
|
52
54
|
console.log("3. API Key Provisioning");
|
|
53
55
|
const existingKey = await keychain.get("nestpilot", "api-key");
|
|
54
56
|
if (existingKey) {
|
|
55
|
-
|
|
57
|
+
// Verify the stored key is still valid
|
|
58
|
+
const deviceAuth = new DeviceAuthClient(config.cloudApiUrl);
|
|
59
|
+
try {
|
|
60
|
+
const valid = await deviceAuth.verifyApiKey(existingKey);
|
|
61
|
+
if (valid) {
|
|
62
|
+
console.log(" ✓ API key already configured and valid");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.log(" ⚠ Stored API key is expired or revoked. Re-authenticating...");
|
|
66
|
+
await provisionApiKey(config.cloudApiUrl, keychain);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
console.log(" ✓ API key configured (cloud verification skipped — offline)");
|
|
71
|
+
}
|
|
56
72
|
}
|
|
57
73
|
else {
|
|
58
74
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
59
75
|
try {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
const response = await fetch(`${config.cloudApiUrl}/api/auth/provision-key`, {
|
|
64
|
-
method: "POST",
|
|
65
|
-
headers: { "Content-Type": "application/json" },
|
|
66
|
-
body: JSON.stringify({ email: email.trim() }),
|
|
67
|
-
signal: AbortSignal.timeout(10_000),
|
|
68
|
-
});
|
|
69
|
-
if (response.ok) {
|
|
70
|
-
const data = (await response.json());
|
|
71
|
-
await keychain.set("nestpilot", "api-key", data.apiKey);
|
|
72
|
-
console.log(" ✓ API key provisioned (10 forecasts/mo free)");
|
|
73
|
-
console.log(" ✓ API key stored in OS keychain");
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
console.log(" ⚠ Could not provision API key (cloud API unavailable).");
|
|
77
|
-
console.log(" Set NESTPILOT_API_KEY env var manually, or run init again later.");
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
console.log(" ⚠ Could not reach cloud API for key provisioning.");
|
|
82
|
-
console.log(" Set NESTPILOT_API_KEY env var manually, or run init again later.");
|
|
83
|
-
}
|
|
76
|
+
const answer = await rl.question(" Sign in to NestPilot for cloud compute (forecasts, Roth optimization)? [Y/n] ");
|
|
77
|
+
if (answer.trim().toLowerCase() !== "n") {
|
|
78
|
+
await provisionApiKey(config.cloudApiUrl, keychain);
|
|
84
79
|
}
|
|
85
80
|
else {
|
|
86
|
-
console.log(" ⏭ Skipped.
|
|
81
|
+
console.log(" ⏭ Skipped. Run 'nestpilot init' again or set NESTPILOT_API_KEY env var later.");
|
|
82
|
+
console.log(" Local plan creation & storage works without an API key.");
|
|
87
83
|
}
|
|
88
84
|
}
|
|
89
85
|
finally {
|
|
@@ -121,6 +117,46 @@ export async function initCommand() {
|
|
|
121
117
|
console.log("Quick test: npx nestpilot-mcp-server status");
|
|
122
118
|
console.log();
|
|
123
119
|
}
|
|
120
|
+
async function provisionApiKey(cloudApiUrl, keychain) {
|
|
121
|
+
const deviceAuth = new DeviceAuthClient(cloudApiUrl);
|
|
122
|
+
try {
|
|
123
|
+
// 1. Initiate device code flow
|
|
124
|
+
const authResponse = await deviceAuth.authorize();
|
|
125
|
+
console.log();
|
|
126
|
+
console.log(" ┌──────────────────────────────────────────┐");
|
|
127
|
+
console.log(` │ Code: ${authResponse.userCode.padEnd(33)}│`);
|
|
128
|
+
console.log(" └──────────────────────────────────────────┘");
|
|
129
|
+
console.log();
|
|
130
|
+
console.log(` Open: ${authResponse.verificationUri}`);
|
|
131
|
+
console.log(" Enter the code above and sign in with your NestPilot account.");
|
|
132
|
+
console.log();
|
|
133
|
+
// 2. Open browser automatically
|
|
134
|
+
deviceAuth.openBrowser(authResponse.verificationUriComplete ?? authResponse.verificationUri);
|
|
135
|
+
// 3. Poll for token completion
|
|
136
|
+
console.log(" ⏳ Waiting for sign-in...");
|
|
137
|
+
const tokenResponse = await deviceAuth.pollForToken(authResponse.deviceCode, authResponse.interval ?? 5, authResponse.expiresIn);
|
|
138
|
+
if (tokenResponse.status !== "completed" || !tokenResponse.accessToken) {
|
|
139
|
+
console.log(` ⚠ Sign-in ${tokenResponse.status}: ${tokenResponse.error ?? "timed out"}`);
|
|
140
|
+
console.log(" Local plan creation & storage still works without an API key.");
|
|
141
|
+
console.log(" Run 'nestpilot init' again to retry.");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(" ✓ Signed in successfully");
|
|
145
|
+
// 4. Exchange JWT for a long-lived API key
|
|
146
|
+
const apiKeyResult = await deviceAuth.exchangeForApiKey(tokenResponse.accessToken, `${process.platform}-cli-${new Date().toISOString().slice(0, 10)}`);
|
|
147
|
+
// 5. Store in OS keychain
|
|
148
|
+
await keychain.set("nestpilot", "api-key", apiKeyResult.apiKey);
|
|
149
|
+
console.log(" ✓ API key provisioned and stored in OS keychain");
|
|
150
|
+
console.log(` ✓ Key: ${apiKeyResult.keyPrefix}... (expires: ${apiKeyResult.expiresAt?.slice(0, 10) ?? "never"})`);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
+
console.log(` ⚠ Could not complete sign-in: ${msg}`);
|
|
155
|
+
console.log(" Local plan creation & storage still works without an API key.");
|
|
156
|
+
console.log(" Cloud compute (forecasts, Roth optimization) requires a key.");
|
|
157
|
+
console.log(" Run 'nestpilot init' again or set NESTPILOT_API_KEY env var.");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
124
160
|
// ── Host config writer ───────────────────────────────────────────────────
|
|
125
161
|
async function writeHostConfigs(dir) {
|
|
126
162
|
// Goose
|
|
@@ -132,19 +168,25 @@ extensions:
|
|
|
132
168
|
type: mcp
|
|
133
169
|
transport: stdio
|
|
134
170
|
command: npx
|
|
135
|
-
args:
|
|
171
|
+
args:
|
|
172
|
+
- "--yes"
|
|
173
|
+
- "--package=@nestpilot/mcp-app"
|
|
174
|
+
- "nestpilot-mcp-server"
|
|
136
175
|
env:
|
|
137
176
|
NESTPILOT_MODE: local
|
|
177
|
+
NESTPILOT_DATA_DIR: "~/.nestpilot"
|
|
138
178
|
`;
|
|
139
179
|
await fs.writeFile(path.join(dir, "goose.yaml"), gooseConfig);
|
|
140
180
|
// Cowork
|
|
141
181
|
const coworkConfig = {
|
|
182
|
+
_comment: "NestPilot MCP Server — Claude Cowork (Claude Desktop) Configuration. Add this to your claude_desktop_config.json.",
|
|
142
183
|
mcpServers: {
|
|
143
184
|
nestpilot: {
|
|
144
185
|
command: "npx",
|
|
145
|
-
args: ["nestpilot-mcp-server"],
|
|
186
|
+
args: ["--yes", "--package=@nestpilot/mcp-app", "nestpilot-mcp-server"],
|
|
146
187
|
env: {
|
|
147
188
|
NESTPILOT_MODE: "local",
|
|
189
|
+
NESTPILOT_DATA_DIR: "~/.nestpilot",
|
|
148
190
|
},
|
|
149
191
|
},
|
|
150
192
|
},
|
|
@@ -153,19 +195,25 @@ extensions:
|
|
|
153
195
|
// OpenClaw
|
|
154
196
|
const openclawManifest = {
|
|
155
197
|
name: "nestpilot",
|
|
198
|
+
package: "@nestpilot/mcp-app",
|
|
156
199
|
version: "1.0.0",
|
|
157
200
|
description: "Local-first retirement planning — your data stays on your machine",
|
|
201
|
+
homepage: "https://www.npmjs.com/package/@nestpilot/mcp-app",
|
|
158
202
|
transport: "stdio",
|
|
159
203
|
command: "npx",
|
|
160
|
-
args: ["nestpilot-mcp-server"],
|
|
204
|
+
args: ["--yes", "--package=@nestpilot/mcp-app", "nestpilot-mcp-server"],
|
|
161
205
|
env: {
|
|
162
206
|
NESTPILOT_MODE: "local",
|
|
207
|
+
NESTPILOT_DATA_DIR: "~/.nestpilot",
|
|
163
208
|
},
|
|
164
209
|
capabilities: {
|
|
165
210
|
tools: true,
|
|
166
211
|
resources: true,
|
|
167
212
|
apps: true,
|
|
168
213
|
},
|
|
214
|
+
setup: {
|
|
215
|
+
instructions: "Run `npx --yes --package=@nestpilot/mcp-app nestpilot init` before first use.",
|
|
216
|
+
},
|
|
169
217
|
};
|
|
170
218
|
await fs.writeFile(path.join(dir, "openclaw-manifest.json"), JSON.stringify(openclawManifest, null, 2));
|
|
171
219
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
+
"_comment": "NestPilot MCP Server — Claude Cowork (Claude Desktop) Configuration. Run `npx --yes --package=@nestpilot/mcp-app nestpilot init` first, then add this to your claude_desktop_config.json.",
|
|
2
3
|
"mcpServers": {
|
|
3
4
|
"nestpilot": {
|
|
4
5
|
"command": "npx",
|
|
5
|
-
"args": [
|
|
6
|
+
"args": [
|
|
7
|
+
"--yes",
|
|
8
|
+
"--package=@nestpilot/mcp-app",
|
|
9
|
+
"nestpilot-mcp-server"
|
|
10
|
+
],
|
|
6
11
|
"env": {
|
|
7
|
-
"NESTPILOT_MODE": "local"
|
|
12
|
+
"NESTPILOT_MODE": "local",
|
|
13
|
+
"NESTPILOT_DATA_DIR": "~/.nestpilot"
|
|
8
14
|
}
|
|
9
15
|
}
|
|
10
16
|
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# NestPilot MCP Server — Goose Host Configuration
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# Installation
|
|
4
|
+
# ------------
|
|
5
|
+
# 1. Run the initializer first (one-time setup):
|
|
6
|
+
# npx --yes --package=@nestpilot/mcp-app nestpilot init
|
|
5
7
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
+
# 2. Add the block below to your Goose profile:
|
|
9
|
+
# ~/.config/goose/profiles.yaml
|
|
10
|
+
# under the `extensions:` key of your active profile.
|
|
11
|
+
#
|
|
12
|
+
# 3. Restart Goose.
|
|
8
13
|
#
|
|
9
14
|
# @feature FEAT-0088
|
|
10
15
|
|
|
@@ -13,10 +18,13 @@ extensions:
|
|
|
13
18
|
type: mcp
|
|
14
19
|
transport: stdio
|
|
15
20
|
command: npx
|
|
16
|
-
args:
|
|
21
|
+
args:
|
|
22
|
+
- "--yes"
|
|
23
|
+
- "--package=@nestpilot/mcp-app"
|
|
24
|
+
- "nestpilot-mcp-server"
|
|
17
25
|
env:
|
|
18
26
|
NESTPILOT_MODE: local
|
|
27
|
+
NESTPILOT_DATA_DIR: "~/.nestpilot"
|
|
19
28
|
# Optional overrides:
|
|
20
|
-
#
|
|
21
|
-
# NESTPILOT_CLOUD_API_URL: "https://api.nestpilot.com"
|
|
29
|
+
# NESTPILOT_CLOUD_API_URL: "https://api.nestpilot.net"
|
|
22
30
|
# NESTPILOT_API_KEY: "your-api-key"
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nestpilot",
|
|
3
|
+
"package": "@nestpilot/mcp-app",
|
|
3
4
|
"version": "1.0.0",
|
|
4
5
|
"description": "Local-first retirement planning — your data stays on your machine",
|
|
6
|
+
"homepage": "https://www.npmjs.com/package/@nestpilot/mcp-app",
|
|
5
7
|
"transport": "stdio",
|
|
6
8
|
"command": "npx",
|
|
7
|
-
"args": [
|
|
9
|
+
"args": [
|
|
10
|
+
"--yes",
|
|
11
|
+
"--package=@nestpilot/mcp-app",
|
|
12
|
+
"nestpilot-mcp-server"
|
|
13
|
+
],
|
|
8
14
|
"env": {
|
|
9
|
-
"NESTPILOT_MODE": "local"
|
|
15
|
+
"NESTPILOT_MODE": "local",
|
|
16
|
+
"NESTPILOT_DATA_DIR": "~/.nestpilot"
|
|
10
17
|
},
|
|
11
18
|
"capabilities": {
|
|
12
19
|
"tools": true,
|
|
13
20
|
"resources": true,
|
|
14
21
|
"apps": true
|
|
22
|
+
},
|
|
23
|
+
"setup": {
|
|
24
|
+
"instructions": "Run `npx --yes --package=@nestpilot/mcp-app nestpilot init` before first use."
|
|
15
25
|
}
|
|
16
26
|
}
|
package/dist/main.js
CHANGED
|
@@ -104,10 +104,16 @@ async function startStreamableHTTPServer(factory) {
|
|
|
104
104
|
process.on("SIGTERM", shutdown);
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
* Starts an MCP server with stdio transport (for Claude Desktop).
|
|
107
|
+
* Starts an MCP server with stdio transport (for Claude Desktop / VS Code).
|
|
108
|
+
*
|
|
109
|
+
* In stdio mode there are no HTTP headers, so we synthesize an authCtx from
|
|
110
|
+
* the config's defaultUserId. This ensures the policy engine can resolve a
|
|
111
|
+
* real role ("authenticated") when MCP_DEFAULT_USER_ID is set to anything
|
|
112
|
+
* other than the literal string "anonymous".
|
|
108
113
|
*/
|
|
109
114
|
async function startStdioServer(factory) {
|
|
110
|
-
|
|
115
|
+
const stdioAuthCtx = { userId: config.defaultUserId };
|
|
116
|
+
await factory(stdioAuthCtx).connect(new StdioServerTransport());
|
|
111
117
|
}
|
|
112
118
|
async function main() {
|
|
113
119
|
if (process.argv.includes("--stdio")) {
|
package/dist/server.js
CHANGED
|
@@ -72,49 +72,49 @@ export function createServer(authCtx) {
|
|
|
72
72
|
});
|
|
73
73
|
if (localConfig.mode === "local") {
|
|
74
74
|
// ── Local mode (FEAT-0087) ────────────────────────────────────────
|
|
75
|
-
console.
|
|
75
|
+
console.error("[server] Starting in LOCAL mode — data stays on this machine");
|
|
76
76
|
const keychain = createKeychainProvider(localConfig.dataDir);
|
|
77
77
|
const encryption = new EncryptionService(keychain);
|
|
78
78
|
const store = new LocalPlanStore(localConfig.dataDir, encryption);
|
|
79
79
|
const computeClient = new CloudComputeClient(localConfig.cloudApiUrl, localConfig.apiKey ?? "");
|
|
80
|
-
console.
|
|
80
|
+
console.error("Registering Local Plan tools...");
|
|
81
81
|
registerLocalPlanTools(server, store, computeClient);
|
|
82
82
|
// Cloud-backed tools that use PII-free payloads
|
|
83
|
-
console.
|
|
83
|
+
console.error("Registering Medicare tools (cloud)...");
|
|
84
84
|
registerMedicareTools(server, authCtx);
|
|
85
|
-
console.
|
|
85
|
+
console.error("Registering Roth tools (cloud)...");
|
|
86
86
|
registerRothTools(server, authCtx);
|
|
87
|
-
console.
|
|
87
|
+
console.error("Registering Report tools (cloud)...");
|
|
88
88
|
registerReportTools(server, authCtx);
|
|
89
89
|
}
|
|
90
90
|
else {
|
|
91
91
|
// ── Cloud mode (default — existing behavior) ──────────────────────
|
|
92
|
-
console.
|
|
93
|
-
console.
|
|
92
|
+
console.error("[server] Starting in CLOUD mode — proxy to Spring Boot API");
|
|
93
|
+
console.error("Registering Medicare tools...");
|
|
94
94
|
registerMedicareTools(server, authCtx);
|
|
95
|
-
console.
|
|
95
|
+
console.error("Registering Planning tools...");
|
|
96
96
|
registerPlanningTools(server, authCtx);
|
|
97
|
-
console.
|
|
97
|
+
console.error("Registering Roth tools...");
|
|
98
98
|
registerRothTools(server, authCtx);
|
|
99
|
-
console.
|
|
99
|
+
console.error("Registering Agent tools...");
|
|
100
100
|
registerAgentTools(server, authCtx);
|
|
101
|
-
console.
|
|
101
|
+
console.error("Registering Plan Management tools...");
|
|
102
102
|
registerPlanManagementTools(server, authCtx);
|
|
103
|
-
console.
|
|
103
|
+
console.error("Registering Forecast Management tools...");
|
|
104
104
|
registerForecastManagementTools(server, authCtx);
|
|
105
|
-
console.
|
|
105
|
+
console.error("Registering Scenario Management tools...");
|
|
106
106
|
registerScenarioManagementTools(server, authCtx);
|
|
107
|
-
console.
|
|
107
|
+
console.error("Registering Proposal tools...");
|
|
108
108
|
registerProposalTools(server, authCtx);
|
|
109
|
-
console.
|
|
109
|
+
console.error("Registering Report tools...");
|
|
110
110
|
registerReportTools(server, authCtx);
|
|
111
111
|
}
|
|
112
112
|
// ── Shared registrations (both modes) ─────────────────────────────────
|
|
113
|
-
console.
|
|
113
|
+
console.error("Registering views...");
|
|
114
114
|
registerPlannerView(server);
|
|
115
115
|
registerVerificationPacketView(server);
|
|
116
116
|
registerSkillResources(server);
|
|
117
|
-
console.
|
|
117
|
+
console.error("Server registration complete");
|
|
118
118
|
return server;
|
|
119
119
|
}
|
|
120
120
|
// ── Retirement Planner view ─────────────────────────────────────────────
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface DeviceCodeResponse {
|
|
2
|
+
deviceCode: string;
|
|
3
|
+
userCode: string;
|
|
4
|
+
verificationUri: string;
|
|
5
|
+
verificationUriComplete?: string;
|
|
6
|
+
expiresIn: number;
|
|
7
|
+
interval: number;
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DeviceTokenResponse {
|
|
11
|
+
status: "pending" | "completed" | "expired" | "denied" | "error";
|
|
12
|
+
accessToken?: string;
|
|
13
|
+
tokenType?: string;
|
|
14
|
+
expiresIn?: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
errorDescription?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ApiKeyCreatedResponse {
|
|
19
|
+
id: string;
|
|
20
|
+
apiKey: string;
|
|
21
|
+
keyPrefix: string;
|
|
22
|
+
name: string;
|
|
23
|
+
scopes: string[];
|
|
24
|
+
expiresAt: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
export declare class DeviceAuthClient {
|
|
29
|
+
private cloudApiUrl;
|
|
30
|
+
constructor(cloudApiUrl: string);
|
|
31
|
+
/**
|
|
32
|
+
* Step 1: Initiate device code flow.
|
|
33
|
+
*/
|
|
34
|
+
authorize(): Promise<DeviceCodeResponse>;
|
|
35
|
+
/**
|
|
36
|
+
* Step 2: Open browser for user authentication.
|
|
37
|
+
*/
|
|
38
|
+
openBrowser(url: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* Step 3: Poll for device code completion.
|
|
41
|
+
* Retries at the specified interval until the user completes auth or the code expires.
|
|
42
|
+
*/
|
|
43
|
+
pollForToken(deviceCode: string, interval: number, expiresIn: number, onPoll?: () => void): Promise<DeviceTokenResponse>;
|
|
44
|
+
/**
|
|
45
|
+
* Step 4: Exchange JWT for a NestPilot API key.
|
|
46
|
+
*/
|
|
47
|
+
exchangeForApiKey(accessToken: string, name?: string): Promise<ApiKeyCreatedResponse>;
|
|
48
|
+
/**
|
|
49
|
+
* Verify an existing API key is still valid.
|
|
50
|
+
*/
|
|
51
|
+
verifyApiKey(apiKey: string): Promise<boolean>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Authorization Grant (RFC 8628) client for CLI-based authentication.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. POST /api/auth/device/authorize → get user_code + verification_uri
|
|
6
|
+
* 2. Open browser for user to sign in
|
|
7
|
+
* 3. Poll POST /api/auth/device/token until completed
|
|
8
|
+
* 4. Exchange JWT for API key via POST /api/auth/api-keys
|
|
9
|
+
* 5. Store API key in OS keychain
|
|
10
|
+
*
|
|
11
|
+
* @feature FEAT-0091
|
|
12
|
+
*/
|
|
13
|
+
import { exec } from "node:child_process";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
// ── Device Auth Client ───────────────────────────────────────────────────
|
|
16
|
+
export class DeviceAuthClient {
|
|
17
|
+
cloudApiUrl;
|
|
18
|
+
constructor(cloudApiUrl) {
|
|
19
|
+
this.cloudApiUrl = cloudApiUrl;
|
|
20
|
+
// Strip trailing slash
|
|
21
|
+
if (this.cloudApiUrl.endsWith("/")) {
|
|
22
|
+
this.cloudApiUrl = this.cloudApiUrl.slice(0, -1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Step 1: Initiate device code flow.
|
|
27
|
+
*/
|
|
28
|
+
async authorize() {
|
|
29
|
+
const response = await fetch(`${this.cloudApiUrl}/api/auth/device/authorize`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
signal: AbortSignal.timeout(10_000),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new Error(`Failed to initiate device code flow: ${response.status} ${await response.text()}`);
|
|
36
|
+
}
|
|
37
|
+
return (await response.json());
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Step 2: Open browser for user authentication.
|
|
41
|
+
*/
|
|
42
|
+
openBrowser(url) {
|
|
43
|
+
const platform = os.platform();
|
|
44
|
+
let command;
|
|
45
|
+
switch (platform) {
|
|
46
|
+
case "darwin":
|
|
47
|
+
command = `open "${url}"`;
|
|
48
|
+
break;
|
|
49
|
+
case "win32":
|
|
50
|
+
command = `start "${url}"`;
|
|
51
|
+
break;
|
|
52
|
+
case "linux":
|
|
53
|
+
command = `xdg-open "${url}"`;
|
|
54
|
+
break;
|
|
55
|
+
default:
|
|
56
|
+
console.log(`\n Please open this URL in your browser:\n ${url}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
exec(command, (error) => {
|
|
60
|
+
if (error) {
|
|
61
|
+
console.log(`\n Could not open browser automatically.\n Please open this URL:\n ${url}\n`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Step 3: Poll for device code completion.
|
|
67
|
+
* Retries at the specified interval until the user completes auth or the code expires.
|
|
68
|
+
*/
|
|
69
|
+
async pollForToken(deviceCode, interval, expiresIn, onPoll) {
|
|
70
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (onPoll)
|
|
73
|
+
onPoll();
|
|
74
|
+
const response = await fetch(`${this.cloudApiUrl}/api/auth/device/token`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
78
|
+
signal: AbortSignal.timeout(10_000),
|
|
79
|
+
});
|
|
80
|
+
const result = (await response.json());
|
|
81
|
+
if (result.status === "completed" && result.accessToken) {
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
if (result.status === "expired" || result.status === "denied") {
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
if (result.status === "error" &&
|
|
88
|
+
result.error !== "authorization_pending") {
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
// Wait before next poll
|
|
92
|
+
await sleep(interval * 1000);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
status: "expired",
|
|
96
|
+
error: "expired_token",
|
|
97
|
+
errorDescription: "Device code expired before user completed authentication.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Step 4: Exchange JWT for a NestPilot API key.
|
|
102
|
+
*/
|
|
103
|
+
async exchangeForApiKey(accessToken, name) {
|
|
104
|
+
const response = await fetch(`${this.cloudApiUrl}/api/auth/api-keys`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
Authorization: `Bearer ${accessToken}`,
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
name: name ?? `CLI (${os.hostname()})`,
|
|
112
|
+
scopes: ["compute"],
|
|
113
|
+
expiresInDays: 90,
|
|
114
|
+
}),
|
|
115
|
+
signal: AbortSignal.timeout(10_000),
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const text = await response.text();
|
|
119
|
+
throw new Error(`Failed to create API key: ${response.status} ${text}`);
|
|
120
|
+
}
|
|
121
|
+
return (await response.json());
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Verify an existing API key is still valid.
|
|
125
|
+
*/
|
|
126
|
+
async verifyApiKey(apiKey) {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(`${this.cloudApiUrl}/api/auth/api-keys/verify`, {
|
|
129
|
+
method: "GET",
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${apiKey}`,
|
|
132
|
+
},
|
|
133
|
+
signal: AbortSignal.timeout(5_000),
|
|
134
|
+
});
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
return false;
|
|
137
|
+
const result = (await response.json());
|
|
138
|
+
return result.valid === true;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// ── Utilities ────────────────────────────────────────────────────────────
|
|
146
|
+
function sleep(ms) {
|
|
147
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
148
|
+
}
|
|
@@ -83,28 +83,46 @@ class WindowsKeychain {
|
|
|
83
83
|
async get(service, account) {
|
|
84
84
|
try {
|
|
85
85
|
const target = this.target(service, account);
|
|
86
|
+
// Use Win32 CredRead API via P/Invoke — works on all Windows versions
|
|
87
|
+
// without requiring the external CredentialManager PowerShell module.
|
|
88
|
+
// The script is Base64-encoded (UTF-16LE) to avoid quoting/escaping issues.
|
|
89
|
+
const script = [
|
|
90
|
+
"Add-Type -TypeDefinition @'",
|
|
91
|
+
"using System; using System.Runtime.InteropServices;",
|
|
92
|
+
"public class NpCred {",
|
|
93
|
+
' [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]',
|
|
94
|
+
" public static extern bool CredRead(string t, int ty, int f, out IntPtr c);",
|
|
95
|
+
' [DllImport("advapi32.dll")]',
|
|
96
|
+
" public static extern void CredFree(IntPtr c);",
|
|
97
|
+
" [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]",
|
|
98
|
+
" public struct CREDENTIAL {",
|
|
99
|
+
" public int Flags; public int Type; public string TargetName;",
|
|
100
|
+
" public string Comment; public long LastWritten;",
|
|
101
|
+
" public int CredentialBlobSize; public IntPtr CredentialBlob;",
|
|
102
|
+
" public int Persist; public int AttributeCount; public IntPtr Attributes;",
|
|
103
|
+
" public string TargetAlias; public string UserName;",
|
|
104
|
+
" }",
|
|
105
|
+
"}",
|
|
106
|
+
"'@",
|
|
107
|
+
"$p=[IntPtr]::Zero",
|
|
108
|
+
`if([NpCred]::CredRead('${target}',1,0,[ref]$p)){`,
|
|
109
|
+
" $c=[Runtime.InteropServices.Marshal]::PtrToStructure($p,[Type][NpCred+CREDENTIAL])",
|
|
110
|
+
" $s=[Runtime.InteropServices.Marshal]::PtrToStringUni($c.CredentialBlob,$c.CredentialBlobSize/2)",
|
|
111
|
+
" [NpCred]::CredFree($p)",
|
|
112
|
+
" Write-Output $s",
|
|
113
|
+
"}",
|
|
114
|
+
].join("\n");
|
|
115
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
86
116
|
const { stdout } = await execFileAsync("powershell.exe", [
|
|
87
117
|
"-NoProfile",
|
|
88
|
-
"-
|
|
89
|
-
|
|
118
|
+
"-EncodedCommand",
|
|
119
|
+
encoded,
|
|
90
120
|
]);
|
|
91
121
|
const result = stdout.trim();
|
|
92
122
|
return result && result !== "" ? result : null;
|
|
93
123
|
}
|
|
94
124
|
catch {
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
const target = this.target(service, account);
|
|
98
|
-
const { stdout } = await execFileAsync("powershell.exe", [
|
|
99
|
-
"-NoProfile",
|
|
100
|
-
"-Command",
|
|
101
|
-
`[System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR((New-Object System.Management.Automation.PSCredential('_', (cmdkey /generic:${target} /list 2>$null | Out-Null; [System.Security.SecureString]::new()))).Password))`,
|
|
102
|
-
]);
|
|
103
|
-
return stdout.trim() || null;
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
125
|
+
return null;
|
|
108
126
|
}
|
|
109
127
|
}
|
|
110
128
|
async set(service, account, value) {
|
|
@@ -31,7 +31,7 @@ export function configFilePath(dataDir) {
|
|
|
31
31
|
return path.join(dataDir, "config.json");
|
|
32
32
|
}
|
|
33
33
|
// ── Configuration loader ─────────────────────────────────────────────────
|
|
34
|
-
const DEFAULT_CLOUD_API_URL = "https://api.nestpilot.
|
|
34
|
+
const DEFAULT_CLOUD_API_URL = "https://api.nestpilot.net";
|
|
35
35
|
/**
|
|
36
36
|
* Loads the local configuration from environment variables.
|
|
37
37
|
*
|
|
@@ -18,3 +18,5 @@ export { scrubPII, validateNoPII } from "./pii-scrubber.js";
|
|
|
18
18
|
export type { ScrubResult } from "./pii-scrubber.js";
|
|
19
19
|
export { CloudComputeClient } from "./cloud-compute-client.js";
|
|
20
20
|
export type { CloudComputeResult } from "./cloud-compute-client.js";
|
|
21
|
+
export { DeviceAuthClient } from "./device-auth.js";
|
|
22
|
+
export type { DeviceCodeResponse, DeviceTokenResponse, ApiKeyCreatedResponse, } from "./device-auth.js";
|
|
@@ -13,3 +13,4 @@ export { createKeychainProvider, FileKeychain, } from "./keychain.js";
|
|
|
13
13
|
export { LocalPlanStore } from "./local-plan-store.js";
|
|
14
14
|
export { scrubPII, validateNoPII } from "./pii-scrubber.js";
|
|
15
15
|
export { CloudComputeClient } from "./cloud-compute-client.js";
|
|
16
|
+
export { DeviceAuthClient } from "./device-auth.js";
|
|
@@ -207,7 +207,7 @@ Requires internet connectivity for cloud compute. Cached results are returned if
|
|
|
207
207
|
}, true);
|
|
208
208
|
}
|
|
209
209
|
if (removedFields.length > 0) {
|
|
210
|
-
console.
|
|
210
|
+
console.error(`[local] PII scrubber removed ${removedFields.length} field(s): ${removedFields.join(", ")}`);
|
|
211
211
|
}
|
|
212
212
|
// Call cloud compute
|
|
213
213
|
const result = await computeClient.forecast(scrubbed);
|
|
@@ -80,9 +80,16 @@ function runPipeline(opts) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
if (contract) {
|
|
83
|
+
const resolvedUserId = opts.actorId ?? opts.authCtx?.userId ?? "anonymous";
|
|
84
|
+
const resolvedRole = opts.role ??
|
|
85
|
+
(opts.authCtx?.bearerToken
|
|
86
|
+
? "authenticated"
|
|
87
|
+
: resolvedUserId !== "anonymous"
|
|
88
|
+
? "authenticated"
|
|
89
|
+
: "anonymous");
|
|
83
90
|
const policyCtx = {
|
|
84
|
-
actorId:
|
|
85
|
-
role:
|
|
91
|
+
actorId: resolvedUserId,
|
|
92
|
+
role: resolvedRole,
|
|
86
93
|
toolName: opts.toolName,
|
|
87
94
|
};
|
|
88
95
|
const decision = evaluatePolicy(policyCtx);
|
|
@@ -32,7 +32,7 @@ const POLICY_TARGET_BRACKETS = {
|
|
|
32
32
|
};
|
|
33
33
|
// ── Tool registration ───────────────────────────────────────────────────
|
|
34
34
|
export function registerRothTools(server, authCtx) {
|
|
35
|
-
console.
|
|
35
|
+
console.error("registerRothTools: Starting registration...");
|
|
36
36
|
try {
|
|
37
37
|
registerAppTool(server, "optimize_roth_conversion", {
|
|
38
38
|
title: "Optimize Roth Conversion",
|
|
@@ -377,7 +377,7 @@ DO NOT USE for basic forecasting — use run_forecast instead.`,
|
|
|
377
377
|
}, true);
|
|
378
378
|
}
|
|
379
379
|
});
|
|
380
|
-
console.
|
|
380
|
+
console.error("registerRothTools: Registration successful!");
|
|
381
381
|
}
|
|
382
382
|
catch (error) {
|
|
383
383
|
console.error("registerRothTools: FAILED to register optimize_roth_conversion:", error);
|
package/host-configs/cowork.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
+
"_comment": "NestPilot MCP Server — Claude Cowork (Claude Desktop) Configuration. Run `npx --yes --package=@nestpilot/mcp-app nestpilot init` first, then add this to your claude_desktop_config.json.",
|
|
2
3
|
"mcpServers": {
|
|
3
4
|
"nestpilot": {
|
|
4
5
|
"command": "npx",
|
|
5
|
-
"args": [
|
|
6
|
+
"args": [
|
|
7
|
+
"--yes",
|
|
8
|
+
"--package=@nestpilot/mcp-app",
|
|
9
|
+
"nestpilot-mcp-server"
|
|
10
|
+
],
|
|
6
11
|
"env": {
|
|
7
|
-
"NESTPILOT_MODE": "local"
|
|
12
|
+
"NESTPILOT_MODE": "local",
|
|
13
|
+
"NESTPILOT_DATA_DIR": "~/.nestpilot"
|
|
8
14
|
}
|
|
9
15
|
}
|
|
10
16
|
}
|
package/host-configs/goose.yaml
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# NestPilot MCP Server — Goose Host Configuration
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# Installation
|
|
4
|
+
# ------------
|
|
5
|
+
# 1. Run the initializer first (one-time setup):
|
|
6
|
+
# npx --yes --package=@nestpilot/mcp-app nestpilot init
|
|
5
7
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
+
# 2. Add the block below to your Goose profile:
|
|
9
|
+
# ~/.config/goose/profiles.yaml
|
|
10
|
+
# under the `extensions:` key of your active profile.
|
|
11
|
+
#
|
|
12
|
+
# 3. Restart Goose.
|
|
8
13
|
#
|
|
9
14
|
# @feature FEAT-0088
|
|
10
15
|
|
|
@@ -13,10 +18,13 @@ extensions:
|
|
|
13
18
|
type: mcp
|
|
14
19
|
transport: stdio
|
|
15
20
|
command: npx
|
|
16
|
-
args:
|
|
21
|
+
args:
|
|
22
|
+
- "--yes"
|
|
23
|
+
- "--package=@nestpilot/mcp-app"
|
|
24
|
+
- "nestpilot-mcp-server"
|
|
17
25
|
env:
|
|
18
26
|
NESTPILOT_MODE: local
|
|
27
|
+
NESTPILOT_DATA_DIR: "~/.nestpilot"
|
|
19
28
|
# Optional overrides:
|
|
20
|
-
#
|
|
21
|
-
# NESTPILOT_CLOUD_API_URL: "https://api.nestpilot.com"
|
|
29
|
+
# NESTPILOT_CLOUD_API_URL: "https://api.nestpilot.net"
|
|
22
30
|
# NESTPILOT_API_KEY: "your-api-key"
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nestpilot",
|
|
3
|
+
"package": "@nestpilot/mcp-app",
|
|
3
4
|
"version": "1.0.0",
|
|
4
5
|
"description": "Local-first retirement planning — your data stays on your machine",
|
|
6
|
+
"homepage": "https://www.npmjs.com/package/@nestpilot/mcp-app",
|
|
5
7
|
"transport": "stdio",
|
|
6
8
|
"command": "npx",
|
|
7
|
-
"args": [
|
|
9
|
+
"args": [
|
|
10
|
+
"--yes",
|
|
11
|
+
"--package=@nestpilot/mcp-app",
|
|
12
|
+
"nestpilot-mcp-server"
|
|
13
|
+
],
|
|
8
14
|
"env": {
|
|
9
|
-
"NESTPILOT_MODE": "local"
|
|
15
|
+
"NESTPILOT_MODE": "local",
|
|
16
|
+
"NESTPILOT_DATA_DIR": "~/.nestpilot"
|
|
10
17
|
},
|
|
11
18
|
"capabilities": {
|
|
12
19
|
"tools": true,
|
|
13
20
|
"resources": true,
|
|
14
21
|
"apps": true
|
|
22
|
+
},
|
|
23
|
+
"setup": {
|
|
24
|
+
"instructions": "Run `npx --yes --package=@nestpilot/mcp-app nestpilot init` before first use."
|
|
15
25
|
}
|
|
16
26
|
}
|