Merge pull request #612 from open-pencil/fix/589-published-packages-windows-mcp

fix(mcp): repair published exports and Windows roots
This commit is contained in:
Danila Poyarkov 2026-08-31 15:06:18 +03:00 committed by GitHub
commit 5689eccc0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 128 additions and 8 deletions

View file

@ -71,6 +71,8 @@
### Fixed
- Make published package export conditions resolve to files included in npm tarballs.
- Use the user's home directory as the default MCP file root on Windows, avoiding the caller's unreliable working directory.
- Open legacy raw `.fig` files that store the Kiwi document and thumbnail without a ZIP wrapper. (#582)
- Preserve a frame's auto-layout HUG sizing mode when converting it into a component with `create_component`, so later padding changes still resize the component as expected.
- Run `openpencil import` on Node so the npm-installed CLI no longer fails with `Bun is not defined`. (#575)

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node
import { resolveMCPRoot } from '#mcp/root'
import { startServer } from '#mcp/server'
import { readToolPolicyFromEnv } from '#mcp/tool/policy'
@ -17,8 +18,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
` platform path. Parent dir created 0o700. Mainly for test isolation.\n` +
` OPENPENCIL_MCP_TCP Deprecated — TCP is controlled by PORT (>0 = on, 0 = off)\n` +
` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for MCP and RPC auth\n` +
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools (default: current working directory)\n` +
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools (default: home directory on Windows, current working directory elsewhere)\n` +
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit\n` +
` OPENPENCIL_MCP_CORS_ORIGIN Allowed CORS origin\n` +
` OPENPENCIL_MCP_APP_TIMEOUT_MS If set, close the server and remove its discovery\n` +
@ -75,7 +76,7 @@ const handle = await startServer({
socketPath: process.env.OPENPENCIL_MCP_SOCKET?.trim() || null,
enableEval: toolPolicy.allowEval,
disabledTools: toolPolicy.disabledTools,
mcpRoot: process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(),
mcpRoot: resolveMCPRoot(process.env.OPENPENCIL_MCP_ROOT),
// Auth token: undefined → auto-generate, empty string → disable auth,
// non-empty → use trimmed value. Whitespace-only is rejected to prevent a
// silent fallback to an auto-generated token when the operator intended to

5
packages/mcp/src/root.ts Normal file
View file

@ -0,0 +1,5 @@
import { homedir, platform } from 'node:os'
export function resolveMCPRoot(value: string | undefined, runtimePlatform = platform()): string {
return value?.trim() || (runtimePlatform === 'win32' ? homedir() : process.cwd())
}

View file

@ -2,6 +2,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { resolveMCPRoot } from '#mcp/root'
import { MCP_VERSION, registerTools } from '#mcp/server'
import { createStdioRPCBridge } from '#mcp/stdio/bridge'
import type { ToolPolicy } from '#mcp/tool/metadata'
@ -22,7 +23,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
` OPENPENCIL_MCP_SOCKET Override socket path (auto-discovered from discovery file when unset)\n` +
` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for RPC auth\n` +
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools\n` +
` (default: cwd when run standalone, home directory when app-spawned)\n` +
` (default: home directory on Windows, cwd elsewhere)\n` +
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit; defaults to the app setting\n`
)
@ -36,7 +37,7 @@ const toolPolicy: ToolPolicy = {
? ((await readDiscoveryFile())?.disabledTools ?? [])
: parseDisabledTools(process.env.OPENPENCIL_MCP_DISABLED_TOOLS)
}
const mcpRoot = process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd()
const mcpRoot = resolveMCPRoot(process.env.OPENPENCIL_MCP_ROOT)
// Auth token: undefined → auto-discover from discovery file, empty string →
// disable auth, whitespace-only → reject (same fail-fast as index.ts to catch
// misconfiguration), otherwise → use the trimmed value.

View file

@ -0,0 +1,14 @@
import { describe, expect, test } from 'bun:test'
import { homedir } from 'node:os'
import { resolveMCPRoot } from '../src/root'
describe('resolveMCPRoot', () => {
test('honors an explicit root', () => {
expect(resolveMCPRoot(' /designs ')).toBe('/designs')
})
test('uses the home directory on Windows', () => {
expect(resolveMCPRoot(undefined, 'win32')).toBe(homedir())
})
})

View file

@ -62,8 +62,31 @@ async function copyRecursive(from: string, to: string): Promise<void> {
await copyFile(from, to)
}
interface PackageExports {
[key: string]: PackageExports | string | undefined
}
interface PublishPackageJSON extends PackageJSON {
exports?: PackageExports
imports?: PackageExports
}
function isPackageExports(value: unknown): value is PackageExports {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function removeUnpublishedConditions(value: PackageExports | undefined): void {
if (!value) return
delete value.bun
for (const child of Object.values(value)) {
if (isPackageExports(child)) removeUnpublishedConditions(child)
}
}
export function publishPackageJSON(source: PackageJSON, coreVersion: string): PackageJSON {
const json = structuredClone(source)
const json = structuredClone(source) as PublishPackageJSON
removeUnpublishedConditions(json.exports)
removeUnpublishedConditions(json.imports)
for (const field of PACKAGE_FIELDS) {
const dependencies = json[field]

View file

@ -5,8 +5,13 @@ import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
type PackageExports = {
[key: string]: PackageExports | string | null | undefined
}
type PackageJSON = {
bin?: Record<string, string> | string
exports?: PackageExports
name: string
}
@ -15,6 +20,17 @@ export function packageBinTargets(packageJSON: PackageJSON): Record<string, stri
return packageJSON.bin ?? {}
}
function packageExportTargets(value: unknown): string[] {
if (typeof value === 'string') return [value]
if (Array.isArray(value)) return value.flatMap(packageExportTargets)
if (!value || typeof value !== 'object') return []
return Object.values(value).flatMap(packageExportTargets)
}
export function packageExportTargetPaths(packageJSON: Pick<PackageJSON, 'exports'>): string[] {
return packageExportTargets(packageJSON.exports)
}
export async function tarballEntries(tarballPath: string): Promise<Set<string>> {
const { stdout } = await execFileAsync('tar', ['-tf', tarballPath], { encoding: 'utf8' })
return new Set(stdout.trim().split('\n').filter(Boolean))
@ -39,9 +55,38 @@ export async function validateTarballBinTargets(tarballPath: string): Promise<vo
}
}
function exportTargetPattern(target: string): RegExp {
const escaped = target.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`)
}
export async function validateTarballExportTargets(tarballPath: string): Promise<void> {
const entries = await tarballEntries(tarballPath)
const packageJSON = await tarballPackageJSON(tarballPath)
for (const target of packageExportTargetPaths(packageJSON)) {
if (!target.startsWith('./')) continue
const relativeTarget = target.slice(2)
let matchingEntries: string[]
if (relativeTarget.includes('*')) {
const pattern = exportTargetPattern(relativeTarget)
matchingEntries = [...entries].filter((entry) => pattern.test(entry.slice('package/'.length)))
} else {
const exactEntry = `package/${relativeTarget}`
matchingEntries = entries.has(exactEntry) ? [exactEntry] : []
}
if (matchingEntries.length === 0) {
throw new Error(
`${tarballPath}: export target missing from tarball: package/${relativeTarget}`
)
}
}
}
export async function validatePackedTarballs(directory: string): Promise<void> {
const tarballs = (await readdir(directory)).filter((name) => name.endsWith('.tgz'))
for (const tarball of tarballs) {
await validateTarballBinTargets(join(directory, tarball))
const path = join(directory, tarball)
await validateTarballBinTargets(path)
await validateTarballExportTargets(path)
}
}

