@infrawise/mcp-server 0.1.0 → 0.1.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/README.md +33 -16
- package/dist/auth.d.ts +17 -2
- package/dist/auth.js +138 -18
- package/dist/client.d.ts +21 -1
- package/dist/client.js +101 -4
- package/dist/index.js +184 -10
- package/dist/tools/general-recommendations.d.ts +6 -1
- package/dist/tools/general-recommendations.js +2 -2
- package/dist/tools/idle-resources.d.ts +6 -1
- package/dist/tools/idle-resources.js +2 -2
- package/dist/tools/sku-optimizations.d.ts +6 -1
- package/dist/tools/sku-optimizations.js +2 -2
- package/dist/tools/summary.js +5 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,27 +13,30 @@ Tools exposed to Claude Code:
|
|
|
13
13
|
|
|
14
14
|
All tools support optional `subscription_filter` (subscription UUID).
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## 2-step quickstart
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
# 1) Authenticate
|
|
20
|
-
npx @infrawise/mcp-server
|
|
21
|
-
```
|
|
19
|
+
# 1) Authenticate + register + validate in one command
|
|
20
|
+
npx @infrawise/mcp-server setup
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
```json
|
|
26
|
-
{
|
|
27
|
-
"mcpServers": {
|
|
28
|
-
"infrawise": {
|
|
29
|
-
"command": "npx",
|
|
30
|
-
"args": ["@infrawise/mcp-server"]
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
22
|
+
# 2) Restart Claude Code
|
|
34
23
|
```
|
|
35
24
|
|
|
36
|
-
|
|
25
|
+
> **Note:** Use `claude mcp add` — do not manually add `mcpServers` to `~/.claude/settings.json`, that key is not valid there.
|
|
26
|
+
|
|
27
|
+
## Helpful commands
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Authenticate only (supports --tenant)
|
|
31
|
+
npx @infrawise/mcp-server auth
|
|
32
|
+
npx @infrawise/mcp-server auth --tenant <tenantId>
|
|
33
|
+
|
|
34
|
+
# Setup with explicit tenant
|
|
35
|
+
npx @infrawise/mcp-server setup --tenant <tenantId>
|
|
36
|
+
|
|
37
|
+
# Diagnose auth and onboarding readiness with fix steps
|
|
38
|
+
npx @infrawise/mcp-server doctor
|
|
39
|
+
```
|
|
37
40
|
|
|
38
41
|
## Local development
|
|
39
42
|
|
|
@@ -44,12 +47,26 @@ npm run build
|
|
|
44
47
|
node dist/index.js auth
|
|
45
48
|
```
|
|
46
49
|
|
|
50
|
+
## Deploying package updates (npm publish)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cd packages/claude-mcp
|
|
54
|
+
npm publish --access public
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Notes:
|
|
58
|
+
|
|
59
|
+
- `prepublishOnly` automatically runs `npm run clean && npm run build`
|
|
60
|
+
- If npm prompts for browser sign-in or passkey/2FA approval, complete that flow and rerun publish if needed
|
|
61
|
+
- Verify deployment with: `npm view @infrawise/mcp-server version`
|
|
62
|
+
|
|
47
63
|
## Environment variables
|
|
48
64
|
|
|
49
65
|
- `INFRAWISE_API_BASE` (default: `https://api.infrawiseai.com/api`)
|
|
50
66
|
- `INFRAWISE_AZURE_CLIENT_ID` (default: Infrawise API app ID)
|
|
51
67
|
- `INFRAWISE_AZURE_API_SCOPE` (default: `api://<clientId>/delegated_access`)
|
|
52
68
|
- `INFRAWISE_AZURE_AUTHORITY` (default: `https://login.microsoftonline.com/common`)
|
|
69
|
+
- `INFRAWISE_AZURE_TENANT_ID` (optional: your Azure AD tenant ID; when set, authority is built automatically)
|
|
53
70
|
|
|
54
71
|
## Security
|
|
55
72
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -5,6 +5,21 @@ export interface AuthSettings {
|
|
|
5
5
|
authority: string;
|
|
6
6
|
clientId: string;
|
|
7
7
|
scope: string;
|
|
8
|
+
tenantId?: string;
|
|
8
9
|
}
|
|
9
|
-
export
|
|
10
|
-
|
|
10
|
+
export interface AuthCommandOptions {
|
|
11
|
+
tenantId?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TokenCacheDiagnostics {
|
|
14
|
+
credentialPath: string;
|
|
15
|
+
cacheExists: boolean;
|
|
16
|
+
cacheUpdatedAt?: string;
|
|
17
|
+
cacheAuthority?: string;
|
|
18
|
+
resolvedAuthority: string;
|
|
19
|
+
resolvedTenantId?: string;
|
|
20
|
+
clientId: string;
|
|
21
|
+
scope: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function runDeviceCodeAuth(options?: AuthCommandOptions): Promise<void>;
|
|
24
|
+
export declare function getTokenCacheDiagnostics(options?: AuthCommandOptions): Promise<TokenCacheDiagnostics>;
|
|
25
|
+
export declare function getAccessToken(options?: AuthCommandOptions): Promise<string>;
|
package/dist/auth.js
CHANGED
|
@@ -3,23 +3,52 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
const DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common";
|
|
6
|
+
const ORGANIZATIONS_AUTHORITY = "https://login.microsoftonline.com/organizations";
|
|
7
|
+
const DEVICE_CODE_AUTH_TIMEOUT_MS = 120_000;
|
|
6
8
|
const DEFAULT_API_CLIENT_ID = "06dc6d06-11f1-4543-bb2c-9c91b263df56";
|
|
7
9
|
const DEFAULT_SCOPE = `api://${DEFAULT_API_CLIENT_ID}/delegated_access`;
|
|
8
10
|
const CREDENTIALS_DIR = ".infrawise";
|
|
9
11
|
const CREDENTIALS_FILE = "mcp-credentials.json";
|
|
10
12
|
export class NotAuthenticatedError extends Error {
|
|
11
|
-
constructor(message = "Not authenticated. Run: npx @infrawise/mcp-server auth") {
|
|
13
|
+
constructor(message = "Not authenticated with Azure AD. Run: npx @infrawise/mcp-server auth") {
|
|
12
14
|
super(message);
|
|
13
15
|
this.name = "NotAuthenticatedError";
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
class AuthTimeoutError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "AuthTimeoutError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function normalizeTenantId(raw) {
|
|
25
|
+
const tenantId = raw?.trim();
|
|
26
|
+
return tenantId ? tenantId : undefined;
|
|
27
|
+
}
|
|
28
|
+
function buildTenantAuthority(tenantId) {
|
|
29
|
+
return `https://login.microsoftonline.com/${tenantId}`;
|
|
30
|
+
}
|
|
31
|
+
function getConfiguredAuthority() {
|
|
32
|
+
const value = process.env.INFRAWISE_AZURE_AUTHORITY?.trim();
|
|
33
|
+
return value ? value : undefined;
|
|
34
|
+
}
|
|
35
|
+
function resolveTenantOverride(options) {
|
|
36
|
+
return normalizeTenantId(options?.tenantId) ?? normalizeTenantId(process.env.INFRAWISE_AZURE_TENANT_ID);
|
|
37
|
+
}
|
|
38
|
+
function getAuthSettings(payload, options, authorityOverride) {
|
|
39
|
+
const clientId = process.env.INFRAWISE_AZURE_CLIENT_ID ?? payload?.clientId ?? DEFAULT_API_CLIENT_ID;
|
|
40
|
+
const scope = process.env.INFRAWISE_AZURE_API_SCOPE ?? payload?.scope ?? `api://${clientId}/delegated_access`;
|
|
41
|
+
const tenantId = resolveTenantOverride(options);
|
|
42
|
+
const authority = authorityOverride ??
|
|
43
|
+
(tenantId ? buildTenantAuthority(tenantId) : undefined) ??
|
|
44
|
+
getConfiguredAuthority() ??
|
|
45
|
+
payload?.authority ??
|
|
46
|
+
DEFAULT_AUTHORITY;
|
|
19
47
|
return {
|
|
20
|
-
authority
|
|
48
|
+
authority,
|
|
21
49
|
clientId,
|
|
22
50
|
scope,
|
|
51
|
+
tenantId,
|
|
23
52
|
};
|
|
24
53
|
}
|
|
25
54
|
function getCredentialFilePath() {
|
|
@@ -58,8 +87,8 @@ async function writeCachePayload(payload) {
|
|
|
58
87
|
// Best-effort on Windows.
|
|
59
88
|
}
|
|
60
89
|
}
|
|
61
|
-
async function createClientFromSettings(settings) {
|
|
62
|
-
const payload = await readCachePayload();
|
|
90
|
+
async function createClientFromSettings(settings, existingPayload) {
|
|
91
|
+
const payload = existingPayload ?? (await readCachePayload());
|
|
63
92
|
const client = new PublicClientApplication({
|
|
64
93
|
auth: {
|
|
65
94
|
clientId: settings.clientId,
|
|
@@ -92,9 +121,34 @@ async function persistClientCache(client, settings, account) {
|
|
|
92
121
|
updatedAt: new Date().toISOString(),
|
|
93
122
|
});
|
|
94
123
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
124
|
+
function toErrorMessage(error) {
|
|
125
|
+
return error instanceof Error ? error.message : String(error);
|
|
126
|
+
}
|
|
127
|
+
function getAuthorityAttempts(options) {
|
|
128
|
+
const tenantId = resolveTenantOverride(options);
|
|
129
|
+
if (tenantId) {
|
|
130
|
+
return [{ authority: buildTenantAuthority(tenantId), label: `tenant ${tenantId}` }];
|
|
131
|
+
}
|
|
132
|
+
const configuredAuthority = getConfiguredAuthority();
|
|
133
|
+
if (configuredAuthority) {
|
|
134
|
+
return [{ authority: configuredAuthority, label: "configured authority" }];
|
|
135
|
+
}
|
|
136
|
+
return [
|
|
137
|
+
{ authority: DEFAULT_AUTHORITY, label: "common" },
|
|
138
|
+
{ authority: ORGANIZATIONS_AUTHORITY, label: "organizations" },
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
function readTenantIdFromResult(result) {
|
|
142
|
+
const claims = result.idTokenClaims;
|
|
143
|
+
return typeof claims?.tid === "string" ? claims.tid : undefined;
|
|
144
|
+
}
|
|
145
|
+
function assertTenantCompatibility(result, settings) {
|
|
146
|
+
const signedInTenantId = readTenantIdFromResult(result);
|
|
147
|
+
if (settings.tenantId && signedInTenantId && settings.tenantId.toLowerCase() !== signedInTenantId.toLowerCase()) {
|
|
148
|
+
throw new Error(`Account/tenant mismatch detected. You requested tenant ${settings.tenantId}, but authenticated into tenant ${signedInTenantId}. Re-run with the correct tenant: npx @infrawise/mcp-server auth --tenant <tenantId>`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function acquireTokenByDeviceCodeWithTimeout(client, settings) {
|
|
98
152
|
const request = {
|
|
99
153
|
scopes: [settings.scope, "openid", "profile"],
|
|
100
154
|
deviceCodeCallback: (response) => {
|
|
@@ -103,16 +157,82 @@ export async function runDeviceCodeAuth() {
|
|
|
103
157
|
console.error(`[Infrawise MCP] ${msg}`);
|
|
104
158
|
},
|
|
105
159
|
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
160
|
+
let timeout;
|
|
161
|
+
try {
|
|
162
|
+
const authPromise = client.acquireTokenByDeviceCode(request);
|
|
163
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
164
|
+
timeout = setTimeout(() => {
|
|
165
|
+
reject(new AuthTimeoutError("Authentication did not complete within 120 seconds. Complete sign-in quickly after the device code is shown, or rerun with a specific tenant: npx @infrawise/mcp-server auth --tenant <tenantId>"));
|
|
166
|
+
}, DEVICE_CODE_AUTH_TIMEOUT_MS);
|
|
167
|
+
});
|
|
168
|
+
const result = await Promise.race([authPromise, timeoutPromise]);
|
|
169
|
+
if (!result) {
|
|
170
|
+
throw new Error("Device Code authentication did not return a result.");
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
if (timeout) {
|
|
176
|
+
clearTimeout(timeout);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export async function runDeviceCodeAuth(options) {
|
|
181
|
+
const payload = await readCachePayload();
|
|
182
|
+
const attempts = getAuthorityAttempts(options);
|
|
183
|
+
const errors = [];
|
|
184
|
+
for (const attempt of attempts) {
|
|
185
|
+
const settings = getAuthSettings(payload, options, attempt.authority);
|
|
186
|
+
const { client } = await createClientFromSettings(settings, payload);
|
|
187
|
+
try {
|
|
188
|
+
if (attempts.length > 1) {
|
|
189
|
+
console.error(`[Infrawise MCP] Authenticating with ${attempt.label} authority...`);
|
|
190
|
+
}
|
|
191
|
+
const result = await acquireTokenByDeviceCodeWithTimeout(client, settings);
|
|
192
|
+
if (!result.account) {
|
|
193
|
+
throw new Error("Device Code authentication did not return an account.");
|
|
194
|
+
}
|
|
195
|
+
assertTenantCompatibility(result, settings);
|
|
196
|
+
await persistClientCache(client, settings, result.account);
|
|
197
|
+
console.error(`[Infrawise MCP] Authentication complete. Token cache saved to ${getCredentialFilePath()}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
errors.push(`${attempt.label}: ${toErrorMessage(error)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const tenantHint = !resolveTenantOverride(options) && !getConfiguredAuthority()
|
|
205
|
+
? " Hint: retry with your tenant ID: npx @infrawise/mcp-server auth --tenant <tenantId>."
|
|
206
|
+
: "";
|
|
207
|
+
throw new Error(`Authentication failed after trying available authorities (${errors.join(" | ")}).${tenantHint}`);
|
|
208
|
+
}
|
|
209
|
+
export async function getTokenCacheDiagnostics(options) {
|
|
210
|
+
const payload = await readCachePayload();
|
|
211
|
+
const settings = getAuthSettings(payload, options);
|
|
212
|
+
const credentialPath = getCredentialFilePath();
|
|
213
|
+
let cacheExists = false;
|
|
214
|
+
try {
|
|
215
|
+
await fs.access(credentialPath);
|
|
216
|
+
cacheExists = true;
|
|
109
217
|
}
|
|
110
|
-
|
|
111
|
-
|
|
218
|
+
catch {
|
|
219
|
+
cacheExists = false;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
credentialPath,
|
|
223
|
+
cacheExists,
|
|
224
|
+
cacheUpdatedAt: payload?.updatedAt,
|
|
225
|
+
cacheAuthority: payload?.authority,
|
|
226
|
+
resolvedAuthority: settings.authority,
|
|
227
|
+
resolvedTenantId: settings.tenantId,
|
|
228
|
+
clientId: settings.clientId,
|
|
229
|
+
scope: settings.scope,
|
|
230
|
+
};
|
|
112
231
|
}
|
|
113
|
-
export async function getAccessToken() {
|
|
114
|
-
const
|
|
115
|
-
const
|
|
232
|
+
export async function getAccessToken(options) {
|
|
233
|
+
const payload = await readCachePayload();
|
|
234
|
+
const settings = getAuthSettings(payload, options);
|
|
235
|
+
const { client } = await createClientFromSettings(settings, payload);
|
|
116
236
|
const account = await getPreferredAccount(client, payload?.homeAccountId);
|
|
117
237
|
if (!account) {
|
|
118
238
|
throw new NotAuthenticatedError();
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
|
+
type OnboardingState = "ACTIVE" | "PENDING_DELEGATION" | "EXPIRED" | "SUSPENDED" | "UNKNOWN";
|
|
2
|
+
export interface TenantReadinessEndpointResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
status?: number;
|
|
5
|
+
code?: InfrawiseErrorCode;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TenantReadinessReport {
|
|
9
|
+
tenantValidation: TenantReadinessEndpointResult;
|
|
10
|
+
onboardingStatus: TenantReadinessEndpointResult & {
|
|
11
|
+
states?: OnboardingState[];
|
|
12
|
+
};
|
|
13
|
+
}
|
|
1
14
|
export type InfrawiseErrorCode = "NOT_AUTHENTICATED" | "NOT_ONBOARDED" | "PENDING_DELEGATION" | "UPSTREAM_ERROR";
|
|
15
|
+
export interface InfrawiseRequestOptions {
|
|
16
|
+
accessToken?: string;
|
|
17
|
+
skipTenantCheck?: boolean;
|
|
18
|
+
}
|
|
2
19
|
export declare class InfrawiseClientError extends Error {
|
|
3
20
|
code: InfrawiseErrorCode;
|
|
4
21
|
status?: number;
|
|
5
22
|
constructor(code: InfrawiseErrorCode, message: string, status?: number);
|
|
6
23
|
}
|
|
7
|
-
export declare function
|
|
24
|
+
export declare function ensureTenantReady(): Promise<string>;
|
|
25
|
+
export declare function getTenantReadinessReport(token: string): Promise<TenantReadinessReport>;
|
|
26
|
+
export declare function getInfrawiseData<T>(path: string, options?: InfrawiseRequestOptions): Promise<T>;
|
|
27
|
+
export {};
|
package/dist/client.js
CHANGED
|
@@ -32,7 +32,7 @@ async function fetchJson(path, token) {
|
|
|
32
32
|
signal: controller.signal,
|
|
33
33
|
});
|
|
34
34
|
if (response.status === 401 || response.status === 403) {
|
|
35
|
-
throw new InfrawiseClientError("NOT_AUTHENTICATED", "
|
|
35
|
+
throw new InfrawiseClientError("NOT_AUTHENTICATED", "Azure AD token rejected by Infrawise API. Re-authenticate with: npx @infrawise/mcp-server auth", response.status);
|
|
36
36
|
}
|
|
37
37
|
if (!response.ok) {
|
|
38
38
|
const body = await response.text();
|
|
@@ -66,6 +66,20 @@ function normalizeOnboardingState(status) {
|
|
|
66
66
|
}
|
|
67
67
|
return "UNKNOWN";
|
|
68
68
|
}
|
|
69
|
+
function toEndpointResult(error, fallbackMessage) {
|
|
70
|
+
if (error instanceof InfrawiseClientError) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
status: error.status,
|
|
74
|
+
code: error.code,
|
|
75
|
+
message: error.message,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: fallbackMessage,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
69
83
|
async function assertTenantReady(token) {
|
|
70
84
|
const tenantValidation = await fetchJson("/auth/validate-tenant", token);
|
|
71
85
|
if (!tenantValidation.data?.authorized) {
|
|
@@ -85,10 +99,9 @@ async function assertTenantReady(token) {
|
|
|
85
99
|
}
|
|
86
100
|
throw new InfrawiseClientError("NOT_ONBOARDED", "No active Infrawise subscription found. Complete onboarding at https://infrawiseai.com/onboard");
|
|
87
101
|
}
|
|
88
|
-
|
|
89
|
-
let token;
|
|
102
|
+
async function resolveAccessToken() {
|
|
90
103
|
try {
|
|
91
|
-
|
|
104
|
+
return await getAccessToken();
|
|
92
105
|
}
|
|
93
106
|
catch (error) {
|
|
94
107
|
if (error instanceof NotAuthenticatedError) {
|
|
@@ -96,7 +109,91 @@ export async function getInfrawiseData(path) {
|
|
|
96
109
|
}
|
|
97
110
|
throw error;
|
|
98
111
|
}
|
|
112
|
+
}
|
|
113
|
+
export async function ensureTenantReady() {
|
|
114
|
+
const token = await resolveAccessToken();
|
|
99
115
|
await assertTenantReady(token);
|
|
116
|
+
return token;
|
|
117
|
+
}
|
|
118
|
+
export async function getTenantReadinessReport(token) {
|
|
119
|
+
let tenantValidation;
|
|
120
|
+
try {
|
|
121
|
+
const validation = await fetchJson("/auth/validate-tenant", token);
|
|
122
|
+
if (validation.data?.authorized) {
|
|
123
|
+
tenantValidation = {
|
|
124
|
+
ok: true,
|
|
125
|
+
status: validation.status,
|
|
126
|
+
message: "Tenant is authorized.",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
tenantValidation = {
|
|
131
|
+
ok: false,
|
|
132
|
+
status: validation.status,
|
|
133
|
+
code: "NOT_ONBOARDED",
|
|
134
|
+
message: validation.data?.message ?? "Tenant is not authorized in Infrawise.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
tenantValidation = toEndpointResult(error, "Unable to validate tenant authorization.");
|
|
140
|
+
}
|
|
141
|
+
let onboardingStatus;
|
|
142
|
+
try {
|
|
143
|
+
const onboarding = await fetchJson("/onboarding/status", token);
|
|
144
|
+
if (!Array.isArray(onboarding.data) || onboarding.data.length === 0) {
|
|
145
|
+
onboardingStatus = {
|
|
146
|
+
ok: true,
|
|
147
|
+
status: onboarding.status,
|
|
148
|
+
message: "No onboarding records found (legacy allowlisted tenant).",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const states = onboarding.data.map((entry) => normalizeOnboardingState(entry.status));
|
|
153
|
+
if (states.includes("ACTIVE")) {
|
|
154
|
+
onboardingStatus = {
|
|
155
|
+
ok: true,
|
|
156
|
+
status: onboarding.status,
|
|
157
|
+
states,
|
|
158
|
+
message: "At least one onboarding record is ACTIVE.",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
else if (states.includes("PENDING_DELEGATION")) {
|
|
162
|
+
onboardingStatus = {
|
|
163
|
+
ok: false,
|
|
164
|
+
status: onboarding.status,
|
|
165
|
+
code: "PENDING_DELEGATION",
|
|
166
|
+
states,
|
|
167
|
+
message: "Onboarding is pending Lighthouse delegation.",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
onboardingStatus = {
|
|
172
|
+
ok: false,
|
|
173
|
+
status: onboarding.status,
|
|
174
|
+
code: "NOT_ONBOARDED",
|
|
175
|
+
states,
|
|
176
|
+
message: "No ACTIVE onboarding records found.",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
onboardingStatus = {
|
|
183
|
+
...toEndpointResult(error, "Unable to read onboarding status."),
|
|
184
|
+
states: undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
tenantValidation,
|
|
189
|
+
onboardingStatus,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export async function getInfrawiseData(path, options = {}) {
|
|
193
|
+
const token = options.accessToken ?? (await resolveAccessToken());
|
|
194
|
+
if (!options.skipTenantCheck) {
|
|
195
|
+
await assertTenantReady(token);
|
|
196
|
+
}
|
|
100
197
|
const response = await fetchJson(path, token);
|
|
101
198
|
return response.data;
|
|
102
199
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
5
|
import { z } from "zod";
|
|
5
|
-
import { runDeviceCodeAuth } from "./auth.js";
|
|
6
|
-
import { InfrawiseClientError } from "./client.js";
|
|
6
|
+
import { getAccessToken, getTokenCacheDiagnostics, runDeviceCodeAuth } from "./auth.js";
|
|
7
|
+
import { getTenantReadinessReport, InfrawiseClientError } from "./client.js";
|
|
7
8
|
import { getGeneralRecommendations } from "./tools/general-recommendations.js";
|
|
8
9
|
import { getIdleResources } from "./tools/idle-resources.js";
|
|
9
10
|
import { getSavingsSummary } from "./tools/summary.js";
|
|
@@ -47,15 +48,173 @@ function toToolError(error) {
|
|
|
47
48
|
],
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
function parseCliOptions(argv) {
|
|
52
|
+
const firstArg = argv[2];
|
|
53
|
+
const mode = firstArg === "auth" || firstArg === "setup" || firstArg === "doctor" ? firstArg : "server";
|
|
54
|
+
const optionStart = mode === "server" ? 2 : 3;
|
|
55
|
+
let tenantId;
|
|
56
|
+
for (let i = optionStart; i < argv.length; i += 1) {
|
|
57
|
+
const arg = argv[i];
|
|
58
|
+
if (arg === "--tenant") {
|
|
59
|
+
const next = argv[i + 1];
|
|
60
|
+
if (!next || next.startsWith("--")) {
|
|
61
|
+
throw new Error("Missing value for --tenant. Usage: --tenant <tenantId>");
|
|
62
|
+
}
|
|
63
|
+
tenantId = next;
|
|
64
|
+
i += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg.startsWith("--tenant=")) {
|
|
68
|
+
tenantId = arg.slice("--tenant=".length);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
72
|
+
}
|
|
73
|
+
return { mode, tenantId };
|
|
74
|
+
}
|
|
75
|
+
function commandWithTenant(base, tenantId) {
|
|
76
|
+
return tenantId ? `${base} --tenant ${tenantId}` : base;
|
|
77
|
+
}
|
|
78
|
+
function getClaudeCommand() {
|
|
79
|
+
return process.platform === "win32" ? "claude.cmd" : "claude";
|
|
80
|
+
}
|
|
81
|
+
async function runCommand(command, args) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const child = spawn(command, args, {
|
|
84
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
85
|
+
windowsHide: true,
|
|
86
|
+
});
|
|
87
|
+
let stdout = "";
|
|
88
|
+
let stderr = "";
|
|
89
|
+
child.stdout.on("data", (chunk) => {
|
|
90
|
+
stdout += String(chunk);
|
|
91
|
+
});
|
|
92
|
+
child.stderr.on("data", (chunk) => {
|
|
93
|
+
stderr += String(chunk);
|
|
94
|
+
});
|
|
95
|
+
child.on("error", (error) => {
|
|
96
|
+
if (error.code === "ENOENT") {
|
|
97
|
+
reject(new Error("Claude Code CLI is not installed or not on PATH. Install Claude Code, then retry setup."));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
reject(error);
|
|
101
|
+
});
|
|
102
|
+
child.on("close", (code) => {
|
|
103
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function isInfrawiseRegistered(listOutput) {
|
|
108
|
+
return /\binfrawise\b/i.test(listOutput);
|
|
109
|
+
}
|
|
110
|
+
async function listMcpRegistrations() {
|
|
111
|
+
const result = await runCommand(getClaudeCommand(), ["mcp", "list"]);
|
|
112
|
+
if (result.code !== 0) {
|
|
113
|
+
throw new Error(`Failed to list MCP servers: ${result.stderr || result.stdout || "unknown error"}`);
|
|
114
|
+
}
|
|
115
|
+
return result.stdout;
|
|
116
|
+
}
|
|
117
|
+
async function addMcpRegistration() {
|
|
118
|
+
const args = ["mcp", "add", "infrawise", "--", "npx", "@infrawise/mcp-server"];
|
|
119
|
+
const result = await runCommand(getClaudeCommand(), args);
|
|
120
|
+
if (result.code !== 0) {
|
|
121
|
+
throw new Error(`Failed to add MCP server: ${result.stderr || result.stdout || "unknown error"}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function runAuthMode(options) {
|
|
125
|
+
await runDeviceCodeAuth({ tenantId: options.tenantId });
|
|
126
|
+
}
|
|
127
|
+
async function runSetupMode(options) {
|
|
128
|
+
await runDeviceCodeAuth({ tenantId: options.tenantId });
|
|
129
|
+
const before = await listMcpRegistrations();
|
|
130
|
+
if (!isInfrawiseRegistered(before)) {
|
|
131
|
+
await addMcpRegistration();
|
|
132
|
+
}
|
|
133
|
+
const after = await listMcpRegistrations();
|
|
134
|
+
if (!isInfrawiseRegistered(after)) {
|
|
135
|
+
throw new Error("Setup could not verify the Infrawise MCP registration. Run: claude mcp add infrawise -- npx @infrawise/mcp-server");
|
|
136
|
+
}
|
|
137
|
+
console.error("[Infrawise MCP] READY: Authenticated and registered with Claude Code. Restart Claude Code to load the server.");
|
|
138
|
+
}
|
|
139
|
+
function printCheck(ok, label, detail) {
|
|
140
|
+
const status = ok ? "PASS" : "FAIL";
|
|
141
|
+
console.error(`[Infrawise MCP Doctor] ${status} - ${label}: ${detail}`);
|
|
142
|
+
}
|
|
143
|
+
function addFix(fixes, fix) {
|
|
144
|
+
if (!fixes.includes(fix)) {
|
|
145
|
+
fixes.push(fix);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function runDoctorMode(options) {
|
|
149
|
+
const cache = await getTokenCacheDiagnostics({ tenantId: options.tenantId });
|
|
150
|
+
const fixes = [];
|
|
151
|
+
const authCommand = commandWithTenant("npx @infrawise/mcp-server auth", options.tenantId);
|
|
152
|
+
printCheck(cache.cacheExists, "Token cache", cache.cacheExists ? `Found at ${cache.credentialPath}` : `Missing at ${cache.credentialPath}`);
|
|
153
|
+
if (!cache.cacheExists) {
|
|
154
|
+
addFix(fixes, `Authenticate: ${authCommand}`);
|
|
155
|
+
}
|
|
156
|
+
const authorityMatchesCache = !cache.cacheAuthority || cache.cacheAuthority === cache.resolvedAuthority;
|
|
157
|
+
printCheck(authorityMatchesCache, "Authority", `Resolved=${cache.resolvedAuthority}${cache.cacheAuthority ? `, cached=${cache.cacheAuthority}` : ""}`);
|
|
158
|
+
if (!authorityMatchesCache) {
|
|
159
|
+
addFix(fixes, `Re-authenticate to refresh authority metadata: ${authCommand}`);
|
|
160
|
+
}
|
|
161
|
+
let token;
|
|
51
162
|
try {
|
|
52
|
-
await
|
|
163
|
+
token = await getAccessToken({ tenantId: options.tenantId });
|
|
164
|
+
printCheck(true, "Silent token acquisition", "Access token acquired successfully.");
|
|
53
165
|
}
|
|
54
166
|
catch (error) {
|
|
55
167
|
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
-
|
|
168
|
+
printCheck(false, "Silent token acquisition", message);
|
|
169
|
+
addFix(fixes, `Re-authenticate: ${authCommand}`);
|
|
170
|
+
}
|
|
171
|
+
if (!token) {
|
|
172
|
+
console.error("[Infrawise MCP Doctor] Not ready.");
|
|
173
|
+
if (fixes.length > 0) {
|
|
174
|
+
console.error("[Infrawise MCP Doctor] Fix steps:");
|
|
175
|
+
fixes.forEach((fix, index) => {
|
|
176
|
+
console.error(`${index + 1}. ${fix}`);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
57
179
|
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const readiness = await getTenantReadinessReport(token);
|
|
183
|
+
printCheck(readiness.tenantValidation.ok, "Tenant validation (/auth/validate-tenant)", readiness.tenantValidation.message);
|
|
184
|
+
if (!readiness.tenantValidation.ok) {
|
|
185
|
+
if (readiness.tenantValidation.code === "NOT_AUTHENTICATED") {
|
|
186
|
+
addFix(fixes, `Re-authenticate: ${authCommand}`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
addFix(fixes, "Complete onboarding: https://infrawiseai.com/onboard");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const onboardingDetail = readiness.onboardingStatus.states?.length
|
|
193
|
+
? `${readiness.onboardingStatus.message} States=${readiness.onboardingStatus.states.join(",")}`
|
|
194
|
+
: readiness.onboardingStatus.message;
|
|
195
|
+
printCheck(readiness.onboardingStatus.ok, "Onboarding status (/onboarding/status)", onboardingDetail);
|
|
196
|
+
if (!readiness.onboardingStatus.ok) {
|
|
197
|
+
if (readiness.onboardingStatus.code === "PENDING_DELEGATION") {
|
|
198
|
+
addFix(fixes, "Finish Lighthouse delegation in onboarding: https://infrawiseai.com/onboard");
|
|
199
|
+
}
|
|
200
|
+
else if (readiness.onboardingStatus.code === "NOT_AUTHENTICATED") {
|
|
201
|
+
addFix(fixes, `Re-authenticate: ${authCommand}`);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
addFix(fixes, "Ensure at least one ACTIVE Infrawise onboarding record: https://infrawiseai.com/onboard");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const ready = fixes.length === 0;
|
|
208
|
+
if (ready) {
|
|
209
|
+
console.error("[Infrawise MCP Doctor] READY: All checks passed.");
|
|
210
|
+
return;
|
|
58
211
|
}
|
|
212
|
+
console.error("[Infrawise MCP Doctor] Not ready.");
|
|
213
|
+
console.error("[Infrawise MCP Doctor] Fix steps:");
|
|
214
|
+
fixes.forEach((fix, index) => {
|
|
215
|
+
console.error(`${index + 1}. ${fix}`);
|
|
216
|
+
});
|
|
217
|
+
process.exitCode = 1;
|
|
59
218
|
}
|
|
60
219
|
async function runServerMode() {
|
|
61
220
|
const server = new McpServer({
|
|
@@ -109,11 +268,26 @@ async function runServerMode() {
|
|
|
109
268
|
await server.connect(transport);
|
|
110
269
|
}
|
|
111
270
|
async function main() {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
271
|
+
try {
|
|
272
|
+
const options = parseCliOptions(process.argv);
|
|
273
|
+
if (options.mode === "auth") {
|
|
274
|
+
await runAuthMode(options);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (options.mode === "setup") {
|
|
278
|
+
await runSetupMode(options);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (options.mode === "doctor") {
|
|
282
|
+
await runDoctorMode(options);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await runServerMode();
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
289
|
+
console.error(`[Infrawise MCP] ${message}`);
|
|
290
|
+
process.exitCode = 1;
|
|
116
291
|
}
|
|
117
|
-
await runServerMode();
|
|
118
292
|
}
|
|
119
293
|
await main();
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import { type GeneralRecommendation } from "./types.js";
|
|
2
|
-
|
|
2
|
+
interface ToolRequestOptions {
|
|
3
|
+
accessToken?: string;
|
|
4
|
+
skipTenantCheck?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function getGeneralRecommendations(subscriptionFilter?: string, options?: ToolRequestOptions): Promise<GeneralRecommendation[]>;
|
|
7
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getInfrawiseData } from "../client.js";
|
|
2
2
|
import { filterBySubscription } from "./types.js";
|
|
3
|
-
export async function getGeneralRecommendations(subscriptionFilter) {
|
|
4
|
-
const data = await getInfrawiseData("/general-recommendations");
|
|
3
|
+
export async function getGeneralRecommendations(subscriptionFilter, options = {}) {
|
|
4
|
+
const data = await getInfrawiseData("/general-recommendations", options);
|
|
5
5
|
const rows = Array.isArray(data) ? data : [];
|
|
6
6
|
return filterBySubscription(rows, subscriptionFilter);
|
|
7
7
|
}
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import { type IdleResource } from "./types.js";
|
|
2
|
-
|
|
2
|
+
interface ToolRequestOptions {
|
|
3
|
+
accessToken?: string;
|
|
4
|
+
skipTenantCheck?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function getIdleResources(subscriptionFilter?: string, options?: ToolRequestOptions): Promise<IdleResource[]>;
|
|
7
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getInfrawiseData } from "../client.js";
|
|
2
2
|
import { filterBySubscription } from "./types.js";
|
|
3
|
-
export async function getIdleResources(subscriptionFilter) {
|
|
4
|
-
const data = await getInfrawiseData("/idle-resources");
|
|
3
|
+
export async function getIdleResources(subscriptionFilter, options = {}) {
|
|
4
|
+
const data = await getInfrawiseData("/idle-resources", options);
|
|
5
5
|
const rows = Array.isArray(data) ? data : [];
|
|
6
6
|
return filterBySubscription(rows, subscriptionFilter);
|
|
7
7
|
}
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import { type SkuOptimization } from "./types.js";
|
|
2
|
-
|
|
2
|
+
interface ToolRequestOptions {
|
|
3
|
+
accessToken?: string;
|
|
4
|
+
skipTenantCheck?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function getSkuOptimizations(subscriptionFilter?: string, options?: ToolRequestOptions): Promise<SkuOptimization[]>;
|
|
7
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getInfrawiseData } from "../client.js";
|
|
2
2
|
import { filterBySubscription } from "./types.js";
|
|
3
|
-
export async function getSkuOptimizations(subscriptionFilter) {
|
|
4
|
-
const data = await getInfrawiseData("/sku-optimizations");
|
|
3
|
+
export async function getSkuOptimizations(subscriptionFilter, options = {}) {
|
|
4
|
+
const data = await getInfrawiseData("/sku-optimizations", options);
|
|
5
5
|
const rows = Array.isArray(data) ? data : [];
|
|
6
6
|
return filterBySubscription(rows, subscriptionFilter);
|
|
7
7
|
}
|
package/dist/tools/summary.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureTenantReady } from "../client.js";
|
|
1
2
|
import { getGeneralRecommendations } from "./general-recommendations.js";
|
|
2
3
|
import { getIdleResources } from "./idle-resources.js";
|
|
3
4
|
import { getSkuOptimizations } from "./sku-optimizations.js";
|
|
@@ -6,10 +7,11 @@ function roundCurrency(value) {
|
|
|
6
7
|
return Math.round(value * 100) / 100;
|
|
7
8
|
}
|
|
8
9
|
export async function getSavingsSummary(subscriptionFilter) {
|
|
10
|
+
const accessToken = await ensureTenantReady();
|
|
9
11
|
const [idleResources, skuOptimizations, generalRecommendations] = await Promise.all([
|
|
10
|
-
getIdleResources(subscriptionFilter),
|
|
11
|
-
getSkuOptimizations(subscriptionFilter),
|
|
12
|
-
getGeneralRecommendations(subscriptionFilter),
|
|
12
|
+
getIdleResources(subscriptionFilter, { accessToken, skipTenantCheck: true }),
|
|
13
|
+
getSkuOptimizations(subscriptionFilter, { accessToken, skipTenantCheck: true }),
|
|
14
|
+
getGeneralRecommendations(subscriptionFilter, { accessToken, skipTenantCheck: true }),
|
|
13
15
|
]);
|
|
14
16
|
const idleMonthly = idleResources.reduce((sum, item) => sum + toNumber(item.monthlyCost), 0);
|
|
15
17
|
const idleAnnual = idleResources.reduce((sum, item) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@infrawise/mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Infrawise MCP server for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/
|
|
17
|
+
"url": "git+https://github.com/Infrawise/infrawise.git",
|
|
18
18
|
"directory": "packages/claude-mcp"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|