diff --git a/packages/schemas/src/manifest.ts b/packages/schemas/src/manifest.ts index 7cc5351..4b66cf1 100644 --- a/packages/schemas/src/manifest.ts +++ b/packages/schemas/src/manifest.ts @@ -1,11 +1,11 @@ -import { z } from "zod"; +import { z } from 'zod'; // ============================================================================= // Manifest Building Blocks // ============================================================================= /** Server runtime type (v0.4 added "uv"). */ -export const ServerTypeSchema = z.enum(["node", "python", "binary", "uv"]); +export const ServerTypeSchema = z.enum(['node', 'python', 'binary', 'uv']); /** * A path that must resolve to a location within the bundle root. @@ -23,33 +23,31 @@ export const ServerTypeSchema = z.enum(["node", "python", "binary", "uv"]); */ export const SafeRelativePathSchema = z .string() - .min(1, "must not be empty") - .refine((p) => !p.includes("\0"), { - message: "must not contain NUL bytes", + .min(1, 'must not be empty') + .refine((p) => !p.includes('\0'), { + message: 'must not contain NUL bytes', }) - .refine((p) => !p.includes("\\"), { + .refine((p) => !p.includes('\\'), { // MCPB bundles are zip archives; ZIP central directories use forward slashes. // Rejecting all backslashes blocks Windows-style traversal forms (`\foo`, // `C:\foo`, `\\server\share`, `foo\..\bar`) without needing per-form rules. - message: - "must use forward slashes only (backslashes are not permitted)", + message: 'must use forward slashes only (backslashes are not permitted)', }) .refine( (p) => { - if (p.startsWith("/")) return false; // POSIX absolute + if (p.startsWith('/')) return false; // POSIX absolute if (/^[a-zA-Z]:/.test(p)) return false; // Windows drive (with or without separator) - if (p.split("/").includes("..")) return false; // traversal segment + if (p.split('/').includes('..')) return false; // traversal segment return true; }, { - message: - 'must be a relative path within the bundle (no absolute paths or ".." segments)', + message: 'must be a relative path within the bundle (no absolute paths or ".." segments)', }, ); /** User-configurable field declared by a bundle author. */ export const UserConfigFieldSchema = z.object({ - type: z.enum(["string", "number", "boolean"]), + type: z.enum(['string', 'number', 'boolean']), title: z.string().optional(), description: z.string().optional(), sensitive: z.boolean().optional(), @@ -57,13 +55,49 @@ export const UserConfigFieldSchema = z.object({ default: z.union([z.string(), z.number(), z.boolean()]).optional(), }); -/** MCP server launch configuration (command, args, env). */ +/** + * MCP server launch configuration (command, args, env). + * + * `command` is optional per MCPB v0.4 — for `type: "uv"` the spec lets the + * host manage execution, in which case the bundle may omit `mcp_config` + * entirely. When present and `command` is omitted, the resolver supplies a + * sensible default for the server type. + */ export const McpConfigSchema = z.object({ - command: z.string(), - args: z.array(z.string()), + command: z.string().optional(), + args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), }); +/** + * Runtime version requirements (`compatibility.runtimes`). + * + * Each value is a semver range (e.g. `">=3.13,<4.0"`). Bundle authors only + * declare runtimes their bundle actually uses. + */ +export const CompatibilityRuntimesSchema = z.object({ + python: z.string().optional(), + node: z.string().optional(), +}); + +/** + * Compatibility block. + * + * Known fields (`platforms`, `runtimes`) are typed; unknown keys are treated + * as client version constraints (e.g. `claude_desktop: ">=1.0.0"`, + * `my_client: ">1.0.0"`) and pass through as semver strings, per MCPB spec. + */ +export const CompatibilitySchema = z + .object({ + // Inlined `z.enum(["darwin", "win32", "linux"])` — the same enum exists as + // `PlatformSchema` in package.ts but importing it here would create a + // module cycle (package.ts already imports `ServerTypeSchema` from this + // file). Three strings; not worth a shared module. + platforms: z.array(z.enum(['darwin', 'win32', 'linux'])).optional(), + runtimes: CompatibilityRuntimesSchema.optional(), + }) + .catchall(z.string()); + /** Author information. */ export const ManifestAuthorSchema = z.object({ name: z.string(), @@ -71,11 +105,18 @@ export const ManifestAuthorSchema = z.object({ url: z.string().optional(), }); -/** Server configuration block. */ +/** + * Server configuration block. + * + * `mcp_config` is optional per MCPB v0.4 — `type: "uv"` bundles may omit it + * entirely and let the host manage execution. + */ export const ManifestServerSchema = z.object({ type: ServerTypeSchema, entry_point: SafeRelativePathSchema, - mcp_config: McpConfigSchema, + // Optional per MCPB v0.4 — `type: "uv"` bundles may omit and let + // the host manage execution. + mcp_config: McpConfigSchema.optional(), }); /** MCP capability descriptor (tool, prompt, or resource). */ @@ -112,6 +153,7 @@ export const McpbManifestSchema = z.object({ .optional(), user_config: z.record(z.string(), UserConfigFieldSchema).optional(), server: ManifestServerSchema, + compatibility: CompatibilitySchema.optional(), tools: z.array(CapabilitySchema).optional(), prompts: z.array(CapabilitySchema).optional(), resources: z.array(CapabilitySchema).optional(), @@ -125,6 +167,8 @@ export const McpbManifestSchema = z.object({ export type ServerType = z.infer; export type UserConfigField = z.infer; export type McpConfig = z.infer; +export type CompatibilityRuntimes = z.infer; +export type Compatibility = z.infer; export type ManifestAuthor = z.infer; export type ManifestServer = z.infer; export type Capability = z.infer; diff --git a/packages/schemas/tests/manifest.test.ts b/packages/schemas/tests/manifest.test.ts index 7f15a99..b720da0 100644 --- a/packages/schemas/tests/manifest.test.ts +++ b/packages/schemas/tests/manifest.test.ts @@ -1,80 +1,110 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; import { + CompatibilityRuntimesSchema, + CompatibilitySchema, ManifestServerSchema, McpbManifestSchema, + McpConfigSchema, SafeRelativePathSchema, -} from "../src/manifest.js"; +} from '../src/manifest.js'; -describe("SafeRelativePathSchema", () => { - describe("accepts safe relative paths", () => { +describe('SafeRelativePathSchema', () => { + describe('accepts safe relative paths', () => { it.each([ - "index.js", - "./index.js", - "src/index.js", - "build/server/main.js", - "main.py", - "mcp_echo.server", - "bin/run", - "deeply/nested/path/to/file.js", - "name-with-dashes.js", - "name_with_underscores.js", - "file.with.many.dots.js", - "ünicode/файл.js", - ])("accepts %j", (path) => { + 'index.js', + './index.js', + 'src/index.js', + 'build/server/main.js', + 'main.py', + 'mcp_echo.server', + 'bin/run', + 'deeply/nested/path/to/file.js', + 'name-with-dashes.js', + 'name_with_underscores.js', + 'file.with.many.dots.js', + 'ünicode/файл.js', + ])('accepts %j', (path) => { expect(SafeRelativePathSchema.safeParse(path).success).toBe(true); }); }); - describe("rejects unsafe paths", () => { + describe('rejects unsafe paths', () => { it.each([ - ["empty string", ""], - ["NUL byte", "foo\0bar"], - ["POSIX absolute", "/etc/passwd"], - ["POSIX absolute root", "/"], - ["dotdot at start", "../foo"], - ["dotdot in middle", "foo/../bar"], - ["dotdot at end", "foo/.."], - ["multiple dotdot", "../../../etc/passwd"], - ["windows drive", "C:\\evil"], - ["windows drive forward slash", "C:/evil"], - ["windows drive lowercase", "c:\\evil"], - ["windows drive without separator", "C:foo"], - ["windows drive-root-relative", "\\foo"], - ["windows UNC", "\\\\server\\share"], - ["dotdot via backslash", "foo\\..\\bar"], - ["any backslash", "foo\\bar"], - ])("rejects %s (%j)", (_label, path) => { + ['empty string', ''], + ['NUL byte', 'foo\0bar'], + ['POSIX absolute', '/etc/passwd'], + ['POSIX absolute root', '/'], + ['dotdot at start', '../foo'], + ['dotdot in middle', 'foo/../bar'], + ['dotdot at end', 'foo/..'], + ['multiple dotdot', '../../../etc/passwd'], + ['windows drive', 'C:\\evil'], + ['windows drive forward slash', 'C:/evil'], + ['windows drive lowercase', 'c:\\evil'], + ['windows drive without separator', 'C:foo'], + ['windows drive-root-relative', '\\foo'], + ['windows UNC', '\\\\server\\share'], + ['dotdot via backslash', 'foo\\..\\bar'], + ['any backslash', 'foo\\bar'], + ])('rejects %s (%j)', (_label, path) => { expect(SafeRelativePathSchema.safeParse(path).success).toBe(false); }); }); it("does not reject paths that merely contain '..' as a substring", () => { - expect(SafeRelativePathSchema.safeParse("foo..bar.js").success).toBe(true); - expect(SafeRelativePathSchema.safeParse("..hidden/file.js").success).toBe( - true, - ); + expect(SafeRelativePathSchema.safeParse('foo..bar.js').success).toBe(true); + expect(SafeRelativePathSchema.safeParse('..hidden/file.js').success).toBe(true); }); }); -describe("ManifestServerSchema", () => { +describe('McpConfigSchema', () => { + it('accepts a fully populated mcp_config', () => { + const result = McpConfigSchema.safeParse({ + command: 'uv', + args: ['run', 'src/server.py'], + env: { FOO: 'bar' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts mcp_config with no command (MCPB v0.4 — host-managed)', () => { + const result = McpConfigSchema.safeParse({ + args: ['run', 'src/server.py'], + }); + expect(result.success).toBe(true); + }); + + it('accepts mcp_config with no args', () => { + // Some uv bundles supply only `command` and rely on resolver defaults for args. + const result = McpConfigSchema.safeParse({ command: 'uv' }); + expect(result.success).toBe(true); + }); + + it('accepts an empty mcp_config object', () => { + const result = McpConfigSchema.safeParse({}); + expect(result.success).toBe(true); + }); +}); + +describe('ManifestServerSchema', () => { const validServer = { - type: "node" as const, - entry_point: "src/index.js", + type: 'node' as const, + entry_point: 'src/index.js', mcp_config: { - command: "node", - args: ["${__dirname}/src/index.js"], + command: 'node', + args: ['${__dirname}/src/index.js'], }, }; - it("accepts a clean relative entry_point", () => { + it('accepts a clean relative entry_point', () => { expect(ManifestServerSchema.safeParse(validServer).success).toBe(true); }); - it("rejects an entry_point with .. traversal", () => { + it('rejects an entry_point with .. traversal', () => { const result = ManifestServerSchema.safeParse({ ...validServer, - entry_point: "../../etc/passwd", + entry_point: '../../etc/passwd', }); expect(result.success).toBe(false); if (!result.success) { @@ -82,50 +112,198 @@ describe("ManifestServerSchema", () => { } }); - it("rejects an absolute entry_point", () => { + it('rejects an absolute entry_point', () => { expect( ManifestServerSchema.safeParse({ ...validServer, - entry_point: "/etc/passwd", + entry_point: '/etc/passwd', }).success, ).toBe(false); }); - it("rejects an empty entry_point", () => { + it('rejects an empty entry_point', () => { expect( ManifestServerSchema.safeParse({ ...validServer, - entry_point: "", + entry_point: '', }).success, ).toBe(false); }); + + it('accepts type:uv server with no mcp_config (host-managed execution)', () => { + const result = ManifestServerSchema.safeParse({ + type: 'uv', + entry_point: 'src/server.py', + }); + expect(result.success).toBe(true); + }); + + it('accepts type:python server with full mcp_config', () => { + const result = ManifestServerSchema.safeParse({ + type: 'python', + entry_point: 'src/server.py', + mcp_config: { + command: 'python', + args: ['-m', 'my_pkg.server'], + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects servers missing entry_point', () => { + const result = ManifestServerSchema.safeParse({ type: 'node' }); + expect(result.success).toBe(false); + }); +}); + +describe('CompatibilityRuntimesSchema', () => { + it('accepts python and node version constraints', () => { + const result = CompatibilityRuntimesSchema.safeParse({ + python: '>=3.13,<4.0', + node: '>=20.0.0', + }); + expect(result.success).toBe(true); + }); + + it('accepts only python (bundle authors declare what they use)', () => { + const result = CompatibilityRuntimesSchema.safeParse({ + python: '>=3.10', + }); + expect(result.success).toBe(true); + }); + + it('accepts an empty runtimes block', () => { + const result = CompatibilityRuntimesSchema.safeParse({}); + expect(result.success).toBe(true); + }); +}); + +describe('CompatibilitySchema', () => { + it("accepts the spec's full example shape", () => { + const result = CompatibilitySchema.safeParse({ + claude_desktop: '>=1.0.0', + my_client: '>1.0.0', + other_client: '>=2.0.0 <3.0.0', + platforms: ['darwin', 'win32', 'linux'], + runtimes: { + python: '>=3.8', + node: '>=16.0.0', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + // Unknown client constraints pass through via catchall. + expect(result.data.claude_desktop).toBe('>=1.0.0'); + expect(result.data.runtimes?.python).toBe('>=3.8'); + } + }); + + it('rejects invalid platform values', () => { + const result = CompatibilitySchema.safeParse({ + platforms: ['beos'], + }); + expect(result.success).toBe(false); + }); + + it('rejects non-string client version constraints (catchall enforces string)', () => { + const result = CompatibilitySchema.safeParse({ + claude_desktop: 123, + }); + expect(result.success).toBe(false); + }); }); -describe("McpbManifestSchema", () => { +describe('McpbManifestSchema', () => { const baseManifest = { - manifest_version: "0.4", - name: "@test/bundle", - version: "1.0.0", - description: "test", + manifest_version: '0.4', + name: '@test/bundle', + version: '1.0.0', + description: 'test', server: { - type: "node", - entry_point: "build/index.js", + type: 'node', + entry_point: 'build/index.js', mcp_config: { - command: "node", - args: ["${__dirname}/build/index.js"], + command: 'node', + args: ['${__dirname}/build/index.js'], }, }, }; - it("accepts a well-formed manifest", () => { + it('accepts a well-formed manifest', () => { expect(McpbManifestSchema.safeParse(baseManifest).success).toBe(true); }); - it("rejects a manifest whose entry_point traverses out of the bundle", () => { + it('rejects a manifest whose entry_point traverses out of the bundle', () => { const result = McpbManifestSchema.safeParse({ ...baseManifest, - server: { ...baseManifest.server, entry_point: "../../../../etc/passwd" }, + server: { ...baseManifest.server, entry_point: '../../../../etc/passwd' }, }); expect(result.success).toBe(false); }); + + it('accepts a v0.3 type:python manifest (backward compatibility)', () => { + const result = McpbManifestSchema.safeParse({ + manifest_version: '0.3', + name: 'legacy-bundle', + version: '1.0.0', + description: 'v0.3 bundle with vendored deps', + server: { + type: 'python', + entry_point: 'src/server.py', + mcp_config: { + command: 'python', + args: ['-m', 'legacy.server'], + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts the canonical hello-world-uv manifest from the MCPB spec', () => { + // Verbatim from anthropics/mcpb examples/hello-world-uv/manifest.json. + // If this stops parsing, mpak has drifted from the upstream spec. + const result = McpbManifestSchema.safeParse({ + manifest_version: '0.4', + name: 'hello-world-uv', + display_name: 'Hello World (UV Runtime)', + version: '1.0.0', + description: 'Simple MCP server using UV runtime', + author: { name: 'Anthropic' }, + icon: 'icon.png', + server: { + type: 'uv', + entry_point: 'src/server.py', + mcp_config: { + command: 'uv', + args: ['run', '--directory', '${__dirname}', 'src/server.py'], + }, + }, + compatibility: { + platforms: ['darwin', 'linux', 'win32'], + runtimes: { python: '>=3.10' }, + }, + keywords: ['example', 'hello-world', 'uv'], + license: 'MIT', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.compatibility?.runtimes?.python).toBe('>=3.10'); + expect(result.data.compatibility?.platforms).toEqual(['darwin', 'linux', 'win32']); + } + }); + + it('accepts a host-managed type:uv bundle that omits mcp_config entirely', () => { + const result = McpbManifestSchema.safeParse({ + manifest_version: '0.4', + name: 'minimal-uv', + version: '0.1.0', + description: 'uv bundle delegating execution to the host', + server: { + type: 'uv', + entry_point: 'src/server.py', + }, + compatibility: { runtimes: { python: '>=3.13' } }, + }); + expect(result.success).toBe(true); + }); }); diff --git a/packages/sdk-typescript/src/mpakSDK.ts b/packages/sdk-typescript/src/mpakSDK.ts index 661a58c..d505ba8 100644 --- a/packages/sdk-typescript/src/mpakSDK.ts +++ b/packages/sdk-typescript/src/mpakSDK.ts @@ -1,4 +1,3 @@ -import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import type { McpbManifest } from '@nimblebrain/mpak-schemas'; @@ -13,6 +12,8 @@ import { localBundleNeedsExtract, readJsonFromFile, } from './helpers.js'; +import { resolvePython } from './python-resolver.js'; +import { resolveUv } from './uv-resolver.js'; import type { MpakClientConfig } from './types.js'; /** @@ -38,6 +39,22 @@ export interface MpakOptions { * policy. */ maxUncompressedSize?: number; + /** + * Override the Python interpreter probe used by `type: "python"` bundles. + * + * Production callers omit this — the resolver spawns the real binary to + * read `sys.implementation.cache_tag`. Tests inject a stub so the suite + * doesn't depend on which Python is on the runner's PATH. + */ + pythonProbe?: (command: string) => { cacheTag: string; version: string } | null; + /** + * Override the `uv --version` probe used by `type: "uv"` bundles. + * + * Production callers omit this — the resolver spawns the real binary to + * confirm uv is installed. Tests inject a stub so the suite doesn't + * depend on whether uv is on the runner's PATH. + */ + uvProbe?: (command: string) => { version: string } | null; } /** @@ -132,6 +149,10 @@ export class Mpak { readonly client: MpakClient; /** Local bundle cache. */ readonly bundleCache: MpakBundleCache; + /** Optional probe override for `type: "python"` resolution (test seam). */ + private readonly pythonProbe: MpakOptions['pythonProbe']; + /** Optional probe override for `type: "uv"` resolution (test seam). */ + private readonly uvProbe: MpakOptions['uvProbe']; constructor(options?: MpakOptions) { // initialize config @@ -156,6 +177,9 @@ export class Mpak { cacheOptions.maxUncompressedSize = options.maxUncompressedSize; } this.bundleCache = new MpakBundleCache(this.client, cacheOptions); + + this.pythonProbe = options?.pythonProbe; + this.uvProbe = options?.uvProbe; } /** @@ -391,12 +415,16 @@ export class Mpak { /** * Resolve the manifest's `server` block into a spawnable command, args, and env. * - * Handles three server types: + * Handles four server types: * - **binary** — runs the compiled executable at `entry_point`, chmod'd +x. * - **node** — runs `mcp_config.command` (default `"node"`) with `mcp_config.args`, * or falls back to `node ` when args are empty. - * - **python** — like node, but resolves `python3`/`python` at runtime and - * prepends `/deps` to `PYTHONPATH` for bundled dependencies. + * - **python** — single-probe interpreter resolution via {@link resolvePython} + * (MPAK_PYTHON > manifest command > `python3`), ABI-validated against the + * bundle's compiled extensions and `compatibility.runtimes.python`. Prepends + * `/deps` to `PYTHONPATH` for bundled dependencies. + * - **uv** — invokes uv to provision Python and install deps from + * `pyproject.toml` (default args: `run --directory `). * * All `${__dirname}` placeholders in args are replaced with `cacheDir`. * All `${user_config.*}` placeholders in env are replaced with gathered user values. @@ -408,7 +436,12 @@ export class Mpak { cacheDir: string, userConfigValues: Record, ): { command: string; args: string[]; env: Record } { - const { type, entry_point, mcp_config } = manifest.server; + const { type, entry_point } = manifest.server; + // `mcp_config` is optional (MCPB v0.4 lets `type: "uv"` bundles omit it); + // normalize to an empty object so each case can reach for command/args/env + // without re-guarding undefined. + const mcp_config = manifest.server.mcp_config ?? {}; + const userArgs = mcp_config.args ?? []; // Substitute user_config placeholders in manifest env const env = Mpak.substituteEnvVars(mcp_config.env, userConfigValues); @@ -419,7 +452,7 @@ export class Mpak { switch (type) { case 'binary': { command = join(cacheDir, entry_point); - args = Mpak.resolveArgs(mcp_config.args ?? [], cacheDir); + args = Mpak.resolveArgs(userArgs, cacheDir); try { chmodSync(command, 0o755); } catch { @@ -431,20 +464,35 @@ export class Mpak { case 'node': { command = mcp_config.command || 'node'; args = - mcp_config.args.length > 0 - ? Mpak.resolveArgs(mcp_config.args, cacheDir) + userArgs.length > 0 + ? Mpak.resolveArgs(userArgs, cacheDir) : [join(cacheDir, entry_point)]; break; } case 'python': { - command = - mcp_config.command === 'python' - ? Mpak.findPythonCommand() - : mcp_config.command || Mpak.findPythonCommand(); + // No candidate-name parade: ABI-validate exactly one interpreter + // (MPAK_PYTHON env > manifest command > "python3"). Throws with an + // actionable message — including how to set MPAK_PYTHON — when the + // chosen interpreter doesn't match the bundle's compiled deps or the + // manifest's declared `compatibility.runtimes.python` range. + const resolved = resolvePython({ + cacheDir, + manifestCommand: mcp_config.command, + declaredRange: manifest.compatibility?.runtimes?.python, + env: process.env, + ...(this.pythonProbe ? { probe: this.pythonProbe } : {}), + }); + command = resolved.command; + // One stderr line at startup so users never have to ask "which Python + // is mpak actually running?". Removes the entire class of issue #90 + // debug sessions. + process.stderr.write( + `[mpak] python: ${resolved.command} (${resolved.version}, ${resolved.cacheTag}) via ${resolved.source}\n`, + ); args = - mcp_config.args.length > 0 - ? Mpak.resolveArgs(mcp_config.args, cacheDir) + userArgs.length > 0 + ? Mpak.resolveArgs(userArgs, cacheDir) : [join(cacheDir, entry_point)]; // Set PYTHONPATH to deps/ directory @@ -454,11 +502,21 @@ export class Mpak { } case 'uv': { - command = mcp_config.command || 'uv'; - args = - mcp_config.args.length > 0 - ? Mpak.resolveArgs(mcp_config.args, cacheDir) - : ['run', join(cacheDir, entry_point)]; + // Preflight uv before spawning so the failure mode is "install uv" + // instead of a raw ENOENT mid-spawn. Default args follow the spec + // example (`run --directory `) so the invocation + // is cwd-independent — embedders that don't honor `server.cwd` + // still find pyproject.toml correctly. + const resolvedUv = resolveUv({ + cacheDir, + entryPoint: entry_point, + manifestCommand: mcp_config.command, + userArgs: Mpak.resolveArgs(userArgs, cacheDir), + ...(this.uvProbe ? { probe: this.uvProbe } : {}), + }); + command = resolvedUv.command; + args = resolvedUv.args; + process.stderr.write(`[mpak] uv: ${resolvedUv.command} (${resolvedUv.version})\n`); break; } @@ -513,17 +571,6 @@ export class Mpak { } return result; } - - /** - * Find a working Python executable. Tries `python3` first, falls back to `python`. - */ - private static findPythonCommand(): string { - const result = spawnSync('python3', ['--version'], { stdio: 'pipe' }); - if (result.status === 0) { - return 'python3'; - } - return 'python'; - } } /** diff --git a/packages/sdk-typescript/src/python-resolver.ts b/packages/sdk-typescript/src/python-resolver.ts new file mode 100644 index 0000000..3527397 --- /dev/null +++ b/packages/sdk-typescript/src/python-resolver.ts @@ -0,0 +1,395 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Required Python ABI inferred from a bundle's vendored extension modules. + * + * - `tag: "cpython-313"`, `abi3: false` — pinned to one major.minor. + * - `tag: "abi3"`, `abi3: true`, `floor: { major: 3, minor: 7 }` — stable ABI; + * any cpython >= floor satisfies it. + * - `null` from {@link findRequiredAbi} means a pure-Python bundle (no `.so`/ + * `.pyd` files); any interpreter satisfying the manifest's declared range + * will do. + */ +export interface RequiredAbi { + tag: string; + abi3: boolean; + floor?: { major: number; minor: number }; +} + +/** + * Parse the cpython ABI tag from a single compiled-extension filename. + * + * Recognized shapes (per PEP 425 and the abi3 stable-ABI convention): + * - `.cpython-313-darwin.so` → `{ tag: "cpython-313" }` + * - `.cpython-313-x86_64-linux-gnu.so` → `{ tag: "cpython-313" }` + * - `.cpython-3.7-abi3-.so` → `{ tag: "abi3", floor: 3.7 }` + * - `.abi3.so` → `{ tag: "abi3", floor: 3.2 }` + * + * Returns `null` for filenames that don't match any known pattern (e.g. + * pure-Python `.py` files, vendor metadata) so the caller can keep scanning. + */ +export function parseCpythonTag(filename: string): RequiredAbi | null { + // abi3 with explicit floor: cpython-3.7-abi3-.so + const abi3WithFloor = /\.cpython-(\d+)\.(\d+)-abi3[-.]/.exec(filename); + if (abi3WithFloor) { + return { + tag: 'abi3', + abi3: true, + floor: { + major: Number(abi3WithFloor[1]), + minor: Number(abi3WithFloor[2]), + }, + }; + } + // Bare abi3 (no floor declared in name): historically any 3.2+, but in + // practice cpython has only shipped abi3 wheels from 3.7+. Use 3.7 as the + // pragmatic floor — narrower than the spec, broader than any modern host. + if (/\.abi3\.(so|pyd|dylib)$/.test(filename)) { + return { tag: 'abi3', abi3: true, floor: { major: 3, minor: 7 } }; + } + // Specific: cpython-XY-... or cpython-XYZ-... (concatenated major+minor) + const specific = /\.cpython-(\d{2,3})[-.]/.exec(filename); + if (specific) { + return { tag: `cpython-${specific[1]}`, abi3: false }; + } + return null; +} + +/** + * Walk `/deps/` and return the first compiled-extension ABI + * requirement found. A single specific (`cpython-XYZ`) tag wins over abi3 — + * if the bundle ships *any* version-specific extension, the whole bundle is + * pinned to that interpreter regardless of what other extensions claim. + * + * Returns `null` for pure-Python bundles (no `.so`/`.pyd`/`.dylib` found). + */ +export function findRequiredAbi(cacheDir: string): RequiredAbi | null { + const depsDir = join(cacheDir, 'deps'); + if (!existsSync(depsDir)) return null; + + let abi3Hit: RequiredAbi | null = null; + + // Bounded recursive walk — bundles can nest deps a few levels for + // namespace packages, but we cap depth defensively to avoid runaway + // traversal on a malformed bundle. + const walk = (dir: string, depth: number): RequiredAbi | null => { + if (depth > 8) return null; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return null; + } + for (const name of entries) { + const full = join(dir, name); + let stat; + try { + stat = statSync(full); + } catch { + continue; + } + if (stat.isDirectory()) { + const found = walk(full, depth + 1); + if (found && !found.abi3) return found; + if (found && found.abi3 && !abi3Hit) abi3Hit = found; + continue; + } + if (!/\.(so|pyd|dylib)$/.test(name)) continue; + const parsed = parseCpythonTag(name); + if (!parsed) continue; + if (!parsed.abi3) return parsed; + if (!abi3Hit) abi3Hit = parsed; + } + return null; + }; + + return walk(depsDir, 0) ?? abi3Hit; +} + +/** + * Probe a Python executable for its ABI cache tag and version string. + * + * Returns `null` if the binary doesn't exist, errors out, or doesn't print + * the expected two-line output. We don't distinguish "not found" from + * "broken" — both reduce to "this interpreter cannot serve the bundle." + */ +export function probeInterpreter(command: string): { cacheTag: string; version: string } | null { + const probe = spawnSync( + command, + [ + '-c', + "import sys; print(sys.implementation.cache_tag); print('.'.join(map(str, sys.version_info[:3])))", + ], + { stdio: 'pipe', timeout: 5_000 }, + ); + if (probe.status !== 0) return null; + const lines = probe.stdout.toString().trim().split('\n'); + if (lines.length < 2) return null; + const [cacheTag, version] = lines as [string, string]; + if (!cacheTag || !version) return null; + return { cacheTag, version }; +} + +/** + * Compare a probed cpython tag against the bundle's required ABI. + */ +export function abiMatches(probedTag: string, required: RequiredAbi): boolean { + if (!required.abi3) { + return probedTag === required.tag; + } + // abi3: probed interpreter must be cpython >= floor. + const m = /^cpython-(\d+)$/.exec(probedTag); + if (!m) return false; + const probedDigits = m[1]!; + // `cpython-313` packs major+minor as digits; `cpython-3.7` would split on a + // dot, but Python's cache_tag joins them. We treat `313` → 3.13, `37` → 3.7. + const major = Number(probedDigits[0]); + const minor = Number(probedDigits.slice(1)); + if (!required.floor) return major === 3; + if (major !== required.floor.major) return major > required.floor.major; + return minor >= required.floor.minor; +} + +/** + * Minimal satisfier for `compatibility.runtimes.python`-style ranges. + * + * Supported clauses (comma-separated, ANDed): `>=X.Y`, `>X.Y`, `<=X.Y`, + * ` s.trim()) + .filter(Boolean); + if (clauses.length === 0) return true; + for (const clause of clauses) { + if (!evaluateClause(v, clause)) return false; + } + return true; +} + +interface SemVer { + major: number; + minor: number; + patch: number; +} + +function parseVersion(raw: string): SemVer | null { + const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(raw.trim()); + if (!m) return null; + return { + major: Number(m[1]), + minor: Number(m[2]), + patch: m[3] ? Number(m[3]) : 0, + }; +} + +function compareVersions(a: SemVer, b: SemVer): number { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} + +function evaluateClause(v: SemVer, clause: string): boolean { + const m = /^(>=|<=|>|<|==|=)?\s*(\d+\.\d+(?:\.\d+)?)$/.exec(clause); + if (!m) return true; // unparseable clause → don't reject + const op = m[1] ?? '=='; + const target = parseVersion(m[2]!)!; + const cmp = compareVersions(v, target); + switch (op) { + case '>=': + return cmp >= 0; + case '>': + return cmp > 0; + case '<=': + return cmp <= 0; + case '<': + return cmp < 0; + case '==': + case '=': + // Exact-on-major.minor when patch wasn't specified in target — treats + // `==3.13` as "any 3.13.x". + if (!/\.\d+\.\d+/.test(m[2]!)) { + return v.major === target.major && v.minor === target.minor; + } + return cmp === 0; + default: + return true; + } +} + +/** + * Resolution input. + */ +export interface ResolvePythonOptions { + /** Bundle cache directory (contains `deps/`). */ + cacheDir: string; + /** `mcp_config.command` from the manifest, if declared. */ + manifestCommand: string | undefined; + /** `compatibility.runtimes.python` range from the manifest, if declared. */ + declaredRange: string | undefined; + /** + * Process env, used to read `MPAK_PYTHON`. Caller passes `process.env` in + * production; tests inject a synthetic record. + */ + env: NodeJS.ProcessEnv; + /** + * Override the interpreter probe. Production callers omit this and the + * resolver spawns the real binary. Tests inject a stub so the suite + * doesn't depend on which Python happens to be on the test runner. + */ + probe?: (command: string) => { cacheTag: string; version: string } | null; +} + +/** + * Resolution result — the spawnable command plus diagnostics for the caller + * to log. + */ +export interface ResolvedPython { + command: string; + cacheTag: string; + version: string; + source: 'MPAK_PYTHON' | 'manifest' | 'default'; +} + +/** + * One probe, no candidate parade. + * + * Resolution order: `MPAK_PYTHON` env var → `mcp_config.command` → + * literal `"python3"`. We pick exactly one command, probe it once, and + * either return it (on ABI + range match) or throw with an actionable + * message. The contract is "tell me which Python to use; if it doesn't fit, + * I'll explain why and stop." + */ +export function resolvePython(options: ResolvePythonOptions): ResolvedPython { + const { cacheDir, manifestCommand, declaredRange, env, probe = probeInterpreter } = options; + + let command: string; + let source: ResolvedPython['source']; + if (env['MPAK_PYTHON'] && env['MPAK_PYTHON'].trim().length > 0) { + command = env['MPAK_PYTHON']; + source = 'MPAK_PYTHON'; + } else if (manifestCommand && manifestCommand.length > 0) { + command = manifestCommand; + source = 'manifest'; + } else { + command = 'python3'; + source = 'default'; + } + + const probed = probe(command); + if (!probed) { + throw new PythonResolutionError(formatNotFoundMessage(command, source, cacheDir)); + } + + const required = findRequiredAbi(cacheDir); + if (required && !abiMatches(probed.cacheTag, required)) { + throw new PythonResolutionError(formatAbiMismatchMessage(command, source, probed, required)); + } + + if (declaredRange && !satisfiesPythonRange(probed.version, declaredRange)) { + throw new PythonResolutionError( + formatRangeMismatchMessage(command, source, probed, declaredRange), + ); + } + + return { command, cacheTag: probed.cacheTag, version: probed.version, source }; +} + +/** + * Surfaced when the resolver cannot pick a Python interpreter that runs the + * bundle. Callers translate this to a CLI-friendly error. + */ +export class PythonResolutionError extends Error { + constructor(message: string) { + super(message); + this.name = 'PythonResolutionError'; + } +} + +function formatNotFoundMessage( + command: string, + source: ResolvedPython['source'], + _cacheDir: string, +): string { + const origin = + source === 'MPAK_PYTHON' + ? 'MPAK_PYTHON env var' + : source === 'manifest' + ? 'bundle manifest' + : 'default'; + return [ + `Python interpreter '${command}' (from ${origin}) is not runnable.`, + ` Set MPAK_PYTHON to a working interpreter:`, + ` export MPAK_PYTHON=$(which python3)`, + ` Or install Python: https://www.python.org/downloads/`, + ].join('\n'); +} + +function formatAbiMismatchMessage( + command: string, + source: ResolvedPython['source'], + probed: { cacheTag: string; version: string }, + required: RequiredAbi, +): string { + const requiredHuman = required.abi3 + ? `Python ${required.floor!.major}.${required.floor!.minor}+ (stable ABI)` + : `Python ${cpythonTagToHuman(required.tag)} (${required.tag})`; + const originLabel = + source === 'MPAK_PYTHON' + ? 'MPAK_PYTHON' + : source === 'manifest' + ? 'manifest mcp_config.command' + : "default 'python3'"; + return [ + `Bundle requires ${requiredHuman}.`, + ` Found: ${command} = ${probed.version} (${probed.cacheTag}), via ${originLabel}.`, + ` Fix: export MPAK_PYTHON=$(which ${suggestBinary(required)})`, + ` Or install: pyenv install ${suggestVersion(required)} | brew install python@${suggestVersion(required)} | uv python install ${suggestVersion(required)}`, + ].join('\n'); +} + +function formatRangeMismatchMessage( + command: string, + source: ResolvedPython['source'], + probed: { cacheTag: string; version: string }, + range: string, +): string { + const originLabel = + source === 'MPAK_PYTHON' + ? 'MPAK_PYTHON' + : source === 'manifest' + ? 'manifest mcp_config.command' + : "default 'python3'"; + return [ + `Bundle declares compatibility.runtimes.python: '${range}'.`, + ` Found: ${command} = ${probed.version}, via ${originLabel}.`, + ` Fix: install a satisfying Python and set MPAK_PYTHON to its path.`, + ].join('\n'); +} + +function cpythonTagToHuman(tag: string): string { + const m = /^cpython-(\d+)$/.exec(tag); + if (!m) return tag; + const digits = m[1]!; + return `${digits[0]}.${digits.slice(1)}`; +} + +function suggestVersion(required: RequiredAbi): string { + if (required.abi3 && required.floor) { + return `${required.floor.major}.${required.floor.minor}`; + } + return cpythonTagToHuman(required.tag); +} + +function suggestBinary(required: RequiredAbi): string { + return `python${suggestVersion(required)}`; +} diff --git a/packages/sdk-typescript/src/uv-resolver.ts b/packages/sdk-typescript/src/uv-resolver.ts new file mode 100644 index 0000000..7f2126e --- /dev/null +++ b/packages/sdk-typescript/src/uv-resolver.ts @@ -0,0 +1,98 @@ +import { spawnSync } from 'node:child_process'; + +/** + * Probe `uv` (or any uv-compatible binary path) for its version. + * + * Returns `null` if the binary doesn't exist or doesn't respond to + * `--version`. We don't distinguish "not installed" from "broken" — both + * collapse to "this uv cannot serve the bundle." + */ +export function probeUv(command: string): { version: string } | null { + const probe = spawnSync(command, ['--version'], { + stdio: 'pipe', + timeout: 5_000, + }); + if (probe.status !== 0) return null; + const out = probe.stdout.toString().trim(); + // `uv --version` prints e.g. "uv 0.4.22 (3f6f4f9 2024-10-17)" — peel off + // the leading word and take the version token. Fall back to the full + // string if the format ever changes; the caller only uses this for log + // output, not parsing. + const m = /\b(\d+\.\d+\.\d+\S*)/.exec(out); + return { version: m ? m[1]! : out }; +} + +/** + * Surfaced when uv isn't reachable on the host. + */ +export class UvResolutionError extends Error { + constructor(message: string) { + super(message); + this.name = 'UvResolutionError'; + } +} + +export interface ResolveUvOptions { + /** Bundle cache directory — passed to `uv run --directory`. */ + cacheDir: string; + /** Bundle entry point (relative to cacheDir). */ + entryPoint: string; + /** `mcp_config.command` from the manifest. Defaults to `"uv"`. */ + manifestCommand: string | undefined; + /** + * Manifest-supplied args, post-substitution. When non-empty the resolver + * defers to them; when empty the resolver supplies the spec-canonical + * `run --directory ` form. + */ + userArgs: string[]; + /** + * Probe override for tests. Production callers omit this and the resolver + * spawns the real binary. + */ + probe?: (command: string) => { version: string } | null; +} + +export interface ResolvedUv { + command: string; + args: string[]; + version: string; +} + +/** + * Resolve the `uv` invocation for a `type: "uv"` bundle. + * + * Two responsibilities: + * + * 1. **Preflight uv.** If the chosen binary isn't reachable, throw a clear + * error with install instructions. Without this, the user sees a raw + * ENOENT mid-spawn and has to know that "uv" is the missing piece. + * + * 2. **Default args to the spec-canonical form.** When the manifest doesn't + * provide its own args, default to + * `run --directory ` — matching the upstream + * hello-world-uv example. The `--directory` flag makes the invocation + * cwd-independent so embedders that don't honor `server.cwd` still find + * `pyproject.toml` correctly. + */ +export function resolveUv(options: ResolveUvOptions): ResolvedUv { + const { cacheDir, entryPoint, manifestCommand, userArgs, probe = probeUv } = options; + + const command = manifestCommand && manifestCommand.length > 0 ? manifestCommand : 'uv'; + + const probed = probe(command); + if (!probed) { + throw new UvResolutionError( + [ + `\`${command}\` is required to run this bundle but is not on PATH.`, + ` Install uv:`, + ` macOS / Linux: curl -LsSf https://astral.sh/uv/install.sh | sh`, + ` Windows: irm https://astral.sh/uv/install.ps1 | iex`, + ` Docs: https://docs.astral.sh/uv/`, + ].join('\n'), + ); + } + + const args = userArgs.length > 0 ? userArgs : ['run', '--directory', cacheDir, entryPoint]; + + return { command, args, version: probed.version }; +} diff --git a/packages/sdk-typescript/tests/mpak.test.ts b/packages/sdk-typescript/tests/mpak.test.ts index 0d2d71d..9cfcf59 100644 --- a/packages/sdk-typescript/tests/mpak.test.ts +++ b/packages/sdk-typescript/tests/mpak.test.ts @@ -249,8 +249,17 @@ describe('Mpak facade', () => { }, }; - function setupSdk(manifest: McpbManifest | null = nodeManifest) { - const sdk = new Mpak({ mpakHome: testDir }); + function setupSdk( + manifest: McpbManifest | null = nodeManifest, + opts: { + pythonProbe?: (cmd: string) => { cacheTag: string; version: string } | null; + uvProbe?: (cmd: string) => { version: string } | null; + } = {}, + ) { + const sdkOpts: ConstructorParameters[0] = { mpakHome: testDir }; + if (opts.pythonProbe) sdkOpts.pythonProbe = opts.pythonProbe; + if (opts.uvProbe) sdkOpts.uvProbe = opts.uvProbe; + const sdk = new Mpak(sdkOpts); const cacheDir = join(testDir, 'cache', 'scope-echo'); vi.spyOn(sdk.bundleCache, 'loadBundle').mockResolvedValue({ @@ -290,7 +299,7 @@ describe('Mpak facade', () => { expect(result.args).toEqual([join(cacheDir, 'index.js')]); }); - it('resolves a python server', async () => { + it('resolves a python server, honoring the manifest command verbatim (issue #90)', async () => { const pythonManifest: McpbManifest = { ...nodeManifest, server: { @@ -299,11 +308,23 @@ describe('Mpak facade', () => { mcp_config: { command: 'python', args: ['${__dirname}/main.py'], env: {} }, }, }; - const { sdk, cacheDir } = setupSdk(pythonManifest); + const { sdk, cacheDir } = setupSdk(pythonManifest, { + // No `deps/` in the test fixture → ABI check no-ops; the probe still + // has to return *something* so resolvePython can confirm the chosen + // interpreter is runnable. + pythonProbe: (cmd) => { + // Pin the assertion below: whatever `cmd` the manifest declared is + // exactly what reaches the probe. No silent rewrite to 'python3'. + expect(cmd).toBe('python'); + return { cacheTag: 'cpython-313', version: '3.13.0' }; + }, + }); const result = await sdk.prepareServer({ name: '@scope/echo' }); - expect(['python', 'python3']).toContain(result.command); + // Manifest said `python` — resolver must return `python`, not 'python3'. + // This pins the issue #90 fix. + expect(result.command).toBe('python'); expect(result.args).toEqual([`${cacheDir}/main.py`]); expect(result.env['PYTHONPATH']).toContain(join(cacheDir, 'deps')); }); @@ -325,6 +346,42 @@ describe('Mpak facade', () => { expect(result.args).toEqual(['--port', '3000']); }); + it('resolves a uv server with the spec-canonical default args', async () => { + // Mirrors `examples/hello-world-uv/manifest.json` upstream: the bundle + // omits args, the resolver supplies `run --directory `. + const uvManifest: McpbManifest = { + ...nodeManifest, + server: { + type: 'uv', + entry_point: 'src/server.py', + }, + compatibility: { runtimes: { python: '>=3.13' } }, + }; + const { sdk, cacheDir } = setupSdk(uvManifest, { + uvProbe: () => ({ version: '0.4.22' }), + }); + + const result = await sdk.prepareServer({ name: '@scope/echo' }); + + expect(result.command).toBe('uv'); + expect(result.args).toEqual(['run', '--directory', cacheDir, 'src/server.py']); + }); + + it('throws when uv mode is requested but uv is not installed', async () => { + const uvManifest: McpbManifest = { + ...nodeManifest, + server: { + type: 'uv', + entry_point: 'src/server.py', + }, + }; + const { sdk } = setupSdk(uvManifest, { + uvProbe: () => null, + }); + + await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow(/not on PATH/); + }); + it('passes version from spec to loadBundle', async () => { const { sdk } = setupSdk(); @@ -951,7 +1008,7 @@ describe('Mpak facade', () => { ); }); - it('resolves a python server from local bundle', async () => { + it('resolves a python server from local bundle, honoring the manifest command (issue #90)', async () => { const pythonManifest: McpbManifest = { ...nodeManifest, server: { @@ -960,12 +1017,15 @@ describe('Mpak facade', () => { mcp_config: { command: 'python', args: ['${__dirname}/main.py'], env: {} }, }, }; - const sdk = new Mpak({ mpakHome: testDir }); + const sdk = new Mpak({ + mpakHome: testDir, + pythonProbe: () => ({ cacheTag: 'cpython-313', version: '3.13.0' }), + }); const mcpbPath = createMcpbBundle(testDir, pythonManifest); const result = await sdk.prepareServer({ local: mcpbPath }); - expect(['python', 'python3']).toContain(result.command); + expect(result.command).toBe('python'); expect(result.args).toEqual([`${result.cwd}/main.py`]); expect(result.env['PYTHONPATH']).toContain(join(result.cwd, 'deps')); }); diff --git a/packages/sdk-typescript/tests/python-resolver.test.ts b/packages/sdk-typescript/tests/python-resolver.test.ts new file mode 100644 index 0000000..1c5cdc2 --- /dev/null +++ b/packages/sdk-typescript/tests/python-resolver.test.ts @@ -0,0 +1,281 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + abiMatches, + findRequiredAbi, + parseCpythonTag, + PythonResolutionError, + resolvePython, + satisfiesPythonRange, +} from '../src/python-resolver.js'; + +describe('parseCpythonTag', () => { + it('recognizes the canonical specific cpython tag', () => { + expect(parseCpythonTag('rpds.cpython-313-darwin.so')).toEqual({ + tag: 'cpython-313', + abi3: false, + }); + }); + + it('recognizes a Linux extension with arch + libc in the platform tag', () => { + expect(parseCpythonTag('_pydantic_core.cpython-311-x86_64-linux-gnu.so')).toEqual({ + tag: 'cpython-311', + abi3: false, + }); + }); + + it('recognizes Windows .pyd extensions', () => { + expect(parseCpythonTag('rpds.cpython-313-win_amd64.pyd')).toEqual({ + tag: 'cpython-313', + abi3: false, + }); + }); + + it('recognizes abi3 with explicit floor', () => { + expect(parseCpythonTag('_brotli.cpython-3.7-abi3-x86_64-linux-gnu.so')).toEqual({ + tag: 'abi3', + abi3: true, + floor: { major: 3, minor: 7 }, + }); + }); + + it('recognizes bare abi3 with implicit 3.7 floor', () => { + expect(parseCpythonTag('_lib.abi3.so')).toEqual({ + tag: 'abi3', + abi3: true, + floor: { major: 3, minor: 7 }, + }); + }); + + it('returns null for non-extension files', () => { + expect(parseCpythonTag('module.py')).toBeNull(); + expect(parseCpythonTag('README.md')).toBeNull(); + }); +}); + +describe('findRequiredAbi', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'mpak-python-resolver-')); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns null when there is no deps/ directory (pure-Python bundle)', () => { + expect(findRequiredAbi(testDir)).toBeNull(); + }); + + it('returns null when deps/ has no compiled extensions', () => { + mkdirSync(join(testDir, 'deps', 'pure_pkg'), { recursive: true }); + writeFileSync(join(testDir, 'deps', 'pure_pkg', '__init__.py'), ''); + expect(findRequiredAbi(testDir)).toBeNull(); + }); + + it('finds the cpython tag of a vendored compiled extension', () => { + mkdirSync(join(testDir, 'deps', 'rpds'), { recursive: true }); + writeFileSync(join(testDir, 'deps', 'rpds', 'rpds.cpython-313-darwin.so'), ''); + expect(findRequiredAbi(testDir)).toEqual({ tag: 'cpython-313', abi3: false }); + }); + + it('prefers a specific cpython tag over an abi3 tag in the same bundle', () => { + // A bundle that mixes a version-pinned extension (e.g. pydantic_core) with + // an abi3-only extension is pinned by the version-specific one, since the + // host interpreter has to satisfy both. + mkdirSync(join(testDir, 'deps', 'a'), { recursive: true }); + mkdirSync(join(testDir, 'deps', 'b'), { recursive: true }); + writeFileSync(join(testDir, 'deps', 'a', 'specific.cpython-313-darwin.so'), ''); + writeFileSync(join(testDir, 'deps', 'b', 'stable.abi3.so'), ''); + expect(findRequiredAbi(testDir)).toEqual({ tag: 'cpython-313', abi3: false }); + }); + + it('returns abi3 when the bundle ships only stable-ABI extensions', () => { + mkdirSync(join(testDir, 'deps', 'c'), { recursive: true }); + writeFileSync(join(testDir, 'deps', 'c', 'stable.abi3.so'), ''); + expect(findRequiredAbi(testDir)).toEqual({ + tag: 'abi3', + abi3: true, + floor: { major: 3, minor: 7 }, + }); + }); +}); + +describe('abiMatches', () => { + it('requires exact match for version-specific cpython tags', () => { + expect(abiMatches('cpython-313', { tag: 'cpython-313', abi3: false })).toBe(true); + expect(abiMatches('cpython-311', { tag: 'cpython-313', abi3: false })).toBe(false); + }); + + it('accepts any cpython >= floor for abi3', () => { + const required = { tag: 'abi3', abi3: true, floor: { major: 3, minor: 7 } }; + expect(abiMatches('cpython-313', required)).toBe(true); + expect(abiMatches('cpython-37', required)).toBe(true); + expect(abiMatches('cpython-36', required)).toBe(false); + }); + + it('rejects non-cpython probed tags', () => { + expect(abiMatches('pypy3', { tag: 'cpython-313', abi3: false })).toBe(false); + }); +}); + +describe('satisfiesPythonRange', () => { + it("treats an unparseable range as 'no constraint' (don't crash the host)", () => { + expect(satisfiesPythonRange('3.13.1', '')).toBe(true); + expect(satisfiesPythonRange('3.13.1', 'garbage')).toBe(true); + }); + + it('evaluates `>=` floors', () => { + expect(satisfiesPythonRange('3.13.0', '>=3.10')).toBe(true); + expect(satisfiesPythonRange('3.9.0', '>=3.10')).toBe(false); + }); + + it('evaluates compound ranges (AND)', () => { + expect(satisfiesPythonRange('3.13.1', '>=3.10,<4.0')).toBe(true); + expect(satisfiesPythonRange('4.0.0', '>=3.10,<4.0')).toBe(false); + }); + + it("treats `==3.13` as 'any 3.13.x' (major.minor exact)", () => { + expect(satisfiesPythonRange('3.13.5', '==3.13')).toBe(true); + expect(satisfiesPythonRange('3.12.0', '==3.13')).toBe(false); + }); +}); + +describe('resolvePython', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'mpak-python-resolver-')); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + function probeOk(tag = 'cpython-313', version = '3.13.0') { + return () => ({ cacheTag: tag, version }); + } + + it('honors `mcp_config.command` verbatim — no silent rewrite (issue #90)', () => { + const seen: string[] = []; + const resolved = resolvePython({ + cacheDir: testDir, + manifestCommand: 'python', + declaredRange: undefined, + env: {}, + probe: (cmd) => { + seen.push(cmd); + return { cacheTag: 'cpython-313', version: '3.13.0' }; + }, + }); + expect(seen).toEqual(['python']); // no rewrite to 'python3' + expect(resolved.command).toBe('python'); + expect(resolved.source).toBe('manifest'); + }); + + it('prefers MPAK_PYTHON over the manifest command', () => { + const resolved = resolvePython({ + cacheDir: testDir, + manifestCommand: 'python', + declaredRange: undefined, + env: { MPAK_PYTHON: '/opt/homebrew/bin/python3.13' }, + probe: probeOk(), + }); + expect(resolved.command).toBe('/opt/homebrew/bin/python3.13'); + expect(resolved.source).toBe('MPAK_PYTHON'); + }); + + it('falls back to `python3` when the manifest declares no command', () => { + const resolved = resolvePython({ + cacheDir: testDir, + manifestCommand: undefined, + declaredRange: undefined, + env: {}, + probe: probeOk(), + }); + expect(resolved.command).toBe('python3'); + expect(resolved.source).toBe('default'); + }); + + it('throws PythonResolutionError when the chosen interpreter cannot be probed', () => { + expect(() => + resolvePython({ + cacheDir: testDir, + manifestCommand: 'python', + declaredRange: undefined, + env: {}, + probe: () => null, + }), + ).toThrow(PythonResolutionError); + }); + + it("throws an actionable ABI error when the bundle's compiled deps don't match", () => { + // Bundle pins cpython-313 via a vendored .so file... + mkdirSync(join(testDir, 'deps', 'rpds'), { recursive: true }); + writeFileSync(join(testDir, 'deps', 'rpds', 'rpds.cpython-313-darwin.so'), ''); + + // ...but the chosen interpreter is 3.11. + let err: unknown; + try { + resolvePython({ + cacheDir: testDir, + manifestCommand: 'python3', + declaredRange: undefined, + env: {}, + probe: () => ({ cacheTag: 'cpython-311', version: '3.11.9' }), + }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(PythonResolutionError); + const msg = (err as Error).message; + expect(msg).toContain('Bundle requires'); + expect(msg).toContain('cpython-313'); + expect(msg).toContain('3.11.9'); + expect(msg).toContain('MPAK_PYTHON'); // explicit override is the fix + }); + + it("throws when probed interpreter doesn't satisfy compatibility.runtimes.python", () => { + expect(() => + resolvePython({ + cacheDir: testDir, + manifestCommand: 'python3', + declaredRange: '>=3.13,<4.0', + env: {}, + probe: () => ({ cacheTag: 'cpython-311', version: '3.11.9' }), + }), + ).toThrow(/compatibility\.runtimes\.python/); + }); + + it('succeeds when ABI tag matches and range is satisfied', () => { + mkdirSync(join(testDir, 'deps', 'rpds'), { recursive: true }); + writeFileSync(join(testDir, 'deps', 'rpds', 'rpds.cpython-313-darwin.so'), ''); + + const resolved = resolvePython({ + cacheDir: testDir, + manifestCommand: 'python3', + declaredRange: '>=3.13', + env: {}, + probe: () => ({ cacheTag: 'cpython-313', version: '3.13.1' }), + }); + expect(resolved.command).toBe('python3'); + expect(resolved.cacheTag).toBe('cpython-313'); + expect(resolved.version).toBe('3.13.1'); + }); + + it('succeeds for pure-Python bundles regardless of interpreter ABI tag', () => { + // No deps/ in cacheDir → no ABI requirement to check. + const resolved = resolvePython({ + cacheDir: testDir, + manifestCommand: 'python3', + declaredRange: '>=3.10', + env: {}, + probe: () => ({ cacheTag: 'cpython-311', version: '3.11.9' }), + }); + expect(resolved.command).toBe('python3'); + }); +}); diff --git a/packages/sdk-typescript/tests/uv-resolver.test.ts b/packages/sdk-typescript/tests/uv-resolver.test.ts new file mode 100644 index 0000000..4944ef9 --- /dev/null +++ b/packages/sdk-typescript/tests/uv-resolver.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveUv, UvResolutionError } from '../src/uv-resolver.js'; + +describe('resolveUv', () => { + function probeOk(version = '0.4.22') { + return () => ({ version }); + } + + it('uses the spec-canonical default args when the manifest provides none', () => { + // From upstream `examples/hello-world-uv/manifest.json`: + // "args": ["run", "--directory", "${__dirname}", "src/server.py"] + // Our default has to match shape-for-shape so embedders that don't honor + // `server.cwd` still find pyproject.toml. + const resolved = resolveUv({ + cacheDir: '/tmp/cache/abc', + entryPoint: 'src/server.py', + manifestCommand: undefined, + userArgs: [], + probe: probeOk(), + }); + expect(resolved.command).toBe('uv'); + expect(resolved.args).toEqual(['run', '--directory', '/tmp/cache/abc', 'src/server.py']); + }); + + it('defers to manifest-supplied args verbatim', () => { + // The manifest's args have already been ${__dirname}-substituted by the + // SDK before reaching the resolver — this test only pins the resolver's + // behavior of trusting them as-is. + const resolved = resolveUv({ + cacheDir: '/tmp/cache/abc', + entryPoint: 'src/server.py', + manifestCommand: 'uv', + userArgs: ['run', '--directory', '/tmp/cache/abc', 'src/server.py'], + probe: probeOk(), + }); + expect(resolved.args).toEqual(['run', '--directory', '/tmp/cache/abc', 'src/server.py']); + }); + + it('honors manifest.command (e.g. an absolute uv path) verbatim', () => { + const resolved = resolveUv({ + cacheDir: '/tmp/cache/abc', + entryPoint: 'src/server.py', + manifestCommand: '/opt/local/bin/uv', + userArgs: [], + probe: probeOk(), + }); + expect(resolved.command).toBe('/opt/local/bin/uv'); + }); + + it('throws UvResolutionError with install instructions when uv is missing', () => { + let err: unknown; + try { + resolveUv({ + cacheDir: '/tmp/cache/abc', + entryPoint: 'src/server.py', + manifestCommand: undefined, + userArgs: [], + probe: () => null, + }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(UvResolutionError); + const msg = (err as Error).message; + expect(msg).toContain('not on PATH'); + expect(msg).toContain('astral.sh/uv/install.sh'); + }); + + it('returns the probed uv version for diagnostic logging', () => { + const resolved = resolveUv({ + cacheDir: '/tmp/cache/abc', + entryPoint: 'src/server.py', + manifestCommand: undefined, + userArgs: [], + probe: () => ({ version: '0.5.1' }), + }); + expect(resolved.version).toBe('0.5.1'); + }); +});