View file

@ -21,6 +21,7 @@ async function fixtureRoot() {
version: '1.0.0',
scripts: { build: 'tsdown' },
dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' },
exports: { '.': { bun: './src/index.ts', import: './dist/index.js' } },
devDependencies: { typescript: '^5.0.0' },
publishConfig: { access: 'public', main: './dist/index.js', types: './dist/index.d.ts' }
},
@ -38,6 +39,7 @@ describe('publishPackageJSON', () => {
name: '@open-pencil/example',
scripts: { build: 'tsdown' },
dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' },
exports: { '.': { bun: './src/index.ts', import: './dist/index.js' } },
devDependencies: { typescript: '^5.0.0' },
publishConfig: { access: 'public', main: './dist/index.js' }
},
@ -47,6 +49,7 @@ describe('publishPackageJSON', () => {
expect(json).toEqual({
name: '@open-pencil/example',
dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' },
exports: { '.': { import: './dist/index.js' } },
main: './dist/index.js'
})
})
@ -86,6 +89,7 @@ describe('preparePublishDirectories', () => {
name: '@open-pencil/example',
version: '1.0.0',
dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' },
exports: { '.': { import: './dist/index.js' } },
main: './dist/index.js',
types: './dist/index.d.ts'
})

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { packageBinTargets } from '../src/tarballs'
import { packageBinTargets, packageExportTargetPaths } from '../src/tarballs'
describe('packageBinTargets', () => {
test('normalizes string bin fields', () => {
@ -17,3 +17,28 @@ describe('packageBinTargets', () => {
})
})
})
describe('package export targets', () => {
test('collects wildcard targets from conditional exports', () => {
expect(
packageExportTargetPaths({ exports: { './feature/*': { import: './dist/*.js' } } })
).toEqual(['./dist/*.js'])
})
test('collects nested wildcard targets from conditional exports', () => {
expect(
packageExportTargetPaths({ exports: { './feature/*': { import: './dist/*.js' } } })
).toEqual(['./dist/*.js'])
})
test('collects targets from conditional exports', () => {
expect(
packageExportTargetPaths({
exports: {
'.': { types: './dist/index.d.ts', import: './dist/index.js' },
'./feature': { import: './dist/feature.js' }
}
})
).toEqual(['./dist/index.d.ts', './dist/index.js', './dist/feature.js'])
})
})