@finctl/mcp 0.1.0

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 ADDED
@@ -0,0 +1,224 @@
1
+ # @finctl/mcp
2
+
3
+ FinCtl's [Model Context Protocol](https://modelcontextprotocol.io) server. Exposes AWS
4
+ cost intelligence — spend summaries, top services, savings recommendations, rightsizing,
5
+ forecasts, anomalies, and account info — directly inside AI-assisted dev tools (Cursor,
6
+ VS Code Copilot, Kiro, Claude Code, and any MCP-compatible client).
7
+
8
+ > **Status:** all 10 tools return live data from the FinCtl backend when an endpoint is
9
+ > configured (FIN-1467/1468/1469). Without an endpoint, each cost tool returns a clear
10
+ > "not configured" message.
11
+
12
+ ## Install & run
13
+
14
+ ```bash
15
+ export FINCTL_API_KEY=fct_live_xxxx # required
16
+ npx @finctl/mcp # stdio transport, no install
17
+ ```
18
+
19
+ Or install globally:
20
+
21
+ ```bash
22
+ npm install -g @finctl/mcp
23
+ finctl-mcp --version
24
+ ```
25
+
26
+ ### CLI options
27
+
28
+ ```
29
+ finctl-mcp [options]
30
+
31
+ --http Run the HTTP/SSE transport instead of stdio
32
+ --port <n> HTTP port (default 3000; or PORT env)
33
+ --endpoint <url> FinCtl backend base URL (or FINCTL_ENDPOINT env)
34
+ -v, --version Print version and exit
35
+ -h, --help Print usage and exit
36
+ ```
37
+
38
+ `--version` and `--help` work without an API key; starting the server requires
39
+ `FINCTL_API_KEY`.
40
+
41
+ ### From source
42
+
43
+ ```bash
44
+ npm install
45
+ npm run build && node dist/index.js # stdio
46
+ npm run dev # stdio, no build (tsx)
47
+ npm run dev:http # HTTP/SSE on :3000
48
+ ```
49
+
50
+ ## IDE integrations
51
+
52
+ | Tool | Guide |
53
+ | ---- | ----- |
54
+ | Cursor | [docs/integrations/cursor.md](docs/integrations/cursor.md) |
55
+ | Claude Code | [docs/integrations/claude-code.md](docs/integrations/claude-code.md) |
56
+ | Cline / Roo Code | [docs/integrations/cline.md](docs/integrations/cline.md) |
57
+ | Amazon Q Developer | [docs/integrations/amazon-q.md](docs/integrations/amazon-q.md) |
58
+ | VS Code (GitHub Copilot) | [docs/integrations/vscode.md](docs/integrations/vscode.md) |
59
+ | Kiro | [docs/integrations/kiro.md](docs/integrations/kiro.md) |
60
+ | Continue.dev | [docs/integrations/continue.md](docs/integrations/continue.md) |
61
+ | Windsurf | [docs/integrations/windsurf.md](docs/integrations/windsurf.md) |
62
+ | JetBrains AI Assistant | [docs/integrations/jetbrains.md](docs/integrations/jetbrains.md) |
63
+ | OpenAI Codex CLI | [docs/integrations/codex.md](docs/integrations/codex.md) |
64
+ | Raycast | [docs/integrations/raycast.md](docs/integrations/raycast.md) |
65
+
66
+ Verify every tool is callable end-to-end (the same round-trip an IDE performs):
67
+
68
+ ```bash
69
+ FINCTL_API_KEY=your-key npm run validate
70
+ ```
71
+
72
+ ## Transports
73
+
74
+ | Transport | Command | Used by |
75
+ | -- | -- | -- |
76
+ | **stdio** | `finctl-mcp` (default) | Local IDE integrations — the client spawns the process and speaks JSON-RPC over stdin/stdout. |
77
+ | **HTTP/SSE** | `finctl-mcp --http [--port N]` | Hosted/remote connections and web MCP clients. Uses the Streamable HTTP transport (carries server→client messages as Server-Sent Events). |
78
+
79
+ The HTTP server exposes:
80
+
81
+ - `POST /mcp` — MCP endpoint (stateless)
82
+ - `GET /health` — liveness check
83
+
84
+ For a hosted endpoint on AWS (Docker + ECS Fargate behind an ALB, SSE-tuned), see
85
+ [docs/deployment/aws.md](docs/deployment/aws.md). For the per-customer (one
86
+ isolated AWS account each) deploy runbook, see
87
+ [docs/deployment/per-customer-deployment.md](docs/deployment/per-customer-deployment.md).
88
+ Quick local container run:
89
+
90
+ ```bash
91
+ docker build -t finctl-mcp .
92
+ docker run --rm -p 3000:3000 -e FINCTL_API_KEY=fct_live_xxxx finctl-mcp
93
+ ```
94
+
95
+ ## Tool catalog
96
+
97
+ | Tool | Description |
98
+ | -- | -- |
99
+ | `get_cost_summary` | Total spend, top accounts, top services |
100
+ | `list_top_services` | Top services by cost |
101
+ | `get_recommendations` | Active savings recommendations |
102
+ | `get_rightsizing` | EC2/RDS rightsizing opportunities |
103
+ | `get_resource_details` | One resource's type, account, active recommendations |
104
+ | `get_forecast` | Projected spend, growth, budget variance |
105
+ | `get_anomalies` | Recent cost anomalies, sorted by severity |
106
+ | `get_budget_status` | Budgets: spend, % consumed, projected, on-track |
107
+ | `list_accounts` | Linked AWS accounts |
108
+ | `get_savings_plans_coverage` | RI/SP coverage and waste |
109
+
110
+ Tool definitions (names, descriptions, input schemas) live in
111
+ [`src/tools/catalog.ts`](src/tools/catalog.ts). Adding a tool = adding one entry there.
112
+
113
+ ## Project structure
114
+
115
+ ```
116
+ finctl-mcp/
117
+ ├── package.json
118
+ ├── tsconfig.json
119
+ ├── README.md
120
+ ├── scripts/
121
+ │ └── smoke.mjs # build-time smoke test (version, handshake, tool list)
122
+ ├── .github/workflows/ # CI (build + test) and Publish (on version tag)
123
+ └── src/
124
+ ├── index.ts # entrypoint — flags + transport selection
125
+ ├── version.ts # runtime version (reads package.json)
126
+ ├── server.ts # builds the McpServer, registers the catalog
127
+ ├── auth/ # API key validation, scoping, rate limiting
128
+ ├── client/
129
+ │ └── finctl-client.ts # HTTP client for the FinCtl backend
130
+ ├── tools/
131
+ │ └── catalog.ts # tool definitions + handlers
132
+ └── transports/
133
+ ├── stdio.ts # local IDE transport
134
+ └── http.ts # hosted Streamable HTTP/SSE transport
135
+ ```
136
+
137
+ ## Publishing
138
+
139
+ CI (`.github/workflows`) builds and runs the smoke test on every push/PR. Pushing a
140
+ version tag publishes to npm:
141
+
142
+ ```bash
143
+ npm version patch # bump package.json + create vX.Y.Z tag
144
+ git push --follow-tags # CI publishes @finctl/mcp on the tag
145
+ ```
146
+
147
+ The publish workflow verifies the tag matches `package.json`, runs build + test, then
148
+ `npm publish --provenance --access public`. Requires an `NPM_TOKEN` repo secret with
149
+ publish rights to the `@finctl` scope — see
150
+ [docs/deployment/npm-publishing.md](docs/deployment/npm-publishing.md) for one-time token
151
+ setup and the release steps.
152
+
153
+ > Stretch (not yet done): Homebrew tap (`brew install finctl/tap/finctl-mcp`) and
154
+ > automated changelog generation.
155
+
156
+ ## Verifying the handshake
157
+
158
+ ```bash
159
+ # stdio: send an initialize request and read the response (key required)
160
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' \
161
+ | FINCTL_API_KEY=fct_test_local node dist/index.js
162
+ ```
163
+
164
+ ## Authentication
165
+
166
+ Every request authenticates with a per-customer API key. The key resolves to a customer
167
+ ID and account scope; all tool responses carry that scope, so a customer only ever sees
168
+ their own data.
169
+
170
+ | Transport | How the key is passed |
171
+ | -- | -- |
172
+ | stdio | `FINCTL_API_KEY` environment variable (one customer per process; missing/invalid key fails fast at startup) |
173
+ | HTTP | `Authorization: Bearer <key>` header (validated per request) |
174
+
175
+ Invalid or missing keys are rejected before reaching the MCP layer (JSON-RPC error
176
+ `-32001`, HTTP 401). Each key is rate-limited (default 60 req/min, JSON-RPC `-32002`,
177
+ HTTP 429).
178
+
179
+ ### Configuration (env)
180
+
181
+ | Var | Purpose | Default |
182
+ | -- | -- | -- |
183
+ | `FINCTL_API_KEY` | Active API key | — (required) |
184
+ | `FINCTL_CUSTOMER_ID` | Customer the key maps to | `local` |
185
+ | `FINCTL_ACCOUNT_IDS` | Comma-separated AWS account scope (empty = all customer accounts) | — |
186
+ | `FINCTL_RATE_LIMIT` | Requests per minute per key | `60` |
187
+ | `FINCTL_API_KEY_PREVIOUS` | Prior key, accepted during rotation grace | — |
188
+ | `FINCTL_API_KEY_PREVIOUS_EXPIRES_AT` | Epoch ms after which the prior key is rejected | — |
189
+
190
+ **Zero-downtime rotation:** set the new key as `FINCTL_API_KEY`, the old one as
191
+ `FINCTL_API_KEY_PREVIOUS`, and `FINCTL_API_KEY_PREVIOUS_EXPIRES_AT` to ~5 min out. Both
192
+ keys work until the old one expires.
193
+
194
+ ## Backend connection
195
+
196
+ The cost tools proxy to the FinCtl backend (the `dashboard-api` Lambda in `finctl-core`)
197
+ rather than querying AWS directly. Point the server at a backend:
198
+
199
+ | Var | Purpose |
200
+ | -- | -- |
201
+ | `FINCTL_ENDPOINT` (or `FINCTL_DASHBOARD_API_URL`) | Backend base URL. Without it, cost tools return a clear "not configured" error. |
202
+ | `FINCTL_MCP_SERVICE_SECRET` | Shared service secret (= backend `MCP_VALIDATE_SECRET`). Authenticates the MCP server to the backend. |
203
+ | `FINCTL_BACKEND_TOKEN` | Optional Cognito Bearer token, if a deployment uses one. |
204
+
205
+ Each request forwards `X-Finctl-Mcp-Secret: <FINCTL_MCP_SERVICE_SECRET>` and
206
+ `X-Customer-Id: <customerId>` (resolved from the caller's API key) so the backend
207
+ authenticates and scopes the data (FIN-2648). Endpoints used:
208
+ `/api/accounts`, `/api/accounts/spend-summary`, `/api/resources/top-cost`,
209
+ `/api/recommendations`, `/api/org/sp-utilization`, `/api/org/ri-utilization`,
210
+ `/api/forecast`, `/api/anomalies`, `/api/budgets`.
211
+
212
+ ### Key store
213
+
214
+ Key validation goes through a pluggable [`KeyStore`](src/auth/store.ts) interface:
215
+
216
+ - **`BackendKeyStore`** (production) — when `FINCTL_ENDPOINT` + `FINCTL_MCP_SERVICE_SECRET`
217
+ are set, presented keys are validated against the backend's
218
+ `POST /api/mcp-keys/validate` (FIN-2646), which resolves the customer scope from the
219
+ portal-generated key records. Positive results are cached ~60s.
220
+ - **`EnvKeyStore`** (local/dev) — used when no backend is configured; validates a single
221
+ `FINCTL_API_KEY` from the env (see the table above), with rotation grace.
222
+
223
+ Customer-facing key generation/revocation lives in the FinCtl backend + portal UI
224
+ (FIN-1475); this repo consumes those keys.
@@ -0,0 +1,37 @@
1
+ import type { AuthContext } from "./context.js";
2
+ import { type KeyStore } from "./store.js";
3
+ import { type RateLimiter } from "./rate-limit.js";
4
+ export type AuthErrorCode = "unauthorized" | "rate_limited";
5
+ /** Authentication/authorization failure, mapped to a JSON-RPC error by callers. */
6
+ export declare class AuthError extends Error {
7
+ readonly code: AuthErrorCode;
8
+ constructor(code: AuthErrorCode, message: string);
9
+ }
10
+ /**
11
+ * Validates a presented API key and enforces per-key rate limiting, producing
12
+ * the {@link AuthContext} that scopes a request to one customer.
13
+ */
14
+ export declare class Authenticator {
15
+ private readonly store;
16
+ private readonly limiter;
17
+ constructor(store: KeyStore, limiter: RateLimiter);
18
+ authenticate(presentedKey: string | undefined): Promise<AuthContext>;
19
+ }
20
+ /**
21
+ * Build the default authenticator from environment configuration:
22
+ * an {@link EnvKeyStore} plus a 60 req/min per-key limiter.
23
+ *
24
+ * FINCTL_RATE_LIMIT requests per minute per key (default 60)
25
+ *
26
+ * Store selection: when a backend endpoint and the shared MCP service secret are
27
+ * configured, keys are validated against the backend (production — customers'
28
+ * portal-generated keys live there). Otherwise an env-backed key is used for
29
+ * local dev.
30
+ *
31
+ * FINCTL_ENDPOINT | FINCTL_DASHBOARD_API_URL backend base URL
32
+ * FINCTL_MCP_SERVICE_SECRET = backend MCP_VALIDATE_SECRET
33
+ */
34
+ export declare function createAuthenticator(env?: NodeJS.ProcessEnv): {
35
+ authenticator: Authenticator;
36
+ store: KeyStore;
37
+ };
@@ -0,0 +1,66 @@
1
+ import { EnvKeyStore } from "./store.js";
2
+ import { BackendKeyStore } from "./backend-store.js";
3
+ import { FixedWindowRateLimiter } from "./rate-limit.js";
4
+ /** Authentication/authorization failure, mapped to a JSON-RPC error by callers. */
5
+ export class AuthError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "AuthError";
11
+ }
12
+ }
13
+ /**
14
+ * Validates a presented API key and enforces per-key rate limiting, producing
15
+ * the {@link AuthContext} that scopes a request to one customer.
16
+ */
17
+ export class Authenticator {
18
+ store;
19
+ limiter;
20
+ constructor(store, limiter) {
21
+ this.store = store;
22
+ this.limiter = limiter;
23
+ }
24
+ async authenticate(presentedKey) {
25
+ if (!presentedKey) {
26
+ throw new AuthError("unauthorized", "Missing API key");
27
+ }
28
+ const validation = await this.store.validate(presentedKey);
29
+ if (!validation) {
30
+ throw new AuthError("unauthorized", "Invalid API key");
31
+ }
32
+ if (!this.limiter.allow(validation.keyId)) {
33
+ throw new AuthError("rate_limited", "Rate limit exceeded");
34
+ }
35
+ return {
36
+ keyId: validation.keyId,
37
+ customerId: validation.customerId,
38
+ accountIds: validation.accountIds,
39
+ };
40
+ }
41
+ }
42
+ /**
43
+ * Build the default authenticator from environment configuration:
44
+ * an {@link EnvKeyStore} plus a 60 req/min per-key limiter.
45
+ *
46
+ * FINCTL_RATE_LIMIT requests per minute per key (default 60)
47
+ *
48
+ * Store selection: when a backend endpoint and the shared MCP service secret are
49
+ * configured, keys are validated against the backend (production — customers'
50
+ * portal-generated keys live there). Otherwise an env-backed key is used for
51
+ * local dev.
52
+ *
53
+ * FINCTL_ENDPOINT | FINCTL_DASHBOARD_API_URL backend base URL
54
+ * FINCTL_MCP_SERVICE_SECRET = backend MCP_VALIDATE_SECRET
55
+ */
56
+ export function createAuthenticator(env = process.env) {
57
+ const baseUrl = (env.FINCTL_ENDPOINT || env.FINCTL_DASHBOARD_API_URL)?.trim();
58
+ const serviceSecret = env.FINCTL_MCP_SERVICE_SECRET?.trim();
59
+ const store = baseUrl && serviceSecret
60
+ ? new BackendKeyStore({ baseUrl, serviceSecret })
61
+ : new EnvKeyStore(env);
62
+ const limit = Number(env.FINCTL_RATE_LIMIT) || 60;
63
+ const authenticator = new Authenticator(store, new FixedWindowRateLimiter(limit));
64
+ return { authenticator, store };
65
+ }
66
+ //# sourceMappingURL=authenticator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authenticator.js","sourceRoot":"","sources":["../../src/auth/authenticator.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAiB,MAAM,YAAY,CAAC;AACxD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,sBAAsB,EAAoB,MAAM,iBAAiB,CAAC;AAI3E,mFAAmF;AACnF,MAAM,OAAO,SAAU,SAAQ,KAAK;IAEvB;IADX,YACW,IAAmB,EAC5B,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHN,SAAI,GAAJ,IAAI,CAAe;QAI5B,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;IAC1B,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,OAAO,aAAa;IAEL;IACA;IAFnB,YACmB,KAAe,EACf,OAAoB;QADpB,UAAK,GAAL,KAAK,CAAU;QACf,YAAO,GAAP,OAAO,CAAa;IACpC,CAAC;IAEJ,KAAK,CAAC,YAAY,CAAC,YAAgC;QACjD,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,MAAM,IAAI,SAAS,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAAC;QACzD,CAAC;QACD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;QAC3D,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,MAAM,IAAI,SAAS,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAAC;QACzD,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,SAAS,CAAC,cAAc,EAAE,qBAAqB,CAAC,CAAC;QAC7D,CAAC;QACD,OAAO;YACL,KAAK,EAAE,UAAU,CAAC,KAAK;YACvB,UAAU,EAAE,UAAU,CAAC,UAAU;YACjC,UAAU,EAAE,UAAU,CAAC,UAAU;SAClC,CAAC;IACJ,CAAC;CACF;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAyB,OAAO,CAAC,GAAG;IAItE,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,wBAAwB,CAAC,EAAE,IAAI,EAAE,CAAC;IAC9E,MAAM,aAAa,GAAG,GAAG,CAAC,yBAAyB,EAAE,IAAI,EAAE,CAAC;IAC5D,MAAM,KAAK,GACT,OAAO,IAAI,aAAa;QACtB,CAAC,CAAC,IAAI,eAAe,CAAC,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC;QACjD,CAAC,CAAC,IAAI,WAAW,CAAC,GAAG,CAAC,CAAC;IAE3B,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,iBAAiB,CAAC,IAAI,EAAE,CAAC;IAClD,MAAM,aAAa,GAAG,IAAI,aAAa,CAAC,KAAK,EAAE,IAAI,sBAAsB,CAAC,KAAK,CAAC,CAAC,CAAC;IAClF,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;AAClC,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { type KeyStore, type KeyValidation } from "./store.js";
2
+ export interface BackendKeyStoreOptions {
3
+ baseUrl: string;
4
+ /** Shared service secret (sent as X-Finctl-Mcp-Secret; = backend MCP_VALIDATE_SECRET). */
5
+ serviceSecret: string;
6
+ cacheTtlMs?: number;
7
+ timeoutMs?: number;
8
+ fetchImpl?: typeof fetch;
9
+ }
10
+ export declare class BackendKeyStore implements KeyStore {
11
+ private readonly baseUrl;
12
+ private readonly serviceSecret;
13
+ private readonly cacheTtlMs;
14
+ private readonly timeoutMs;
15
+ private readonly fetchImpl;
16
+ private readonly cache;
17
+ constructor(opts: BackendKeyStoreOptions);
18
+ validate(presentedKey: string): Promise<KeyValidation | null>;
19
+ }
@@ -0,0 +1,57 @@
1
+ import { keyId } from "./store.js";
2
+ export class BackendKeyStore {
3
+ baseUrl;
4
+ serviceSecret;
5
+ cacheTtlMs;
6
+ timeoutMs;
7
+ fetchImpl;
8
+ cache = new Map();
9
+ constructor(opts) {
10
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
11
+ this.serviceSecret = opts.serviceSecret;
12
+ this.cacheTtlMs = opts.cacheTtlMs ?? 60_000;
13
+ this.timeoutMs = opts.timeoutMs ?? 10_000;
14
+ this.fetchImpl = opts.fetchImpl ?? fetch;
15
+ }
16
+ async validate(presentedKey) {
17
+ const id = keyId(presentedKey);
18
+ const cached = this.cache.get(id);
19
+ if (cached && cached.expiresAt > Date.now())
20
+ return cached.validation;
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
23
+ let res;
24
+ try {
25
+ res = await this.fetchImpl(`${this.baseUrl}/api/mcp-keys/validate`, {
26
+ method: "POST",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ Accept: "application/json",
30
+ "X-Finctl-Mcp-Secret": this.serviceSecret,
31
+ },
32
+ body: JSON.stringify({ key: presentedKey }),
33
+ signal: controller.signal,
34
+ });
35
+ }
36
+ catch {
37
+ // Network/timeout — treat as not-valid (caller surfaces an auth error).
38
+ return null;
39
+ }
40
+ finally {
41
+ clearTimeout(timer);
42
+ }
43
+ if (!res.ok)
44
+ return null; // 401 = invalid/revoked key
45
+ const data = (await res.json());
46
+ if (!data.customerId)
47
+ return null;
48
+ const validation = {
49
+ keyId: id,
50
+ customerId: data.customerId,
51
+ accountIds: data.accountIds ?? [],
52
+ };
53
+ this.cache.set(id, { validation, expiresAt: Date.now() + this.cacheTtlMs });
54
+ return validation;
55
+ }
56
+ }
57
+ //# sourceMappingURL=backend-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend-store.js","sourceRoot":"","sources":["../../src/auth/backend-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAqC,MAAM,YAAY,CAAC;AAyBtE,MAAM,OAAO,eAAe;IACT,OAAO,CAAS;IAChB,aAAa,CAAS;IACtB,UAAU,CAAS;IACnB,SAAS,CAAS;IAClB,SAAS,CAAe;IACxB,KAAK,GAAG,IAAI,GAAG,EAAsB,CAAC;IAEvD,YAAY,IAA4B;QACtC,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAChD,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,CAAC;QACxC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,MAAM,CAAC;QAC5C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,MAAM,CAAC;QAC1C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC;IAC3C,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,YAAoB;QACjC,MAAM,EAAE,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC;QAE/B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAClC,IAAI,MAAM,IAAI,MAAM,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE;YAAE,OAAO,MAAM,CAAC,UAAU,CAAC;QAEtE,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACnE,IAAI,GAAa,CAAC;QAClB,IAAI,CAAC;YACH,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,OAAO,wBAAwB,EAAE;gBAClE,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;oBAClC,MAAM,EAAE,kBAAkB;oBAC1B,qBAAqB,EAAE,IAAI,CAAC,aAAa;iBAC1C;gBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,YAAY,EAAE,CAAC;gBAC3C,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,wEAAwE;YACxE,OAAO,IAAI,CAAC;QACd,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;QAED,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,IAAI,CAAC,CAAC,4BAA4B;QACtD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAmD,CAAC;QAClF,IAAI,CAAC,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,CAAC;QAElC,MAAM,UAAU,GAAkB;YAChC,KAAK,EAAE,EAAE;YACT,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,EAAE;SAClC,CAAC;QACF,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC;QAC5E,OAAO,UAAU,CAAC;IACpB,CAAC;CACF"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The authenticated caller's scope. Resolved from an API key and made available
3
+ * to every tool handler so responses can be filtered to this customer's data —
4
+ * no cross-customer leakage.
5
+ */
6
+ export interface AuthContext {
7
+ /** Stable, non-secret identifier for the key (safe to log). */
8
+ keyId: string;
9
+ /** Customer the key belongs to. All data queries filter by this. */
10
+ customerId: string;
11
+ /** AWS accounts this key may read. Empty = all of the customer's accounts. */
12
+ accountIds: string[];
13
+ }
14
+ export declare function setProcessAuth(ctx: AuthContext | null): void;
15
+ /** Run `fn` with `ctx` as the active auth context (used per-request by HTTP). */
16
+ export declare function runWithAuth<T>(ctx: AuthContext, fn: () => T): T;
17
+ /** Current auth context, or null if none is active. */
18
+ export declare function currentAuthOrNull(): AuthContext | null;
19
+ /**
20
+ * Current auth context. Throws if none is active — tool handlers must never run
21
+ * unauthenticated, so this throwing is a safety net, not normal control flow.
22
+ */
23
+ export declare function currentAuth(): AuthContext;
@@ -0,0 +1,30 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const als = new AsyncLocalStorage();
3
+ /**
4
+ * Process-wide fallback context. The stdio transport serves a single customer
5
+ * per process, so it sets this once at startup rather than wrapping every
6
+ * incoming message. HTTP uses {@link runWithAuth} per request instead.
7
+ */
8
+ let processDefault = null;
9
+ export function setProcessAuth(ctx) {
10
+ processDefault = ctx;
11
+ }
12
+ /** Run `fn` with `ctx` as the active auth context (used per-request by HTTP). */
13
+ export function runWithAuth(ctx, fn) {
14
+ return als.run(ctx, fn);
15
+ }
16
+ /** Current auth context, or null if none is active. */
17
+ export function currentAuthOrNull() {
18
+ return als.getStore() ?? processDefault;
19
+ }
20
+ /**
21
+ * Current auth context. Throws if none is active — tool handlers must never run
22
+ * unauthenticated, so this throwing is a safety net, not normal control flow.
23
+ */
24
+ export function currentAuth() {
25
+ const ctx = currentAuthOrNull();
26
+ if (!ctx)
27
+ throw new Error("No authentication context — request reached a tool handler unauthenticated");
28
+ return ctx;
29
+ }
30
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../../src/auth/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAgBrD,MAAM,GAAG,GAAG,IAAI,iBAAiB,EAAe,CAAC;AAEjD;;;;GAIG;AACH,IAAI,cAAc,GAAuB,IAAI,CAAC;AAE9C,MAAM,UAAU,cAAc,CAAC,GAAuB;IACpD,cAAc,GAAG,GAAG,CAAC;AACvB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,WAAW,CAAI,GAAgB,EAAE,EAAW;IAC1D,OAAO,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AAC1B,CAAC;AAED,uDAAuD;AACvD,MAAM,UAAU,iBAAiB;IAC/B,OAAO,GAAG,CAAC,QAAQ,EAAE,IAAI,cAAc,CAAC;AAC1C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW;IACzB,MAAM,GAAG,GAAG,iBAAiB,EAAE,CAAC;IAChC,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAC;IACxG,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Per-key fixed-window rate limiter. In-memory — fine for a single stdio process
3
+ * and per-instance HTTP throttling. A hosted multi-instance deployment (FIN-1474)
4
+ * should swap this for a shared store (e.g. Redis/DynamoDB) behind the same
5
+ * {@link RateLimiter} interface.
6
+ */
7
+ export interface RateLimiter {
8
+ /** Record one request for `key`; return false if it exceeds the limit. */
9
+ allow(key: string): boolean;
10
+ }
11
+ export declare class FixedWindowRateLimiter implements RateLimiter {
12
+ private readonly limit;
13
+ private readonly windowMs;
14
+ private readonly windows;
15
+ constructor(limit?: number, windowMs?: number);
16
+ allow(key: string): boolean;
17
+ }
@@ -0,0 +1,22 @@
1
+ export class FixedWindowRateLimiter {
2
+ limit;
3
+ windowMs;
4
+ windows = new Map();
5
+ constructor(limit = 60, windowMs = 60_000) {
6
+ this.limit = limit;
7
+ this.windowMs = windowMs;
8
+ }
9
+ allow(key) {
10
+ const now = Date.now();
11
+ const win = this.windows.get(key);
12
+ if (!win || now >= win.resetAt) {
13
+ this.windows.set(key, { count: 1, resetAt: now + this.windowMs });
14
+ return true;
15
+ }
16
+ if (win.count >= this.limit)
17
+ return false;
18
+ win.count++;
19
+ return true;
20
+ }
21
+ }
22
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../src/auth/rate-limit.ts"],"names":[],"mappings":"AAgBA,MAAM,OAAO,sBAAsB;IAId;IACA;IAJF,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IAErD,YACmB,QAAQ,EAAE,EACV,WAAW,MAAM;QADjB,UAAK,GAAL,KAAK,CAAK;QACV,aAAQ,GAAR,QAAQ,CAAS;IACjC,CAAC;IAEJ,KAAK,CAAC,GAAW;QACf,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAElC,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;YAC/B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;YAClE,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IAAI,GAAG,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAC;QAC1C,GAAG,CAAC,KAAK,EAAE,CAAC;QACZ,OAAO,IAAI,CAAC;IACd,CAAC;CACF"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Resolved scope for a valid API key. Backends (env, DynamoDB, SSM) all produce
3
+ * this shape so the rest of the server is storage-agnostic.
4
+ */
5
+ export interface KeyValidation {
6
+ /** Stable, non-secret key identifier — safe to log and rate-limit on. */
7
+ keyId: string;
8
+ customerId: string;
9
+ /** AWS accounts the key may read. Empty = all of the customer's accounts. */
10
+ accountIds: string[];
11
+ }
12
+ /**
13
+ * Pluggable API key store. Production will back this with DynamoDB or SSM (key
14
+ * lookup → customer scope); see {@link EnvKeyStore} for the local/dev impl.
15
+ */
16
+ export interface KeyStore {
17
+ /** Return the key's scope if valid, else null. Constant-time on the secret. */
18
+ validate(presentedKey: string): Promise<KeyValidation | null>;
19
+ }
20
+ /**
21
+ * Derive a stable, non-secret identifier from a key. Logging the raw key would
22
+ * leak a credential; this hash prefix is safe to log and rate-limit on.
23
+ */
24
+ export declare function keyId(key: string): string;
25
+ /**
26
+ * Env-var-backed key store for local/dev and stdio single-customer processes.
27
+ *
28
+ * FINCTL_API_KEY active key (required)
29
+ * FINCTL_CUSTOMER_ID customer the key maps to (default "local")
30
+ * FINCTL_ACCOUNT_IDS comma-separated AWS account scope (optional)
31
+ *
32
+ * Zero-downtime rotation grace — the previous key keeps working until its expiry
33
+ * (default 5 minutes after rotation):
34
+ * FINCTL_API_KEY_PREVIOUS prior key, still accepted during grace
35
+ * FINCTL_API_KEY_PREVIOUS_EXPIRES_AT epoch ms after which the prior key is rejected
36
+ */
37
+ export declare class EnvKeyStore implements KeyStore {
38
+ private readonly current;
39
+ private readonly previous;
40
+ private readonly previousExpiresAt;
41
+ private readonly customerId;
42
+ private readonly accountIds;
43
+ constructor(env?: NodeJS.ProcessEnv);
44
+ /** True if at least one key is configured (used to fail fast at startup). */
45
+ isConfigured(): boolean;
46
+ validate(presentedKey: string): Promise<KeyValidation | null>;
47
+ }
@@ -0,0 +1,62 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * Derive a stable, non-secret identifier from a key. Logging the raw key would
4
+ * leak a credential; this hash prefix is safe to log and rate-limit on.
5
+ */
6
+ export function keyId(key) {
7
+ return "k_" + createHash("sha256").update(key).digest("hex").slice(0, 12);
8
+ }
9
+ /** Length-safe, timing-safe key comparison. */
10
+ function secretEquals(a, b) {
11
+ const ab = Buffer.from(a);
12
+ const bb = Buffer.from(b);
13
+ if (ab.length !== bb.length)
14
+ return false;
15
+ return timingSafeEqual(ab, bb);
16
+ }
17
+ /**
18
+ * Env-var-backed key store for local/dev and stdio single-customer processes.
19
+ *
20
+ * FINCTL_API_KEY active key (required)
21
+ * FINCTL_CUSTOMER_ID customer the key maps to (default "local")
22
+ * FINCTL_ACCOUNT_IDS comma-separated AWS account scope (optional)
23
+ *
24
+ * Zero-downtime rotation grace — the previous key keeps working until its expiry
25
+ * (default 5 minutes after rotation):
26
+ * FINCTL_API_KEY_PREVIOUS prior key, still accepted during grace
27
+ * FINCTL_API_KEY_PREVIOUS_EXPIRES_AT epoch ms after which the prior key is rejected
28
+ */
29
+ export class EnvKeyStore {
30
+ current;
31
+ previous;
32
+ previousExpiresAt;
33
+ customerId;
34
+ accountIds;
35
+ constructor(env = process.env) {
36
+ this.current = env.FINCTL_API_KEY?.trim() || undefined;
37
+ this.previous = env.FINCTL_API_KEY_PREVIOUS?.trim() || undefined;
38
+ this.previousExpiresAt = Number(env.FINCTL_API_KEY_PREVIOUS_EXPIRES_AT) || 0;
39
+ this.customerId = env.FINCTL_CUSTOMER_ID?.trim() || "local";
40
+ this.accountIds = (env.FINCTL_ACCOUNT_IDS ?? "")
41
+ .split(",")
42
+ .map((s) => s.trim())
43
+ .filter(Boolean);
44
+ }
45
+ /** True if at least one key is configured (used to fail fast at startup). */
46
+ isConfigured() {
47
+ return Boolean(this.current);
48
+ }
49
+ async validate(presentedKey) {
50
+ const scope = { customerId: this.customerId, accountIds: this.accountIds };
51
+ if (this.current && secretEquals(presentedKey, this.current)) {
52
+ return { keyId: keyId(this.current), ...scope };
53
+ }
54
+ if (this.previous &&
55
+ this.previousExpiresAt > Date.now() &&
56
+ secretEquals(presentedKey, this.previous)) {
57
+ return { keyId: keyId(this.previous), ...scope };
58
+ }
59
+ return null;
60
+ }
61
+ }
62
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/auth/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAuB1D;;;GAGG;AACH,MAAM,UAAU,KAAK,CAAC,GAAW;IAC/B,OAAO,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAC5E,CAAC;AAED,+CAA+C;AAC/C,SAAS,YAAY,CAAC,CAAS,EAAE,CAAS;IACxC,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,IAAI,EAAE,CAAC,MAAM,KAAK,EAAE,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC1C,OAAO,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AACjC,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,WAAW;IACL,OAAO,CAAqB;IAC5B,QAAQ,CAAqB;IAC7B,iBAAiB,CAAS;IAC1B,UAAU,CAAS;IACnB,UAAU,CAAW;IAEtC,YAAY,MAAyB,OAAO,CAAC,GAAG;QAC9C,IAAI,CAAC,OAAO,GAAG,GAAG,CAAC,cAAc,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;QACvD,IAAI,CAAC,QAAQ,GAAG,GAAG,CAAC,uBAAuB,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;QACjE,IAAI,CAAC,iBAAiB,GAAG,MAAM,CAAC,GAAG,CAAC,kCAAkC,CAAC,IAAI,CAAC,CAAC;QAC7E,IAAI,CAAC,UAAU,GAAG,GAAG,CAAC,kBAAkB,EAAE,IAAI,EAAE,IAAI,OAAO,CAAC;QAC5D,IAAI,CAAC,UAAU,GAAG,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC;aAC7C,KAAK,CAAC,GAAG,CAAC;aACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;aACpB,MAAM,CAAC,OAAO,CAAC,CAAC;IACrB,CAAC;IAED,6EAA6E;IAC7E,YAAY;QACV,OAAO,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,YAAoB;QACjC,MAAM,KAAK,GAAG,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC;QAE3E,IAAI,IAAI,CAAC,OAAO,IAAI,YAAY,CAAC,YAAY,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7D,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC;QAClD,CAAC;QACD,IACE,IAAI,CAAC,QAAQ;YACb,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC,GAAG,EAAE;YACnC,YAAY,CAAC,YAAY,EAAE,IAAI,CAAC,QAAQ,CAAC,EACzC,CAAC;YACD,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC;QACnD,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;CACF"}