@habitusnet/bc365 2.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [2.0.0] — Unreleased
6
+
7
+ ### Added
8
+ - `bc365 onboard` CLI: auto-discovers tenant, environments, companies via Entra ID device code flow, writes `.mcp.json`
9
+ - `bc365 switch <profile>` for multi-tenant profile management
10
+ - `bc365 profiles` command to list saved profiles
11
+ - `bc365 check` command to check latest npm versions of bc365 packages
12
+ - Entra ID multi-tenant app (`bc365 CLI`) with device code flow authentication
13
+ - Token caching via OS keychain (`keytar`)
14
+ - Vendored mirror repos with daily upstream-watch and security scanning:
15
+ - `habitusnet/d365bc-admin-mcp`
16
+ - `habitusnet/mcp-business-central`
17
+ - npm package published as `@habitusnet/bc365`
18
+ - CI: Jest tests, npm audit, CodeQL on every PR
19
+
20
+ ### Changed
21
+ - Package name changed to `@habitusnet/bc365` for scoped npm distribution
22
+ - Minimum Node.js version: 20
23
+
24
+ ---
25
+
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # bc365 — MCP Config Manager for Business Central
2
+
3
+ `@habitusnet/bc365` is a CLI that auto-discovers your Microsoft Dynamics 365 Business Central environments and writes `.mcp.json` for use with Claude / MCP-compatible AI tools.
4
+
5
+ ## v2 — Smart Onboarding
6
+
7
+ Instead of manually editing `.mcp.json`, use the CLI:
8
+
9
+ ```bash
10
+ npx @habitusnet/bc365 onboard
11
+ ```
12
+
13
+ Sign in with your Microsoft account. The CLI discovers your tenant, environments, and companies automatically, checks your BC permissions, and writes `.mcp.json`.
14
+
15
+ **Prerequisites:**
16
+ - Microsoft 365 / Azure AD account with access to Business Central
17
+ - Business Central environment with `D365 BUS FULL ACCESS` permission set
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install -g @habitusnet/bc365
25
+ ```
26
+
27
+ Or use without installing:
28
+
29
+ ```bash
30
+ npx @habitusnet/bc365 onboard
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ | Command | Description |
36
+ |---|---|
37
+ | `bc365 onboard` | Auto-discover tenant, environments, companies; write `.mcp.json` |
38
+ | `bc365 profiles` | List saved profiles |
39
+ | `bc365 switch <profile>` | Switch to a saved profile |
40
+ | `bc365 check` | Check latest npm versions of bc365 packages |
41
+
42
+ ## Multi-Tenant Usage
43
+
44
+ For agencies managing multiple clients, see [SETUP.md](SETUP.md#multi-tenant-usage-agencies).
45
+
46
+ ## License
47
+
48
+ MIT
package/bin/bc365.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { program } from '../lib/cli.js';
3
+ program.parse();
package/lib/auth.js ADDED
@@ -0,0 +1,52 @@
1
+ import { PublicClientApplication } from '@azure/msal-node';
2
+ import keytar from 'keytar';
3
+
4
+ const SERVICE = 'bc365';
5
+ const ACCOUNT = 'token';
6
+ const CLIENT_ID = process.env.BC365_CLIENT_ID ?? 'e9dd228a-569c-4ab2-8e0a-cda6b01555d9';
7
+ const AUTHORITY = 'https://login.microsoftonline.com/organizations';
8
+ const SCOPES = [
9
+ 'https://api.businesscentral.dynamics.com/user_impersonation',
10
+ 'User.Read',
11
+ ];
12
+
13
+ // Lazy PCA creation so tests can control the mock per-call via
14
+ // PublicClientApplication.mockImplementation() before invoking getToken().
15
+ function createPca() {
16
+ return new PublicClientApplication({
17
+ auth: { clientId: CLIENT_ID, authority: AUTHORITY },
18
+ });
19
+ }
20
+
21
+ export async function getToken() {
22
+ const cached = await keytar.getPassword(SERVICE, ACCOUNT);
23
+ if (cached) {
24
+ try {
25
+ const parsed = JSON.parse(cached);
26
+ if (parsed.expiresOn > Date.now() + 60_000) return parsed;
27
+ } catch {
28
+ // Corrupt cache — fall through to re-authenticate
29
+ }
30
+ }
31
+
32
+ const pca = createPca();
33
+ const response = await pca.acquireTokenByDeviceCode({
34
+ scopes: SCOPES,
35
+ deviceCodeCallback: (info) => {
36
+ console.log(info.message);
37
+ },
38
+ });
39
+
40
+ const token = {
41
+ accessToken: response.accessToken,
42
+ expiresOn: response.expiresOn instanceof Date
43
+ ? response.expiresOn.getTime()
44
+ : response.expiresOn,
45
+ };
46
+ await keytar.setPassword(SERVICE, ACCOUNT, JSON.stringify(token));
47
+ return token;
48
+ }
49
+
50
+ export async function clearToken() {
51
+ await keytar.deletePassword(SERVICE, ACCOUNT);
52
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,77 @@
1
+ import { createRequire } from 'node:module';
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import { onboard } from './onboard.js';
5
+ import { listProfiles, loadProfile } from './profiles.js';
6
+ import { checkVersions } from './versions.js';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const { version } = require('../package.json');
10
+
11
+ export const program = new Command();
12
+
13
+ program
14
+ .name('bc365')
15
+ .description('Business Central MCP onboarding and management CLI')
16
+ .version(version);
17
+
18
+ program
19
+ .command('onboard')
20
+ .description('Auto-discover tenant/environment/company and write .mcp.json')
21
+ .option('-t, --tenant-id <id>', 'Azure AD tenant ID (skip Graph lookup)')
22
+ .option('-o, --output <path>', 'Output path for .mcp.json', '.mcp.json')
23
+ .option('-p, --profile <name>', 'Profile name to save (default: tenantId/envName)')
24
+ .action(async (opts) => {
25
+ try {
26
+ await onboard({ tenantId: opts.tenantId, output: opts.output, profileName: opts.profile });
27
+ } catch (err) {
28
+ console.error(chalk.red(`✗ ${err.message}`));
29
+ process.exit(1);
30
+ }
31
+ });
32
+
33
+ program
34
+ .command('switch <profile>')
35
+ .description('Switch active profile (writes .mcp.json from saved profile)')
36
+ .option('-o, --output <path>', 'Output path', '.mcp.json')
37
+ .action(async (profileName, opts) => {
38
+ try {
39
+ const profile = await loadProfile(profileName);
40
+ if (!profile) {
41
+ console.error(chalk.red(`✗ Profile '${profileName}' not found. Run 'bc365 onboard' first.`));
42
+ process.exit(1);
43
+ }
44
+ const { writeFile } = await import('node:fs/promises');
45
+ const { buildMcpConfig } = await import('./onboard.js');
46
+ const config = buildMcpConfig(profile);
47
+ await writeFile(opts.output, JSON.stringify(config, null, 2), 'utf8');
48
+ console.log(chalk.green(`✓ Switched to profile '${profileName}'`));
49
+ } catch (err) {
50
+ console.error(chalk.red(`✗ ${err.message}`));
51
+ process.exit(1);
52
+ }
53
+ });
54
+
55
+ program
56
+ .command('profiles')
57
+ .description('List saved tenant profiles')
58
+ .action(async () => {
59
+ const profiles = await listProfiles();
60
+ if (profiles.length === 0) {
61
+ console.log('No profiles saved yet. Run bc365 onboard to create one.');
62
+ return;
63
+ }
64
+ profiles.forEach((p) => console.log(` ${p.name} (${p.tenantId})`));
65
+ });
66
+
67
+ program
68
+ .command('check')
69
+ .description('Check latest versions of bc365 packages from npm registry')
70
+ .action(async () => {
71
+ console.log('Checking npm registry...');
72
+ const results = await checkVersions();
73
+ results.forEach((r) => {
74
+ const status = r.latest ? chalk.green(r.latest) : chalk.yellow('unknown');
75
+ console.log(` ${r.package} latest: ${status}`);
76
+ });
77
+ });
@@ -0,0 +1,38 @@
1
+ const BC_ADMIN_BASE = 'https://api.businesscentral.dynamics.com/admin/v2.21';
2
+ const BC_API_BASE = 'https://api.businesscentral.dynamics.com/v2.0';
3
+ const REQUIRED_PERMISSIONS = ['D365 BUS FULL ACCESS'];
4
+
5
+ async function bcFetch(url, token) {
6
+ const res = await fetch(url, {
7
+ headers: { Authorization: `Bearer ${token.accessToken}` },
8
+ });
9
+ if (!res.ok) throw new Error(`BC API error ${res.status}: ${url}`);
10
+ return res.json();
11
+ }
12
+
13
+ export async function getEnvironments(token, opts = {}) {
14
+ const { tenantId, type } = opts;
15
+ const url = tenantId
16
+ ? `${BC_ADMIN_BASE}/applications/BusinessCentral/environments?aadTenantId=${tenantId}`
17
+ : `${BC_ADMIN_BASE}/applications/BusinessCentral/environments`;
18
+ const data = await bcFetch(url, token);
19
+ const envs = data.value ?? [];
20
+ return type ? envs.filter((e) => e.type === type) : envs;
21
+ }
22
+
23
+ export async function getCompanies(env, token) {
24
+ const url = `${BC_API_BASE}/${encodeURIComponent(env.aadTenantId)}/${encodeURIComponent(env.name)}/api/v2.0/companies`;
25
+ const data = await bcFetch(url, token);
26
+ return data.value ?? [];
27
+ }
28
+
29
+ export async function getPermissions(env, token, opts = {}) {
30
+ const { companyId } = opts;
31
+ const url = `${BC_API_BASE}/${encodeURIComponent(env.aadTenantId)}/${encodeURIComponent(env.name)}/api/v2.0/companies(${encodeURIComponent(companyId)})/userPermissions`;
32
+ const data = await bcFetch(url, token);
33
+ const grantedRoles = (data.value ?? []).map((p) => p.roleId);
34
+ return {
35
+ present: REQUIRED_PERMISSIONS.filter((r) => grantedRoles.includes(r)),
36
+ missing: REQUIRED_PERMISSIONS.filter((r) => !grantedRoles.includes(r)),
37
+ };
38
+ }
package/lib/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { onboard } from './onboard.js';
2
+ export { getToken } from './auth.js';
package/lib/onboard.js ADDED
@@ -0,0 +1,74 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import inquirer from 'inquirer';
3
+ import { getToken } from './auth.js';
4
+ import { getEnvironments, getCompanies, getPermissions } from './discovery.js';
5
+ import { saveProfile } from './profiles.js';
6
+
7
+ const BC_API_BASE = 'https://api.businesscentral.dynamics.com/v2.0';
8
+
9
+ export function buildMcpConfig(ctx) {
10
+ const { tenantId, envName, companyId } = ctx;
11
+ return {
12
+ mcpServers: {
13
+ 'bc-admin': {
14
+ type: 'stdio',
15
+ command: 'd365bc-admin-mcp',
16
+ env: { BC_TENANT_ID: tenantId },
17
+ },
18
+ 'bc-data': {
19
+ type: 'stdio',
20
+ command: 'npx',
21
+ args: ['-y', '@habitusnet/mcp-business-central'],
22
+ env: {
23
+ BC_URL_SERVER: `${BC_API_BASE}/${tenantId}/${envName}/api/v2.0`,
24
+ BC_COMPANY: companyId,
25
+ BC_AUTH_TYPE: 'azure_cli',
26
+ },
27
+ },
28
+ },
29
+ };
30
+ }
31
+
32
+ export async function onboard(options = {}) {
33
+ const { tenantId, output = '.mcp.json', profileName } = options;
34
+ const token = await getToken();
35
+
36
+ const environments = await getEnvironments(token, { tenantId, type: 'Production' });
37
+ if (environments.length === 0) throw new Error('No Production environments found.');
38
+
39
+ const { envName } = environments.length === 1
40
+ ? { envName: environments[0].name }
41
+ : await inquirer.prompt([{
42
+ type: 'list',
43
+ name: 'envName',
44
+ message: 'Select environment:',
45
+ choices: environments.map((e) => e.name),
46
+ }]);
47
+
48
+ const selectedEnv = environments.find((e) => e.name === envName);
49
+ const companies = await getCompanies(selectedEnv, token);
50
+ if (companies.length === 0) throw new Error('No companies found in this environment.');
51
+
52
+ const { companyId } = companies.length === 1
53
+ ? { companyId: companies[0].id }
54
+ : await inquirer.prompt([{
55
+ type: 'list',
56
+ name: 'companyId',
57
+ message: 'Select company:',
58
+ choices: companies.map((c) => ({ name: c.name, value: c.id })),
59
+ }]);
60
+
61
+ const perms = await getPermissions(selectedEnv, token, { companyId });
62
+ if (perms.missing.length > 0) {
63
+ console.warn(`⚠️ Missing permissions: ${perms.missing.join(', ')}`);
64
+ }
65
+
66
+ const ctx = { tenantId: selectedEnv.aadTenantId, envName, companyId };
67
+ const config = buildMcpConfig(ctx);
68
+ await writeFile(output, JSON.stringify(config, null, 2), 'utf8');
69
+ console.log(`✓ Wrote ${output}`);
70
+
71
+ const name = profileName ?? `${selectedEnv.aadTenantId}/${envName}`;
72
+ await saveProfile(name, ctx);
73
+ console.log(`✓ Saved profile '${name}'`);
74
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // PROFILE_PATH exported for external reference (computed at import time using real homedir)
6
+ export const PROFILE_PATH = join(homedir(), '.bc365', 'profiles.json');
7
+
8
+ // Lazy path helpers — always call homedir() at runtime so mocks work in tests
9
+ function getProfileDir() {
10
+ return join(homedir(), '.bc365');
11
+ }
12
+
13
+ function getProfilePath() {
14
+ return join(homedir(), '.bc365', 'profiles.json');
15
+ }
16
+
17
+ async function readProfiles() {
18
+ try {
19
+ const data = await readFile(getProfilePath(), 'utf8');
20
+ return JSON.parse(data);
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ async function writeProfiles(profiles) {
27
+ const dir = getProfileDir();
28
+ await mkdir(dir, { recursive: true });
29
+ await writeFile(getProfilePath(), JSON.stringify(profiles, null, 2), 'utf8');
30
+ }
31
+
32
+ export async function saveProfile(name, config) {
33
+ const profiles = await readProfiles();
34
+ profiles[name] = { ...config, savedAt: new Date().toISOString() };
35
+ await writeProfiles(profiles);
36
+ }
37
+
38
+ export async function listProfiles() {
39
+ const profiles = await readProfiles();
40
+ return Object.entries(profiles).map(([name, cfg]) => ({ name, ...cfg }));
41
+ }
42
+
43
+ export async function loadProfile(name) {
44
+ const profiles = await readProfiles();
45
+ return profiles[name] ?? null;
46
+ }
@@ -0,0 +1,27 @@
1
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
2
+
3
+ const PACKAGES = [
4
+ '@habitusnet/bc365',
5
+ '@habitusnet/d365bc-admin-mcp',
6
+ '@habitusnet/mcp-business-central',
7
+ ];
8
+
9
+ export async function getLatestVersion(pkg) {
10
+ try {
11
+ const res = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(pkg)}`);
12
+ if (!res.ok) return null;
13
+ const data = await res.json();
14
+ return data['dist-tags']?.latest ?? null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export async function checkVersions() {
21
+ return Promise.all(
22
+ PACKAGES.map(async (pkg) => ({
23
+ package: pkg,
24
+ latest: await getLatestVersion(pkg),
25
+ }))
26
+ );
27
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@habitusnet/bc365",
3
+ "version": "2.0.0",
4
+ "description": "Smart onboarding CLI and MCP config manager for Business Central",
5
+ "type": "module",
6
+ "bin": {
7
+ "bc365": "./bin/bc365.js"
8
+ },
9
+ "exports": {
10
+ ".": "./lib/index.js"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "lib/",
15
+ "skills/",
16
+ "commands/",
17
+ "README.md",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/habitusnet/mcp-d365-BC"
23
+ },
24
+ "engines": { "node": ">=20" },
25
+ "scripts": {
26
+ "test": "node --experimental-vm-modules node_modules/.bin/jest --coverage",
27
+ "lint": "eslint lib/ bin/"
28
+ },
29
+ "dependencies": {
30
+ "@azure/msal-node": "^2.15.0",
31
+ "@microsoft/microsoft-graph-client": "^3.0.7",
32
+ "chalk": "^5.3.0",
33
+ "commander": "^12.1.0",
34
+ "inquirer": "^10.1.5",
35
+ "keytar": "^7.9.0"
36
+ },
37
+ "devDependencies": {
38
+ "jest": "^29.7.0"
39
+ }
40
+ }