Skip to content

Subprocess Scan

lcp.subprocess_scan

Run package scans in a separate interpreter.

The MCP server must not import arbitrary package code in-process: import-time side effects can crash the server, heavy imports stall concurrent tool calls (CPython import lock + GIL, even though FastMCP runs sync tools on worker threads), and the server's environment is often not the project's environment. This module launches an isolated child interpreter that runs only raw introspection and parses the ScannedModule tree it writes to stdout as JSON; the host then runs :func:lcp.generator.generate_lcp with its own pydantic to produce the LCP document.

The child is started as <python> -c <bootstrap> <package>. The bootstrap loads the self-contained scanner.py and the stdlib-only _childscan.py by absolute file path (so lcp/__init__.py — and thus pydantic and fastmcp — never runs in the child) and adds only extra_paths to the child's sys.path. The host site-packages is therefore never exposed: target submodules cannot import host packages, and the target's pydantic_core cannot collide with the host's pydantic. The target interpreter needs neither lcp nor pydantic installed. The child inherits the server's working directory; with -c, that directory is also on its sys.path, mirroring in-process import behavior.

Residual trust model (documented, not solved here): the scanned package's import-time code still executes — just in a disposable child process. Process isolation contains crashes and hangs; it is not a sandbox.

DEFAULT_SCAN_TIMEOUT module-attribute

DEFAULT_SCAN_TIMEOUT = 60.0

Seconds before a scan subprocess is killed (heavy imports can be slow).

SubprocessScanError

Bases: Exception

Base class for subprocess scan failures; messages are agent-facing.

Source code in src/lcp/subprocess_scan.py
class SubprocessScanError(Exception):
    """Base class for subprocess scan failures; messages are agent-facing."""

ScanImportError

Bases: SubprocessScanError

The target package is not importable in the scan environment (exit 3).

Source code in src/lcp/subprocess_scan.py
class ScanImportError(SubprocessScanError):
    """The target package is not importable in the scan environment (exit 3)."""

ScanFailedError

Bases: SubprocessScanError

The scan ran but failed: crash, invalid output, or unexpected exit.

Source code in src/lcp/subprocess_scan.py
class ScanFailedError(SubprocessScanError):
    """The scan ran but failed: crash, invalid output, or unexpected exit."""

ScanTimeoutError

Bases: SubprocessScanError

The scan subprocess exceeded its timeout and was killed.

Source code in src/lcp/subprocess_scan.py
class ScanTimeoutError(SubprocessScanError):
    """The scan subprocess exceeded its timeout and was killed."""

ScanInterpreterNotFoundError

Bases: SubprocessScanError

The configured scan interpreter does not exist.

Source code in src/lcp/subprocess_scan.py
class ScanInterpreterNotFoundError(SubprocessScanError):
    """The configured scan interpreter does not exist."""

ScanSpawnError

Bases: SubprocessScanError

The scan subprocess could not be started (process spawning restricted).

Source code in src/lcp/subprocess_scan.py
class ScanSpawnError(SubprocessScanError):
    """The scan subprocess could not be started (process spawning restricted)."""

ScanResult dataclass

A generated LCP document plus the diagnostics from producing it.

Attributes:

Name Type Description
document LCPDocument

The generated LCP document.

unresolved_reexports list[tuple[str, int, int]]

