@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 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. Optionally provision API key via cloud endpoint
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 (optional)
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
- console.log(" ✓ API key already configured");
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 email = await rl.question(" Enter your email for free tier (10 forecasts/mo), or press Enter to skip: ");
61
- if (email.trim()) {
62
- try {
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. Set NESTPILOT_API_KEY env var later.");
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: ["nestpilot-mcp-server"]
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": ["nestpilot-mcp-server"],
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
- # Add this to your Goose profile configuration:
4
- # ~/.config/goose/profiles.yaml
3
+ # Installation
4
+ # ------------
5
+ # 1. Run the initializer first (one-time setup):
6
+ # npx --yes --package=@nestpilot/mcp-app nestpilot init
5
7
  #
6
- # Under the `extensions:` section of your active profile,
7
- # paste the following block.
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: ["nestpilot-mcp-server"]
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
- # NESTPILOT_DATA_DIR: "~/.nestpilot"
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": ["nestpilot-mcp-server"],
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
- await factory().connect(new StdioServerTransport());
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.log("[server] Starting in LOCAL mode — data stays on this machine");
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.log("Registering Local Plan tools...");
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.log("Registering Medicare tools (cloud)...");
83
+ console.error("Registering Medicare tools (cloud)...");
84
84
  registerMedicareTools(server, authCtx);
85
- console.log("Registering Roth tools (cloud)...");
85
+ console.error("Registering Roth tools (cloud)...");
86
86
  registerRothTools(server, authCtx);
87
- console.log("Registering Report tools (cloud)...");
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.log("[server] Starting in CLOUD mode — proxy to Spring Boot API");
93
- console.log("Registering Medicare tools...");
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.log("Registering Planning tools...");
95
+ console.error("Registering Planning tools...");
96
96
  registerPlanningTools(server, authCtx);
97
- console.log("Registering Roth tools...");
97
+ console.error("Registering Roth tools...");
98
98
  registerRothTools(server, authCtx);
99
- console.log("Registering Agent tools...");
99
+ console.error("Registering Agent tools...");
100
100
  registerAgentTools(server, authCtx);
101
- console.log("Registering Plan Management tools...");
101
+ console.error("Registering Plan Management tools...");
102
102
  registerPlanManagementTools(server, authCtx);
103
- console.log("Registering Forecast Management tools...");
103
+ console.error("Registering Forecast Management tools...");
104
104
  registerForecastManagementTools(server, authCtx);
105
- console.log("Registering Scenario Management tools...");
105
+ console.error("Registering Scenario Management tools...");
106
106
  registerScenarioManagementTools(server, authCtx);
107
- console.log("Registering Proposal tools...");
107
+ console.error("Registering Proposal tools...");
108
108
  registerProposalTools(server, authCtx);
109
- console.log("Registering Report tools...");
109
+ console.error("Registering Report tools...");
110
110
  registerReportTools(server, authCtx);
111
111
  }
112
112
  // ── Shared registrations (both modes) ─────────────────────────────────
113
- console.log("Registering views...");
113
+ console.error("Registering views...");
114
114
  registerPlannerView(server);
115
115
  registerVerificationPacketView(server);
116
116
  registerSkillResources(server);
117
- console.log("Server registration complete");
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
- "-Command",
89
- `$cred = Get-StoredCredential -Target '${target}' -ErrorAction SilentlyContinue; if ($cred) { $cred.GetNetworkCredential().Password } else { $null }`,
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
- // Fallback: try cmdkey
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.com";
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.log(`[local] PII scrubber removed ${removedFields.length} field(s): ${removedFields.join(", ")}`);
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: opts.actorId ?? opts.authCtx?.userId ?? "anonymous",
85
- role: opts.role ?? (opts.authCtx?.bearerToken ? "authenticated" : "anonymous"),
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.log("registerRothTools: Starting registration...");
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.log("registerRothTools: Registration successful!");
380
+ console.error("registerRothTools: Registration successful!");
381
381
  }
382
382
  catch (error) {
383
383
  console.error("registerRothTools: FAILED to register optimize_roth_conversion:", error);
@@ -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": ["nestpilot-mcp-server"],
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
- # Add this to your Goose profile configuration:
4
- # ~/.config/goose/profiles.yaml
3
+ # Installation
4
+ # ------------
5
+ # 1. Run the initializer first (one-time setup):
6
+ # npx --yes --package=@nestpilot/mcp-app nestpilot init
5
7
  #
6
- # Under the `extensions:` section of your active profile,
7
- # paste the following block.
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: ["nestpilot-mcp-server"]
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
- # NESTPILOT_DATA_DIR: "~/.nestpilot"
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": ["nestpilot-mcp-server"],
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestpilot/mcp-app",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "NestPilot MCP App — Retirement planning tools and interactive views",
6
6
  "bin": {