Stop the npm wrapper's platform-package auto-install from writing to the caller's package.json (#3184)

This commit is contained in:
Dan Dombrowski authored and GitHub committed 2026-09-18 20:23:05 +00:00
1 parent 97d7c2c041
commit a51a2262a2
7 files changed
+723 -36

No files matched your search

+9
View File
@@ -23,6 +23,15 @@ Process exited with code: 0
This is useful if you want to see where the package is installed and how it's resolving the platform specific dependency.
# Platform package auto-install
The wrapper runs the server from a platform-specific package (for example `@azure/mcp-linux-x64`) that npm installs as an optional dependency. npm skips it in some cases, most commonly when the running Node version does not satisfy the package's `engines` field. With npm, the wrapper's install script then usually fails the install with a "Missing required package" error. Installs that skip install scripts (pnpm and bun by default, or npm with `--ignore-scripts`) complete without it. When the wrapper can't find it at startup, it runs `npm install --no-save` itself, into the first writable one of:
1. A `.platform` directory inside the wrapper package.
2. A per-user cache directory: `~/Library/Caches/microsoft-mcp/<package>/<version>` on macOS, `%LOCALAPPDATA%\microsoft-mcp\<package>\<version>` on Windows, and `$XDG_CACHE_HOME/microsoft-mcp/<package>/<version>` (default `~/.cache`) elsewhere.
It never installs into the current working directory. Later runs reuse the install, and with `DEBUG=true` the wrapper logs which directory it used. Delete that directory to force a fresh install.
# NPX Debugging
To debug javascript invoked during the npx call, you can start the run with the node option `--inspect-brk` enabled:
+426
View File
@@ -0,0 +1,426 @@
// Tests for eng/npm/wrapper/index.js. Run with:
// node --test eng/npm/test/wrapper.test.js
//
// These live outside eng/npm/wrapper because Pack-Npm.ps1 copies that whole
// directory into the published package.
//
// The wrapper does all of its work when it loads and then exits, so each test
// runs a copy of it as a child process. A stub `npm` first on PATH records how
// it was called and "installs" a fake platform package, so nothing touches the
// network or the real npm cache.
const { test } = require('node:test')
const assert = require('node:assert')
const { spawn, spawnSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const wrapperSource = path.join(__dirname, '..', 'wrapper', 'index.js')
const packageName = '@test/mcp'
const packageVersion = '1.2.3'
const platformPackageName = `${packageName}-${os.platform()}-${os.arch()}`
// Stands in for eng/npm/platform/index.js: instead of starting the server
// binary, it writes its arguments to stdout so tests can check that stdout
// carries nothing else.
const platformIndexSource = `exports.runExecutable = async (args) => {
process.stdout.write('ran ' + JSON.stringify(args))
return 0
}
`
function writePlatformPackage(dir) {
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
name: platformPackageName,
version: packageVersion,
main: './index.js'
}))
fs.writeFileSync(path.join(dir, 'index.js'), platformIndexSource)
}
const npmStubSource = `const fs = require('fs')
const path = require('path')
const args = process.argv.slice(2)
fs.appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify({ args, cwd: process.cwd() }) + '\\n')
// Real npm prints progress and summaries to stdout; the wrapper must keep it
// off its own stdout.
process.stdout.write('added 1 package in 1s\\n')
const failOnceMarker = process.env.FAKE_NPM_FAIL_ONCE
if (process.env.FAKE_NPM_FAIL || (failOnceMarker && !fs.existsSync(failOnceMarker))) {
if (failOnceMarker) {
fs.writeFileSync(failOnceMarker, '')
}
process.stderr.write('npm error network request failed\\n')
process.exit(1)
}
const spec = args[1]
const name = spec.slice(0, spec.lastIndexOf('@'))
const prefix = path.resolve(args[args.indexOf('--prefix') + 1])
const dir = path.join(prefix, 'node_modules', name)
// Like npm's reify, move the existing package out of the way before writing
// the new one. A slow install widens that window, which is what concurrent
// installs into one prefix trip over.
fs.rmSync(dir, { recursive: true, force: true })
const delayMs = Number(process.env.FAKE_NPM_DELAY_MS || 0)
if (delayMs) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs)
}
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, main: './index.js' }))
fs.writeFileSync(path.join(dir, 'index.js'), ${JSON.stringify(platformIndexSource)})
`
// Lays out a caller project and an installed copy of the wrapper under a fresh
// temp root:
//
// <root>/project/package.json cwd the wrapper is spawned from
// <root>/node_modules/@test/mcp/ the wrapper, as npm would install it
// <root>/bin/npm the npm stub
// <root>/home/ HOME and per-user cache dirs
function createFixture(t) {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-wrapper-test-')))
const project = path.join(root, 'project')
const projectManifest = JSON.stringify({ name: 'project', version: '1.0.0', dependencies: {} }, null, 2)
fs.mkdirSync(project)
fs.writeFileSync(path.join(project, 'package.json'), projectManifest)
const wrapperDir = path.join(root, 'node_modules', ...packageName.split('/'))
const home = path.join(root, 'home')
fs.mkdirSync(wrapperDir, { recursive: true })
fs.mkdirSync(home)
t.after(() => {
// Some tests make these read-only.
fs.chmodSync(wrapperDir, 0o755)
fs.chmodSync(home, 0o755)
fs.rmSync(root, { recursive: true, force: true })
})
fs.copyFileSync(wrapperSource, path.join(wrapperDir, 'index.js'))
fs.writeFileSync(path.join(wrapperDir, 'package.json'), JSON.stringify({
name: packageName,
version: packageVersion,
engines: { node: '>=22.0.0' }
}))
const bin = path.join(root, 'bin')
fs.mkdirSync(bin)
fs.writeFileSync(path.join(bin, 'npm.js'), npmStubSource)
if (process.platform === 'win32') {
fs.writeFileSync(path.join(bin, 'npm.cmd'), `@"${process.execPath}" "%~dp0npm.js" %*\r\n`)
} else {
fs.writeFileSync(path.join(bin, 'npm'), `#!/bin/sh\nexec "${process.execPath}" "${path.join(bin, 'npm.js')}" "$@"\n`, { mode: 0o755 })
}
const env = { ...process.env }
const pathKey = Object.keys(env).find(key => key.toUpperCase() === 'PATH') || 'PATH'
env[pathKey] = bin + path.delimiter + env[pathKey]
env.HOME = home
env.USERPROFILE = home
env.LOCALAPPDATA = path.join(home, 'AppData', 'Local')
env.XDG_CACHE_HOME = path.join(home, '.cache')
env.FAKE_NPM_LOG = path.join(root, 'npm.log')
delete env.NODE_PATH
delete env.NODE_OPTIONS
delete env.DEBUG
const cacheBase = {
win32: env.LOCALAPPDATA,
darwin: path.join(home, 'Library', 'Caches')
}[process.platform] || env.XDG_CACHE_HOME
return {
root,
project,
projectManifest,
wrapperDir,
home,
env,
privateDir: path.join(wrapperDir, '.platform'),
cacheDir: path.join(cacheBase, 'microsoft-mcp', platformPackageName, packageVersion)
}
}
function wrapperArgs(fixture) {
return [path.join(fixture.wrapperDir, 'index.js'), 'server', 'start']
}
function runWrapper(fixture, extraEnv = {}) {
const result = spawnSync(process.execPath, wrapperArgs(fixture), {
cwd: fixture.project,
env: { ...fixture.env, ...extraEnv },
encoding: 'utf8',
timeout: 30000
})
assert.ifError(result.error)
return result
}
function runWrapperAsync(fixture, extraEnv = {}) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, wrapperArgs(fixture), {
cwd: fixture.project,
env: { ...fixture.env, ...extraEnv }
})
let stdout = ''
let stderr = ''
child.stdout.on('data', chunk => { stdout += chunk })
child.stderr.on('data', chunk => { stderr += chunk })
child.on('error', reject)
child.on('close', status => resolve({ status, stdout, stderr }))
})
}
function npmCalls(fixture) {
if (!fs.existsSync(fixture.env.FAKE_NPM_LOG)) {
return []
}
return fs.readFileSync(fixture.env.FAKE_NPM_LOG, 'utf8').trim().split('\n').map(line => JSON.parse(line))
}
// The directory npm was told to install into, resolved against the cwd it ran in.
function installPrefix(call) {
return path.resolve(call.cwd, call.args[call.args.indexOf('--prefix') + 1])
}
// A process ID that is no longer running.
function deadPid() {
return spawnSync(process.execPath, ['-e', '']).pid
}
// Replaces os.homedir() in the wrapper with one that throws, as it does when
// HOME is unset and the user has no passwd entry.
function noHomeDirEnv(fixture) {
const preload = path.join(fixture.root, 'no-home-dir.js')
fs.writeFileSync(preload, `require('os').homedir = () => { throw new Error('ENOENT: no home directory') }\n`)
return { NODE_OPTIONS: `--require ${JSON.stringify(preload)}` }
}
const permissionsSkip = process.platform === 'win32'
? 'directory permissions are not enforced this way on Windows'
: process.getuid() === 0 && 'root ignores directory permissions'
const windowsOnlySkip = process.platform === 'win32' ? false : 'Windows-only behavior'
const expectedStdout = 'ran ["server","start"]'
test('does not run npm when the platform package is installed normally', (t) => {
const fixture = createFixture(t)
writePlatformPackage(path.join(fixture.root, 'node_modules', ...platformPackageName.split('/')))
const result = runWrapper(fixture)
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
assert.deepStrictEqual(npmCalls(fixture), [])
assert.ok(!fs.existsSync(fixture.privateDir), 'should not create the private install dir')
})
test('does not need a home directory when the platform package is installed normally', (t) => {
const fixture = createFixture(t)
writePlatformPackage(path.join(fixture.root, 'node_modules', ...platformPackageName.split('/')))
const result = runWrapper(fixture, noHomeDirEnv(fixture))
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
})
test('installs a missing platform package privately, not into the caller\'s project', (t) => {
const fixture = createFixture(t)
const result = runWrapper(fixture)
assert.strictEqual(result.status, 0, result.stderr)
// npm's own stdout must not reach ours: under `server start` it is the
// JSON-RPC stream.
assert.strictEqual(result.stdout, expectedStdout)
const calls = npmCalls(fixture)
assert.strictEqual(calls.length, 1)
const { args, cwd } = calls[0]
assert.strictEqual(args[0], 'install')
assert.strictEqual(args[1], `${platformPackageName}@${packageVersion}`)
for (const flag of ['--no-save', '--no-audit', '--no-fund']) {
assert.ok(args.includes(flag), `expected ${flag} in ${JSON.stringify(args)}`)
}
assert.strictEqual(installPrefix(calls[0]), fixture.privateDir)
assert.strictEqual(cwd, fixture.privateDir)
assert.ok(fs.existsSync(path.join(fixture.privateDir, 'node_modules', ...platformPackageName.split('/'), 'index.js')))
assert.ok(!fs.existsSync(path.join(fixture.privateDir, '.install.lock')), 'should release the install lock')
assert.strictEqual(fs.readFileSync(path.join(fixture.project, 'package.json'), 'utf8'), fixture.projectManifest)
assert.ok(!fs.existsSync(path.join(fixture.project, 'package-lock.json')), 'should not write a lockfile into the project')
assert.ok(!fs.existsSync(path.join(fixture.project, 'node_modules')), 'should not install into the project')
})
test('installs into the wrapper directory without a home directory', (t) => {
const fixture = createFixture(t)
const result = runWrapper(fixture, noHomeDirEnv(fixture))
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
const calls = npmCalls(fixture)
assert.strictEqual(calls.length, 1)
assert.strictEqual(installPrefix(calls[0]), fixture.privateDir)
})
test('reuses an earlier private install instead of running npm again', (t) => {
const fixture = createFixture(t)
assert.strictEqual(runWrapper(fixture).status, 0)
const second = runWrapper(fixture)
assert.strictEqual(second.status, 0, second.stderr)
assert.strictEqual(second.stdout, expectedStdout)
assert.doesNotMatch(second.stderr, /Installing missing platform package/)
assert.strictEqual(npmCalls(fixture).length, 1)
})
test('reinstalls over a half-finished private install', (t) => {
const fixture = createFixture(t)
// A package.json without its index.js, as an interrupted install could leave.
const partial = path.join(fixture.privateDir, 'node_modules', ...platformPackageName.split('/'))
fs.mkdirSync(partial, { recursive: true })
fs.writeFileSync(path.join(partial, 'package.json'), JSON.stringify({ name: platformPackageName, main: './index.js' }))
const result = runWrapper(fixture)
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
assert.strictEqual(npmCalls(fixture).length, 1)
})
test('installs once when several launches start at the same time', async (t) => {
const fixture = createFixture(t)
const results = await Promise.all(
Array.from({ length: 4 }, () => runWrapperAsync(fixture, { FAKE_NPM_DELAY_MS: '500' }))
)
for (const result of results) {
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
}
assert.strictEqual(npmCalls(fixture).length, 1)
assert.ok(!fs.existsSync(path.join(fixture.privateDir, '.install.lock')), 'should release the install lock')
})
test('takes over an install lock left by a process that died', (t) => {
const fixture = createFixture(t)
fs.mkdirSync(fixture.privateDir, { recursive: true })
fs.writeFileSync(path.join(fixture.privateDir, '.install.lock'), String(deadPid()))
const result = runWrapper(fixture)
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
assert.strictEqual(npmCalls(fixture).length, 1)
assert.ok(!fs.existsSync(path.join(fixture.privateDir, '.install.lock')), 'should release the install lock')
})
test('falls back to a per-user cache when the private install path cannot be created', (t) => {
const fixture = createFixture(t)
fs.writeFileSync(fixture.privateDir, 'not a directory')
const result = runWrapper(fixture)
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
const calls = npmCalls(fixture)
assert.strictEqual(calls.length, 1)
assert.strictEqual(installPrefix(calls[0]), fixture.cacheDir)
})
test('ignores a relative LOCALAPPDATA path', { skip: windowsOnlySkip }, (t) => {
const fixture = createFixture(t)
fs.writeFileSync(fixture.privateDir, 'not a directory')
fixture.env.LOCALAPPDATA = 'relative-cache'
const expectedCacheDir = path.join(
fixture.home,
'AppData',
'Local',
'microsoft-mcp',
platformPackageName,
packageVersion
)
const result = runWrapper(fixture)
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
const calls = npmCalls(fixture)
assert.strictEqual(calls.length, 1)
assert.strictEqual(installPrefix(calls[0]), expectedCacheDir)
assert.ok(!fs.existsSync(path.join(fixture.project, 'relative-cache')))
})
test('falls back to a per-user cache when the wrapper directory is not writable', { skip: permissionsSkip }, (t) => {
const fixture = createFixture(t)
// Like a root-owned global install, or a read-only filesystem.
fs.chmodSync(fixture.wrapperDir, 0o555)
const first = runWrapper(fixture)
assert.strictEqual(first.status, 0, first.stderr)
assert.strictEqual(first.stdout, expectedStdout)
const calls = npmCalls(fixture)
assert.strictEqual(calls.length, 1)
assert.strictEqual(installPrefix(calls[0]), fixture.cacheDir)
assert.ok(!fs.existsSync(fixture.privateDir), 'should not have created the private install dir')
const second = runWrapper(fixture)
assert.strictEqual(second.status, 0, second.stderr)
assert.strictEqual(second.stdout, expectedStdout)
assert.strictEqual(npmCalls(fixture).length, 1, 'should reuse the cached install')
})
test('reports troubleshooting steps when no install directory is writable', { skip: permissionsSkip }, (t) => {
const fixture = createFixture(t)
fs.chmodSync(fixture.wrapperDir, 0o555)
fs.chmodSync(fixture.home, 0o555)
const result = runWrapper(fixture)
assert.strictEqual(result.status, 1)
assert.strictEqual(result.stdout, '')
assert.match(result.stderr, /No writable directory to install into/)
assert.deepStrictEqual(npmCalls(fixture), [])
})
test('recovers when the first npm attempt fails', (t) => {
const fixture = createFixture(t)
const result = runWrapper(fixture, { FAKE_NPM_FAIL_ONCE: path.join(fixture.root, 'failed-once') })
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, expectedStdout)
const calls = npmCalls(fixture)
assert.strictEqual(calls.length, 2)
assert.ok(calls[1].args.includes('--prefer-online'))
assert.strictEqual(installPrefix(calls[1]), fixture.privateDir)
})
test('reports troubleshooting steps and exits non-zero when npm fails', (t) => {
const fixture = createFixture(t)
const result = runWrapper(fixture, { FAKE_NPM_FAIL: '1' })
assert.strictEqual(result.status, 1)
assert.strictEqual(result.stdout, '')
assert.match(result.stderr, /Troubleshooting steps/)
assert.match(result.stderr, new RegExp(`You are running ${process.version.replace(/\./g, '\\.')}`))
assert.ok(!fs.existsSync(path.join(fixture.privateDir, '.install.lock')), 'should release the install lock')
const calls = npmCalls(fixture)
assert.strictEqual(calls.length, 2, 'should retry once')
assert.ok(calls[1].args.includes('--prefer-online'))
})
+232 -36
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env node
const fs = require('fs')
const os = require('os')
const path = require('path')
const packageJson = require('./package.json')
// Check if DEBUG environment variable is set
@@ -30,48 +32,239 @@ const packageName = packageJson.name
const packageVersion = packageJson.version
const platformPackageName = `${packageName}-${platform}-${arch}`
// Where the auto-install fallback below puts the platform package, in order of
// preference. It never goes in the caller's project: cwd is inherited from
// whoever spawned us - for an MCP client that is an unrelated user project -
// and a bare `npm install` resolves the nearest package.json from there and
// saves the platform package into it.
//
// The first choice lives inside this package, so it is tied to this version
// and goes away on uninstall. It is not writable for root-owned global installs
// or read-only filesystems, so the second is a per-user cache keyed by package
// and version. The OS temp directory is deliberately not used: on a shared
// machine another user could plant a package there for us to load.
//
// Neither directory has a package.json of its own: pointing npm at this
// package's manifest is a no-op, because the platform package is declared
// there as an optionalDependency that npm is already skipping (that skip is
// usually why we are in this code path at all).
//
// This only runs once the normal require has failed, so a working install never
// touches the home directory: os.homedir() throws when HOME is unset and the
// user has no passwd entry.
function getInstallDirs() {
const dirs = [path.join(__dirname, '.platform')]
try {
dirs.push(path.join(userCacheDir(), 'microsoft-mcp', platformPackageName, packageVersion))
} catch (err) {
debugLog(`No per-user cache directory available: ${err.message}`)
}
return dirs
}
function userCacheDir() {
if (platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA
return localAppData && path.isAbsolute(localAppData)
? localAppData
: path.join(os.homedir(), 'AppData', 'Local')
}
if (platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Caches')
}
// The XDG spec says to ignore a relative XDG_CACHE_HOME. Honoring one would
// put the cache under the caller's working directory.
const xdgCacheHome = process.env.XDG_CACHE_HOME
return xdgCacheHome && path.isAbsolute(xdgCacheHome) ? xdgCacheHome : path.join(os.homedir(), '.cache')
}
function requireFromInstallDir(installDir) {
return require(path.join(installDir, 'node_modules', platformPackageName))
}
function isWritableDir(dir) {
try {
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
return true
} catch (err) {
debugLog(`Cannot install platform package into ${dir}: ${err.message}`)
return false
}
}
// MCP clients often start several copies of a server at once, and they all
// share one install directory. Concurrent npm installs into one prefix undo
// each other's work (one moves away the package another has just finished), so
// a lock file lets one process install while the others wait and then load
// what it installed.
const installLockName = '.install.lock'
// Longer than both npm attempts together. The PID check below catches a dead
// holder sooner; this is the backstop for a PID that has been reused.
const installLockStaleMs = 3 * 60 * 1000
const installLockPollMs = 250
// A holder that is killed - say, by an MCP client giving up on a slow first
// start - never releases its lock, so check whether it is still running rather
// than waiting the lock out.
function isStaleLock(lockPath) {
if (Date.now() - fs.statSync(lockPath).mtimeMs > installLockStaleMs) {
return true
}
const pid = Number(fs.readFileSync(lockPath, 'utf8'))
if (!pid) {
return false // created, but the holder has not written its PID yet
}
try {
process.kill(pid, 0)
return false
} catch (err) {
return err.code === 'ESRCH'
}
}
function isInstallInProgress(dir) {
try {
return !isStaleLock(path.join(dir, installLockName))
} catch {
return false // no lock
}
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
}
function acquireInstallLock(installDir) {
const lockPath = path.join(installDir, installLockName)
let announced = false
for (;;) {
try {
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' })
return lockPath
} catch (err) {
if (err.code !== 'EEXIST') {
throw err
}
}
try {
if (isStaleLock(lockPath)) {
debugLog(`Removing stale install lock ${lockPath}`)
fs.rmSync(lockPath, { force: true })
continue
}
} catch (err) {
if (err.code !== 'ENOENT') {
throw err
}
continue // released between our attempt and the check
}
if (!announced) {
debugLog(`Waiting for another process to finish installing into ${installDir}`)
announced = true
}
sleep(installLockPollMs)
}
}
// Call with the install lock held.
function loadOrInstall(installDir) {
// Another process may have installed it while we waited for the lock.
try {
const installed = requireFromInstallDir(installDir)
debugLog(`Loaded ${platformPackageName} installed into ${installDir} by another process`)
return installed
} catch {
// Not installed yet
}
const { execSync } = require('child_process')
console.error(`Installing missing platform package: ${platformPackageName}`)
debugLog(`Installing ${platformPackageName} into ${installDir}`)
// stdout is not inherited: for `server start` it carries the JSON-RPC
// protocol stream, and npm output written there would corrupt it.
const installOptions = {
cwd: installDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 60000 // 60 second timeout
}
// --prefix is relative to cwd so that no path goes through the shell, which
// would expand any `$` or backtick in it.
const installCommand = `npm install ${platformPackageName}@${packageVersion} --no-save --no-audit --no-fund --prefix .`
// Try to install the platform package
try {
execSync(installCommand, installOptions)
} catch (npmErr) {
// If npm install fails, try again against the registry
debugLog(`npm install failed, trying alternative installation methods: ${npmErr.message}`)
execSync(`${installCommand} --prefer-online`, installOptions)
}
// Clear module cache and try to require again after installation
Object.keys(require.cache).forEach(key => {
if (key.includes(platformPackageName)) {
delete require.cache[key]
}
})
const installed = requireFromInstallDir(installDir)
console.error(`✅ Successfully installed and loaded ${platformPackageName}`)
return installed
}
// Try to load the platform package
let platformPackage
let loadErr
let installDirs
try {
debugLog(`Attempting to require platform package: ${platformPackageName}`)
platformPackage = require(platformPackageName)
} catch (err) {
debugLog(`Failed to require ${platformPackageName}, attempting auto-install: ${err.message}`)
loadErr = err
debugLog(`Failed to require ${platformPackageName}: ${err.message}`)
// Try to automatically install the missing platform package
try {
const { execSync } = require('child_process')
console.error(`Installing missing platform package: ${platformPackageName}`)
// Try to install the platform package
// Node never searches the install directories on its own, so an earlier
// auto-install is only found by looking there. Without this, every run would
// reinstall. A failed require (not installed, or a half-finished install)
// just falls through to installing again, and a directory another process is
// still installing into is left to the locked path below.
installDirs = getInstallDirs()
for (const dir of installDirs) {
if (isInstallInProgress(dir)) {
debugLog(`Skipping ${dir}: another process is installing into it`)
continue
}
try {
execSync(`npm install ${platformPackageName}@${packageVersion}`, {
stdio: ['inherit', 'inherit', 'pipe'], // Only pipe stderr to capture errors
timeout: 60000 // 60 second timeout
})
} catch (npmErr) {
// If npm install fails, try with --no-save and different install strategies
debugLog(`npm install failed, trying alternative installation methods: ${npmErr.message}`)
platformPackage = requireFromInstallDir(dir)
debugLog(`Loaded previously installed ${platformPackageName} from ${dir}`)
break
} catch (cachedErr) {
debugLog(`No usable ${platformPackageName} in ${dir}: ${cachedErr.message}`)
}
}
}
// Try with --no-save and --prefer-online
execSync(`npm install ${platformPackageName}@${packageVersion} --no-save --prefer-online`, {
stdio: ['inherit', 'inherit', 'pipe'],
timeout: 60000
})
if (!platformPackage) {
// Try to automatically install the missing platform package
let installDir
try {
installDir = installDirs.find(isWritableDir)
if (!installDir) {
throw new Error(`No writable directory to install into (tried ${installDirs.join(', ')})`)
}
// Clear module cache and try to require again after installation
Object.keys(require.cache).forEach(key => {
if (key.includes(platformPackageName)) {
delete require.cache[key]
}
})
platformPackage = require(platformPackageName)
console.error(`✅ Successfully installed and loaded ${platformPackageName}`)
const lockPath = acquireInstallLock(installDir)
try {
platformPackage = loadOrInstall(installDir)
} finally {
fs.rmSync(lockPath, { force: true })
}
} catch (installErr) {
debugLog(`Auto-install failed: ${installErr.message}`)
@@ -85,12 +278,15 @@ try {
console.error(`\n3. If using npx, clear the npx cache and try again:`)
console.error(` npx -y clear-npx-cache`)
console.error(` npx -y ${packageName}@latest --version`)
console.error(`\n4. Manually install the platform package to check compatibility:`)
console.error(` npm install ${platformPackageName}@latest`)
console.error(`\n5. Check your internet connection and try again`)
console.error(`\n6. If the issue persists, please report it at:`)
console.error(`\n4. Install the platform package manually to see npm's full output:`)
console.error(` npm install ${platformPackageName}@${packageVersion} --no-save --prefix "${installDir || installDirs[0]}"`)
console.error(`\n5. Check your Node version. npm silently skips the optional platform`)
console.error(` package when Node does not satisfy engines (${(packageJson.engines || {}).node}).`)
console.error(` You are running ${process.version}.`)
console.error(`\n6. Check your internet connection and try again`)
console.error(`\n7. If the issue persists, please report it at:`)
console.error(` https://github.com/microsoft/mcp/issues`)
console.error(`\nOriginal error: ${err.message}`)
console.error(`\nOriginal error: ${loadErr.message}`)
console.error(`Install error: ${installErr.message}`)
process.exit(1)
}
+44
View File
@@ -26,6 +26,10 @@ jobs:
env:
AZURE_MCP_COLLECT_TELEMETRY: 'false'
- pwsh: node --test eng/npm/test/wrapper.test.js
displayName: "Run npm wrapper tests"
workingDirectory: $(Build.SourcesDirectory)
# Run AOT analysis only on Linux x64
- template: /eng/pipelines/templates/steps/analyze-aot-compact.yml
parameters:
@@ -40,3 +44,43 @@ jobs:
}
displayName: 'Validate Package README.md files'
condition: succeededOrFailed()
- job: NpmWrapperTests_Windows
displayName: "Test npm wrapper on Windows"
condition: and(succeededOrFailed(), ne(variables['Skip.Analyze'], 'true'))
dependsOn: []
pool:
name: $(WINDOWSPOOL)
image: $(WINDOWSVMIMAGE)
os: windows
steps:
- checkout: self
- task: UseNode@1
displayName: 'Install Node.js 24'
inputs:
version: '24.x'
- pwsh: node --test eng/npm/test/wrapper.test.js
displayName: "Run npm wrapper tests"
workingDirectory: $(Build.SourcesDirectory)
- job: NpmWrapperTests_macOS
displayName: "Test npm wrapper on macOS"
condition: and(succeededOrFailed(), ne(variables['Skip.Analyze'], 'true'))
dependsOn: []
pool:
name: $(MACPOOL)
vmImage: $(MACVMIMAGE)
os: macos
steps:
- checkout: self
- task: UseNode@1
displayName: 'Install Node.js 24'
inputs:
version: '24.x'
- pwsh: node --test eng/npm/test/wrapper.test.js
displayName: "Run npm wrapper tests"
workingDirectory: $(Build.SourcesDirectory)
@@ -0,0 +1,4 @@
pr: 3184
changes:
- section: "Bugs Fixed"
description: "The npm wrapper no longer writes `@azure/mcp-<platform>-<arch>` into the calling project's `package.json` and lockfile when it auto-installs a missing platform package. The install now targets a private directory inside the wrapper package, or a per-user cache directory when that is not writable, instead of the inherited working directory. Later runs reuse that install rather than running npm again, and npm's output no longer goes to stdout, which carries the MCP protocol stream in `server start`."
@@ -0,0 +1,4 @@
pr: 3184
changes:
- section: "Bugs Fixed"
description: "The npm wrapper no longer writes its platform package into the calling project's `package.json` and lockfile when it auto-installs a missing platform package. The install now targets a private directory inside the wrapper package, or a per-user cache directory when that is not writable. Later runs reuse that install, and npm output no longer goes to stdout, which carries the MCP protocol stream during server startup."
@@ -0,0 +1,4 @@
pr: 3184
changes:
- section: "Bugs Fixed"
description: "The npm wrapper no longer writes its platform package into the calling project's `package.json` and lockfile when it auto-installs a missing platform package. The install now targets a private directory inside the wrapper package, or a per-user cache directory when that is not writable. Later runs reuse that install, and npm output no longer goes to stdout, which carries the MCP protocol stream during server startup."