(ancestor_module, count, module_count) triples for sibling packages that define re-exported names but were never scanned (issue #58). Empty for documents that did not come from a live scan.

Source code in src/lcp/subprocess_scan.py
@dataclass
class ScanResult:
    """A generated LCP document plus the diagnostics from producing it.

    Attributes:
        document: The generated LCP document.
        unresolved_reexports: ``(ancestor_module, count, module_count)``
            triples for sibling packages that define re-exported names but
            were never scanned (issue #58). Empty for documents that did not
            come from a live scan.
    """

    document: LCPDocument
    unresolved_reexports: list[tuple[str, int, int]] = field(default_factory=list)

scan_package_subprocess

scan_package_subprocess(package: str, python: str | None = None, timeout: float = DEFAULT_SCAN_TIMEOUT, extra_paths: Sequence[str] = ()) -> ScanResult

Scan package in a child interpreter and return its LCP document.

Parameters:

Name Type Description Default
package str

Import path of the package to scan (e.g. "requests").

required
python str | None

Interpreter whose environment gets scanned. Defaults to sys.executable — the server's own environment.

None
timeout float

Seconds before the child is killed.

DEFAULT_SCAN_TIMEOUT
extra_paths Sequence[str]

Additional sys.path entries for the child (test fixtures, path-based scans). The scan modules load by absolute path, so the target interpreter needs neither lcp nor pydantic.

()

Returns:

Name Type Description
A ScanResult

class:ScanResult with the :class:~lcp.models.LCPDocument

ScanResult

generated on the host from the child's raw ScannedModule JSON,

ScanResult

plus any unresolved re-export diagnostics from the scan.

Raises:

Type Description
ScanImportError

The package is not importable in the scan environment.

ScanFailedError

The scan crashed or produced invalid output.

ScanTimeoutError

The child exceeded timeout and was killed.

ScanInterpreterNotFoundError

python does not exist.

ScanSpawnError

The child process could not be spawned at all.

Source code in src/lcp/subprocess_scan.py
def scan_package_subprocess(
    package: str,
    python: str | None = None,
    timeout: float = DEFAULT_SCAN_TIMEOUT,
    extra_paths: Sequence[str] = (),
) -> ScanResult:
    """Scan *package* in a child interpreter and return its LCP document.

    Args:
        package: Import path of the package to scan (e.g. ``"requests"``).
        python: Interpreter whose environment gets scanned. Defaults to
            ``sys.executable`` — the server's own environment.
        timeout: Seconds before the child is killed.
        extra_paths: Additional ``sys.path`` entries for the child (test
            fixtures, path-based scans). The scan modules load by absolute path,
            so the target interpreter needs neither ``lcp`` nor ``pydantic``.

    Returns:
        A :class:`ScanResult` with the :class:`~lcp.models.LCPDocument`
        generated on the host from the child's raw ``ScannedModule`` JSON,
        plus any unresolved re-export diagnostics from the scan.

    Raises:
        ScanImportError: The package is not importable in the scan environment.
        ScanFailedError: The scan crashed or produced invalid output.
        ScanTimeoutError: The child exceeded *timeout* and was killed.
        ScanInterpreterNotFoundError: *python* does not exist.
        ScanSpawnError: The child process could not be spawned at all.
    """
    interpreter = python or sys.executable
    code = _build_child_code(extra_paths)
    cmd = [interpreter, "-c", code, package]
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except subprocess.TimeoutExpired as exc:
        raise ScanTimeoutError(
            f"scan of '{package}' timed out after {timeout:.0f}s "
            f"(interpreter: {interpreter}); heavy packages may need a "
            f"higher scan timeout (--scan-timeout / .lcp-config.json 'scan_timeout')"
        ) from exc
    except FileNotFoundError as exc:
        raise ScanInterpreterNotFoundError(
            f"scan interpreter not found: {interpreter} — check the "
            f"'scan_python' / 'python' setting in .lcp-config.json"
        ) from exc
    except OSError as exc:
        raise ScanSpawnError(
            f"could not spawn the scan subprocess ({interpreter}): {exc}"
        ) from exc

    if proc.returncode == 0:
        try:
            from .generator import generate_lcp
            from .scanner import scanned_from_dict

            scanned = scanned_from_dict(json.loads(proc.stdout))
            return ScanResult(
                document=generate_lcp(scanned),
                unresolved_reexports=scanned.unresolved_reexports,
            )
        except Exception as exc:
            raise ScanFailedError(
                f"scan of '{package}' produced invalid scan data: {exc}"
            ) from exc

    message = _stderr_message(proc.stderr)
    if proc.returncode == 3:
        raise ScanImportError(
            message or f"'{package}' could not be imported by {interpreter}"
        )
    detail = (
        message
        or proc.stderr.strip()[-_STDERR_TAIL_CHARS:]
        or "no error output"
    )
    raise ScanFailedError(
        f"scan of '{package}' failed (exit code {proc.returncode}, "
        f"interpreter: {interpreter}): {detail}"
    )

lcp.scanjson

Machine-mode scan entry point: python -m lcp.scanjson <package>.

Runs inside the scan interpreter — possibly a different venv from the MCP server — and speaks the public LCP document format on stdout, so there is no private IPC format to version. Errors are a single JSON object {"type": ..., "message": ...} on stderr plus a distinguishing exit code.

Exit codes

0: success — the LCP JSON document is on stdout. 3: import failure — the target package could not be imported. 4: scan failure — the package imported but scanning or generation failed, including SystemExit raised by import-time code.

Deliberately imports only the stdlib until arguments are parsed, and never imports click: the scan environment only needs lcp and pydantic importable (the MCP server appends its own paths to the child's sys.path for exactly that — see :mod:lcp.subprocess_scan).

main

main(argv: list[str] | None = None) -> None

Scan a package and write its LCP document to stdout.

Parameters:

Name Type Description Default
argv list[str] | None

Argument list (default: sys.argv[1:]): the package name plus optional --include-private / --no-recursive / --include-tests flags.

None
Source code in src/lcp/scanjson.py
def main(argv: list[str] | None = None) -> None:
    """Scan a package and write its LCP document to stdout.

    Args:
        argv: Argument list (default: ``sys.argv[1:]``): the package name plus
            optional ``--include-private`` / ``--no-recursive`` /
            ``--include-tests`` flags.
    """
    parser = argparse.ArgumentParser(
        prog="python -m lcp.scanjson",
        description="Scan an installed package and write its LCP document to stdout.",
    )
    parser.add_argument("package", help="Import path of the package to scan.")
    parser.add_argument("--include-private", action="store_true")
    parser.add_argument("--no-recursive", action="store_true")
    parser.add_argument("--include-tests", action="store_true")
    args = parser.parse_args(argv)

    # Import-time prints from the scanned package must not corrupt the
    # stdout protocol: park stdout on stderr while scanning, write the
    # document to the real stdout at the very end.
    real_stdout = sys.stdout
    sys.stdout = sys.stderr
    try:
        from .generator import generate_lcp
        from .scanner import scan_package

        try:
            scanned = scan_package(
                args.package,
                include_private=args.include_private,
                recursive=not args.no_recursive,
                include_tests=args.include_tests,
            )
        except ImportError as exc:
            _fail(3, "import_failure", str(exc))
        except (SystemExit, KeyboardInterrupt, Exception) as exc:
            # Import-time code can raise SystemExit (or anything else);
            # convert it into the exit-code contract instead of dying with it.
            _fail(
                4,
                "scan_failure",
                f"{type(exc).__name__} raised while importing "
                f"'{args.package}': {exc}",
            )
        try:
            payload = generate_lcp(scanned).to_json(indent=2)
        except Exception as exc:
            _fail(4, "scan_failure", f"{type(exc).__name__}: {exc}")
    finally:
        sys.stdout = real_stdout

    print(payload)