@soundbi/sound-connect 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 +111 -0
- package/dist/__tests__/ingest.test.d.ts +18 -0
- package/dist/__tests__/ingest.test.d.ts.map +1 -0
- package/dist/__tests__/ingest.test.js +639 -0
- package/dist/__tests__/ingest.test.js.map +1 -0
- package/dist/__tests__/isolation.test.d.ts +12 -0
- package/dist/__tests__/isolation.test.d.ts.map +1 -0
- package/dist/__tests__/isolation.test.js +149 -0
- package/dist/__tests__/isolation.test.js.map +1 -0
- package/dist/__tests__/retry-queue.test.d.ts +11 -0
- package/dist/__tests__/retry-queue.test.d.ts.map +1 -0
- package/dist/__tests__/retry-queue.test.js +458 -0
- package/dist/__tests__/retry-queue.test.js.map +1 -0
- package/dist/auth.d.ts +80 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +211 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +66 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +253 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +573 -0
- package/dist/ingest.js.map +1 -0
- package/dist/proxy.d.ts +79 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +217 -0
- package/dist/proxy.js.map +1 -0
- package/dist/retry-queue.d.ts +236 -0
- package/dist/retry-queue.d.ts.map +1 -0
- package/dist/retry-queue.js +461 -0
- package/dist/retry-queue.js.map +1 -0
- package/dist/tools.d.ts +75 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +368 -0
- package/dist/tools.js.map +1 -0
- package/package.json +36 -0
package/dist/auth.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSAL authentication module for the Sound Connect bridge (STORY-007, ADR-003, ADR-010).
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* - Device-code flow login (public client, Sound BI tenant)
|
|
6
|
+
* - Keytar-backed OS-protected token cache (DPAPI on Windows, Keychain on macOS)
|
|
7
|
+
* - Silent token acquisition with NO interactive fallback (STORY-010, ADR-011)
|
|
8
|
+
* - Logout (clears cached token)
|
|
9
|
+
*
|
|
10
|
+
* ADR-010: MSAL token cache is stored ONLY in the OS credential store — never plaintext on disk.
|
|
11
|
+
* ADR-011: Fails closed — never silently proceeds unauthenticated.
|
|
12
|
+
*
|
|
13
|
+
* STORY-010: acquireTokenSilent() does NOT fall back to device-code flow when called from
|
|
14
|
+
* the MCP server process. The device-code prompt is invisible from a Claude-spawned stdio
|
|
15
|
+
* process — triggering it causes a silent hang. Instead, return null so tool calls can
|
|
16
|
+
* return a clear, actionable message directing the user to the separate `login` terminal
|
|
17
|
+
* command.
|
|
18
|
+
*/
|
|
19
|
+
import { PublicClientApplication, InteractionRequiredAuthError, } from '@azure/msal-node';
|
|
20
|
+
import keytar from 'keytar';
|
|
21
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
22
|
+
/** Sound BI tenant ID (ADR-003) */
|
|
23
|
+
export const TENANT_ID = '2536810f-20e1-4911-a453-4409fd96db8a';
|
|
24
|
+
/**
|
|
25
|
+
* Entra client ID for the Sound Connect bridge public client application.
|
|
26
|
+
* This must be created in the Sound BI tenant via app registration (ADR-003).
|
|
27
|
+
* Sourced from env SC_ENTRA_CLIENT_ID to allow override in tests / multiple environments.
|
|
28
|
+
* IMPORTANT: The actual clientId must be set before publishing. The default is a placeholder
|
|
29
|
+
* that will fail until replaced with a real app registration's client ID.
|
|
30
|
+
*/
|
|
31
|
+
export const CLIENT_ID = process.env['SC_ENTRA_CLIENT_ID'] ?? 'sound-connect-bridge-placeholder';
|
|
32
|
+
/** OAuth scopes requested — openid/profile/offline_access + the backend API audience */
|
|
33
|
+
export const DEFAULT_SCOPES = [
|
|
34
|
+
'openid',
|
|
35
|
+
'profile',
|
|
36
|
+
'offline_access',
|
|
37
|
+
// The backend API audience. Peers must have been granted access to this scope in the Entra app.
|
|
38
|
+
// Overridable via SC_AUTH_SCOPE for test environments.
|
|
39
|
+
process.env['SC_AUTH_SCOPE'] ?? 'api://sound-connect/.default',
|
|
40
|
+
];
|
|
41
|
+
/** Keytar service name — all entries for this bridge are grouped under this service */
|
|
42
|
+
const KEYTAR_SERVICE = 'sound-connect-bridge';
|
|
43
|
+
/** Keytar account key for the serialised MSAL cache blob */
|
|
44
|
+
const KEYTAR_ACCOUNT_CACHE = 'msal-token-cache';
|
|
45
|
+
// ── Keytar-backed MSAL cache plugin (ADR-010) ─────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Implements MSAL's ICachePlugin using the OS credential store (keytar).
|
|
48
|
+
* On Windows: DPAPI-encrypted credential store.
|
|
49
|
+
* On macOS: Keychain.
|
|
50
|
+
* On Linux: libsecret / kwallet.
|
|
51
|
+
*
|
|
52
|
+
* The cache is a JSON blob serialised by MSAL and stored as a single password entry.
|
|
53
|
+
* This means the token never touches a plaintext file (ADR-010 requirement).
|
|
54
|
+
*/
|
|
55
|
+
function buildKeytarCachePlugin() {
|
|
56
|
+
return {
|
|
57
|
+
async beforeCacheAccess(context) {
|
|
58
|
+
const serialised = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT_CACHE);
|
|
59
|
+
if (serialised) {
|
|
60
|
+
context.tokenCache.deserialize(serialised);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async afterCacheAccess(context) {
|
|
64
|
+
if (context.cacheHasChanged) {
|
|
65
|
+
const serialised = context.tokenCache.serialize();
|
|
66
|
+
await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT_CACHE, serialised);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// ── MSAL public client factory ────────────────────────────────────────────────
|
|
72
|
+
/**
|
|
73
|
+
* Constructs a MSAL PublicClientApplication bound to the Sound BI tenant.
|
|
74
|
+
* Cache is wired to keytar so tokens survive process restarts.
|
|
75
|
+
*/
|
|
76
|
+
export function buildMsalClient(cachePlugin) {
|
|
77
|
+
const config = {
|
|
78
|
+
auth: {
|
|
79
|
+
clientId: CLIENT_ID,
|
|
80
|
+
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
|
|
81
|
+
},
|
|
82
|
+
cache: {
|
|
83
|
+
cachePlugin: cachePlugin ?? buildKeytarCachePlugin(),
|
|
84
|
+
},
|
|
85
|
+
system: {
|
|
86
|
+
// Suppress MSAL's internal logger noise to stderr by default.
|
|
87
|
+
// Set SC_MSAL_LOG_LEVEL=3 for verbose MSAL tracing when debugging.
|
|
88
|
+
loggerOptions: {
|
|
89
|
+
loggerCallback: (level, message, containsPii) => {
|
|
90
|
+
if (containsPii)
|
|
91
|
+
return; // never log PII
|
|
92
|
+
const msalLogLevel = parseInt(process.env['SC_MSAL_LOG_LEVEL'] ?? '0', 10);
|
|
93
|
+
if (level <= msalLogLevel) {
|
|
94
|
+
process.stderr.write(`[msal] ${message}\n`);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
piiLoggingEnabled: false,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
return new PublicClientApplication(config);
|
|
102
|
+
}
|
|
103
|
+
// ── Login (device-code flow) — TERMINAL ONLY (STORY-010) ─────────────────────
|
|
104
|
+
/**
|
|
105
|
+
* Run the MSAL device-code flow.
|
|
106
|
+
*
|
|
107
|
+
* IMPORTANT (STORY-010, ADR-011): This function MUST be called only from the
|
|
108
|
+
* standalone `login` terminal subcommand — never from the MCP server path.
|
|
109
|
+
* stdout from a Claude-spawned stdio process is not reliably visible to the user,
|
|
110
|
+
* so a device-code prompt there would cause a silent hang.
|
|
111
|
+
*
|
|
112
|
+
* Prints the verification URL + user code to stdout in a human-readable format
|
|
113
|
+
* that is easy to copy from any terminal.
|
|
114
|
+
*
|
|
115
|
+
* On success returns the AuthenticationResult (contains access_token, account, etc.).
|
|
116
|
+
* On failure throws — caller is responsible for presenting the error.
|
|
117
|
+
*/
|
|
118
|
+
export async function login() {
|
|
119
|
+
const pca = buildMsalClient();
|
|
120
|
+
process.stdout.write('\n=== Sound Connect — Sign In ===\n\n');
|
|
121
|
+
const result = await pca.acquireTokenByDeviceCode({
|
|
122
|
+
scopes: DEFAULT_SCOPES,
|
|
123
|
+
deviceCodeCallback: (response) => {
|
|
124
|
+
process.stdout.write(`To sign in, open the following URL in a browser:\n\n`);
|
|
125
|
+
process.stdout.write(` ${response.verificationUri}\n\n`);
|
|
126
|
+
process.stdout.write(`Then enter the code: ${response.userCode}\n\n`);
|
|
127
|
+
process.stdout.write(`Waiting for authentication...\n`);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
if (!result) {
|
|
131
|
+
throw new Error('Device-code flow returned no token. Try again.');
|
|
132
|
+
}
|
|
133
|
+
const displayName = result.account?.username ?? result.account?.name ?? 'unknown';
|
|
134
|
+
process.stdout.write(`\nSigned in as: ${displayName}\n`);
|
|
135
|
+
process.stdout.write(`Token cached securely in the OS credential store.\n\n`);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
// ── Silent token acquisition (MCP server path) ────────────────────────────────
|
|
139
|
+
/**
|
|
140
|
+
* Attempt to acquire a token silently using the cached account.
|
|
141
|
+
*
|
|
142
|
+
* Returns null in any of these cases (caller must surface an actionable message):
|
|
143
|
+
* - No cached accounts (user has never logged in, or has run `logout`)
|
|
144
|
+
* - InteractionRequiredAuthError (refresh token expired / re-consent needed)
|
|
145
|
+
*
|
|
146
|
+
* STORY-010: This function deliberately does NOT fall back to device-code login.
|
|
147
|
+
* If the user needs to re-authenticate, they must run `npx @soundbi/sound-connect login`
|
|
148
|
+
* in a terminal — never from the MCP server process where stdout is invisible.
|
|
149
|
+
*
|
|
150
|
+
* On token expiry + successful silent refresh: returns a fresh AuthenticationResult.
|
|
151
|
+
* On any auth error that requires user interaction: returns null (fail closed, ADR-011).
|
|
152
|
+
*/
|
|
153
|
+
export async function acquireTokenSilent() {
|
|
154
|
+
const pca = buildMsalClient();
|
|
155
|
+
const accounts = await pca.getAllAccounts();
|
|
156
|
+
if (accounts.length === 0) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
// Use the first account (ADR-005: one client per bridge instance → one account).
|
|
160
|
+
const account = accounts[0];
|
|
161
|
+
try {
|
|
162
|
+
return await pca.acquireTokenSilent({
|
|
163
|
+
scopes: DEFAULT_SCOPES,
|
|
164
|
+
account,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
// MSAL throws InteractionRequiredAuthError when the refresh token has expired
|
|
169
|
+
// or the user needs to re-consent.
|
|
170
|
+
//
|
|
171
|
+
// STORY-010: We do NOT fall back to device-code here. Return null so the
|
|
172
|
+
// caller (MCP server) can surface the actionable "run login in a terminal" message.
|
|
173
|
+
if (err instanceof InteractionRequiredAuthError ||
|
|
174
|
+
(err instanceof Error && (err.message.includes('interaction_required') ||
|
|
175
|
+
err.message.includes('AADSTS')))) {
|
|
176
|
+
process.stderr.write('[sound-connect] Refresh token expired — run `npx @soundbi/sound-connect login` ' +
|
|
177
|
+
'in a terminal to re-authenticate.\n');
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── Logout ─────────────────────────────────────────────────────────────────────
|
|
184
|
+
/**
|
|
185
|
+
* Clears the cached MSAL token from the OS credential store.
|
|
186
|
+
*
|
|
187
|
+
* After this call, `acquireTokenSilent()` will return null.
|
|
188
|
+
*/
|
|
189
|
+
export async function logout() {
|
|
190
|
+
// Remove the raw keytar entry — this is the OS-protected blob.
|
|
191
|
+
const deleted = await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT_CACHE);
|
|
192
|
+
if (!deleted) {
|
|
193
|
+
// Not an error — already logged out.
|
|
194
|
+
process.stdout.write('No stored credentials found. Already signed out.\n');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
process.stdout.write('Signed out. Token cleared from the OS credential store.\n');
|
|
198
|
+
}
|
|
199
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* Returns true if a cached account exists (i.e. the user has logged in and
|
|
202
|
+
* the refresh token has not been explicitly deleted via logout).
|
|
203
|
+
*
|
|
204
|
+
* Does NOT validate that the token is still accepted by the backend.
|
|
205
|
+
*/
|
|
206
|
+
export async function hasStoredAccount() {
|
|
207
|
+
const pca = buildMsalClient();
|
|
208
|
+
const accounts = await pca.getAllAccounts();
|
|
209
|
+
return accounts.length > 0;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/auth.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loader for the Sound Connect bridge (STORY-006, STORY-009, ADR-002, ADR-005, ADR-008).
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (first wins):
|
|
5
|
+
* 1. CLI args: --backend-url <url> --client-slug <slug>
|
|
6
|
+
* 2. Env vars: SC_BACKEND_URL, SC_CLIENT_SLUG
|
|
7
|
+
* 3. .mcp.json `env` block (already in process.env when Claude Code launches the process)
|
|
8
|
+
*
|
|
9
|
+
* In practice, Claude Code injects the `env` block from .mcp.json into the process
|
|
10
|
+
* environment before spawning, so order 2 and 3 are the same mechanism.
|
|
11
|
+
*
|
|
12
|
+
* ADR-005 / STORY-009: The client slug is fixed at process start. There is NO tool or
|
|
13
|
+
* runtime command to switch clients in-session. A peer working N clients configures N
|
|
14
|
+
* bridge entries (one per workspace / .mcp.json entry).
|
|
15
|
+
*/
|
|
16
|
+
export interface BridgeConfig {
|
|
17
|
+
/** Base URL of the Sound Connect backend, e.g. https://my-client-forge.azurecontainerapps.io */
|
|
18
|
+
backendUrl: string;
|
|
19
|
+
/**
|
|
20
|
+
* Bound client slug for this bridge instance, e.g. "beacon" (ADR-005: one client per instance).
|
|
21
|
+
* Must be 3–50 chars, lowercase alphanumeric and hyphens only, no leading/trailing hyphens.
|
|
22
|
+
* Fixed at process start — cannot be changed in-session.
|
|
23
|
+
*/
|
|
24
|
+
clientSlug: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Slug validation rule — mirrors the server-side constraint in registry.ts.
|
|
28
|
+
* 3–50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphens.
|
|
29
|
+
*
|
|
30
|
+
* STORY-009 AC2: if the bound slug is missing or does not match this pattern,
|
|
31
|
+
* the bridge refuses to start with a clear error.
|
|
32
|
+
*/
|
|
33
|
+
export declare const SLUG_PATTERN: RegExp;
|
|
34
|
+
export declare function loadConfig(argv?: string[]): BridgeConfig;
|
|
35
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,YAAY;IAC3B,gGAAgG;IAChG,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,QAAsC,CAAC;AAchE,wBAAgB,UAAU,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,YAAY,CA2C/E"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loader for the Sound Connect bridge (STORY-006, STORY-009, ADR-002, ADR-005, ADR-008).
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (first wins):
|
|
5
|
+
* 1. CLI args: --backend-url <url> --client-slug <slug>
|
|
6
|
+
* 2. Env vars: SC_BACKEND_URL, SC_CLIENT_SLUG
|
|
7
|
+
* 3. .mcp.json `env` block (already in process.env when Claude Code launches the process)
|
|
8
|
+
*
|
|
9
|
+
* In practice, Claude Code injects the `env` block from .mcp.json into the process
|
|
10
|
+
* environment before spawning, so order 2 and 3 are the same mechanism.
|
|
11
|
+
*
|
|
12
|
+
* ADR-005 / STORY-009: The client slug is fixed at process start. There is NO tool or
|
|
13
|
+
* runtime command to switch clients in-session. A peer working N clients configures N
|
|
14
|
+
* bridge entries (one per workspace / .mcp.json entry).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Slug validation rule — mirrors the server-side constraint in registry.ts.
|
|
18
|
+
* 3–50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphens.
|
|
19
|
+
*
|
|
20
|
+
* STORY-009 AC2: if the bound slug is missing or does not match this pattern,
|
|
21
|
+
* the bridge refuses to start with a clear error.
|
|
22
|
+
*/
|
|
23
|
+
export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/;
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const result = {};
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
if (argv[i] === '--backend-url' && argv[i + 1]) {
|
|
28
|
+
result.backendUrl = argv[++i];
|
|
29
|
+
}
|
|
30
|
+
else if (argv[i] === '--client-slug' && argv[i + 1]) {
|
|
31
|
+
result.clientSlug = argv[++i];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
export function loadConfig(argv = process.argv.slice(2)) {
|
|
37
|
+
const fromArgs = parseArgs(argv);
|
|
38
|
+
const backendUrl = fromArgs.backendUrl ??
|
|
39
|
+
process.env['SC_BACKEND_URL'] ??
|
|
40
|
+
'';
|
|
41
|
+
const clientSlug = fromArgs.clientSlug ??
|
|
42
|
+
process.env['SC_CLIENT_SLUG'] ??
|
|
43
|
+
'';
|
|
44
|
+
if (!backendUrl) {
|
|
45
|
+
throw new Error('SC_BACKEND_URL is required. Set it via --backend-url <url>, ' +
|
|
46
|
+
'the SC_BACKEND_URL env var, or the env block in .mcp.json / claude_desktop_config.json.');
|
|
47
|
+
}
|
|
48
|
+
if (!clientSlug) {
|
|
49
|
+
throw new Error('SC_CLIENT_SLUG is required. Set it via --client-slug <slug>, ' +
|
|
50
|
+
'the SC_CLIENT_SLUG env var, or the env block in .mcp.json / claude_desktop_config.json.');
|
|
51
|
+
}
|
|
52
|
+
// STORY-009 AC2: Validate slug format. Mirrors the server-side constraint so a
|
|
53
|
+
// malformed slug is caught locally with a clear message rather than getting a
|
|
54
|
+
// cryptic 404/403 from the backend later.
|
|
55
|
+
if (!SLUG_PATTERN.test(clientSlug)) {
|
|
56
|
+
throw new Error(`SC_CLIENT_SLUG "${clientSlug}" is invalid. ` +
|
|
57
|
+
'Must be 3–50 chars, lowercase alphanumeric and hyphens only, no leading/trailing hyphens. ' +
|
|
58
|
+
'Example: "beacon" or "eye-south".');
|
|
59
|
+
}
|
|
60
|
+
// Normalise: strip trailing slash so every URL join is consistent.
|
|
61
|
+
return {
|
|
62
|
+
backendUrl: backendUrl.replace(/\/+$/, ''),
|
|
63
|
+
clientSlug,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAaH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,mCAAmC,CAAC;AAEhE,SAAS,SAAS,CAAC,IAAc;IAC/B,MAAM,MAAM,GAA0B,EAAE,CAAC;IACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,eAAe,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YAC/C,MAAM,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;QAChC,CAAC;aAAM,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,eAAe,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YACtD,MAAM,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;QAChC,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,OAAiB,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAC/D,MAAM,QAAQ,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IAEjC,MAAM,UAAU,GACd,QAAQ,CAAC,UAAU;QACnB,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;QAC7B,EAAE,CAAC;IAEL,MAAM,UAAU,GACd,QAAQ,CAAC,UAAU;QACnB,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;QAC7B,EAAE,CAAC;IAEL,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CACb,8DAA8D;YAC9D,yFAAyF,CAC1F,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CACb,+DAA+D;YAC/D,yFAAyF,CAC1F,CAAC;IACJ,CAAC;IAED,+EAA+E;IAC/E,8EAA8E;IAC9E,0CAA0C;IAC1C,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CACb,mBAAmB,UAAU,gBAAgB;YAC7C,4FAA4F;YAC5F,mCAAmC,CACpC,CAAC;IACJ,CAAC;IAED,mEAAmE;IACnE,OAAO;QACL,UAAU,EAAE,UAAU,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QAC1C,UAAU;KACX,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sound Connect Bridge — entry point (STORY-006, STORY-007, STORY-010, ADR-002, ADR-008).
|
|
4
|
+
*
|
|
5
|
+
* Routes to:
|
|
6
|
+
* - `login` subcommand → device-code Entra login (STORY-007, ADR-003)
|
|
7
|
+
* - `logout` subcommand → clear OS credential store (STORY-007, ADR-010)
|
|
8
|
+
* - (default) → stdio MCP server (STORY-006)
|
|
9
|
+
*
|
|
10
|
+
* STORY-010 / ADR-011: The `login` subcommand is intentionally SEPARATE from the MCP
|
|
11
|
+
* server path. Claude spawns the MCP server with stdout used for the MCP framing
|
|
12
|
+
* protocol — a device-code prompt written to stdout there is invisible, causing a
|
|
13
|
+
* silent hang. Run `npx @soundbi/sound-connect login` from a terminal BEFORE starting
|
|
14
|
+
* Claude. The MCP server detects the missing token and returns a clear, actionable
|
|
15
|
+
* error on every tool call instead of blocking.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* npx @soundbi/sound-connect login # sign in once (terminal only)
|
|
19
|
+
* npx @soundbi/sound-connect logout # clear stored token
|
|
20
|
+
* SC_BACKEND_URL=https://... SC_CLIENT_SLUG=beacon npx @soundbi/sound-connect
|
|
21
|
+
*/
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;GAmBG"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sound Connect Bridge — entry point (STORY-006, STORY-007, STORY-010, ADR-002, ADR-008).
|
|
4
|
+
*
|
|
5
|
+
* Routes to:
|
|
6
|
+
* - `login` subcommand → device-code Entra login (STORY-007, ADR-003)
|
|
7
|
+
* - `logout` subcommand → clear OS credential store (STORY-007, ADR-010)
|
|
8
|
+
* - (default) → stdio MCP server (STORY-006)
|
|
9
|
+
*
|
|
10
|
+
* STORY-010 / ADR-011: The `login` subcommand is intentionally SEPARATE from the MCP
|
|
11
|
+
* server path. Claude spawns the MCP server with stdout used for the MCP framing
|
|
12
|
+
* protocol — a device-code prompt written to stdout there is invisible, causing a
|
|
13
|
+
* silent hang. Run `npx @soundbi/sound-connect login` from a terminal BEFORE starting
|
|
14
|
+
* Claude. The MCP server detects the missing token and returns a clear, actionable
|
|
15
|
+
* error on every tool call instead of blocking.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* npx @soundbi/sound-connect login # sign in once (terminal only)
|
|
19
|
+
* npx @soundbi/sound-connect logout # clear stored token
|
|
20
|
+
* SC_BACKEND_URL=https://... SC_CLIENT_SLUG=beacon npx @soundbi/sound-connect
|
|
21
|
+
*/
|
|
22
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
24
|
+
import { loadConfig } from './config.js';
|
|
25
|
+
import { registerBridgeTools } from './tools.js';
|
|
26
|
+
import { login, logout, acquireTokenSilent } from './auth.js';
|
|
27
|
+
// ── Subcommand dispatch ────────────────────────────────────────────────────────
|
|
28
|
+
const subcommand = process.argv[2];
|
|
29
|
+
if (subcommand === 'login') {
|
|
30
|
+
// Device-code login — interactive CLI flow, not the MCP server.
|
|
31
|
+
// STORY-010: This is the ONLY place login() may be called.
|
|
32
|
+
login()
|
|
33
|
+
.then(() => process.exit(0))
|
|
34
|
+
.catch((err) => {
|
|
35
|
+
process.stderr.write(`[sound-connect] Login failed: ${err.message}\n`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else if (subcommand === 'logout') {
|
|
40
|
+
// Clear OS credential store.
|
|
41
|
+
logout()
|
|
42
|
+
.then(() => process.exit(0))
|
|
43
|
+
.catch((err) => {
|
|
44
|
+
process.stderr.write(`[sound-connect] Logout failed: ${err.message}\n`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// Default: start the stdio MCP server.
|
|
50
|
+
startMcpServer().catch((err) => {
|
|
51
|
+
process.stderr.write(`[sound-connect] Fatal: ${err.message}\n`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// ── MCP server (STORY-006, STORY-010) ─────────────────────────────────────────
|
|
56
|
+
async function startMcpServer() {
|
|
57
|
+
// Config errors go to stderr (not stdout — that belongs to the MCP protocol).
|
|
58
|
+
let config;
|
|
59
|
+
try {
|
|
60
|
+
config = loadConfig();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
process.stderr.write(`[sound-connect] Config error: ${err.message}\n`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
// STORY-010 / ADR-011: Check for a valid cached token at startup.
|
|
67
|
+
// acquireTokenSilent() never triggers device-code flow — it returns null if the
|
|
68
|
+
// user is not signed in or the refresh token has expired. Tool calls will surface
|
|
69
|
+
// an actionable message rather than hanging on an invisible prompt.
|
|
70
|
+
let isAuthenticated = false;
|
|
71
|
+
try {
|
|
72
|
+
const tokenResult = await acquireTokenSilent();
|
|
73
|
+
isAuthenticated = tokenResult !== null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Any unexpected error during token check → treat as unauthenticated (fail closed).
|
|
77
|
+
process.stderr.write('[sound-connect] Token check failed — proceeding unauthenticated.\n');
|
|
78
|
+
}
|
|
79
|
+
if (!isAuthenticated) {
|
|
80
|
+
process.stderr.write('[sound-connect] Not signed in. Run `npx @soundbi/sound-connect login` in a ' +
|
|
81
|
+
'terminal to authenticate, then restart Claude.\n');
|
|
82
|
+
}
|
|
83
|
+
const server = new McpServer({
|
|
84
|
+
name: `sound-connect [${config.clientSlug}]`,
|
|
85
|
+
version: '0.1.0',
|
|
86
|
+
});
|
|
87
|
+
registerBridgeTools(server, config, isAuthenticated);
|
|
88
|
+
const transport = new StdioServerTransport();
|
|
89
|
+
// Log startup info to stderr only — stdout is the MCP framing channel.
|
|
90
|
+
process.stderr.write(`[sound-connect] Starting bridge for client "${config.clientSlug}" ` +
|
|
91
|
+
`→ ${config.backendUrl} (authenticated: ${isAuthenticated})\n`);
|
|
92
|
+
await server.connect(transport);
|
|
93
|
+
// Keep process alive until stdin closes (the MCP client disconnects).
|
|
94
|
+
// StdioServerTransport reads from stdin; when it closes, the process can exit.
|
|
95
|
+
process.stdin.on('close', () => {
|
|
96
|
+
process.stderr.write('[sound-connect] stdin closed, shutting down.\n');
|
|
97
|
+
process.exit(0);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|