googleapis/mcp-toolbox · error

unable to read tool file at %q: %w

Error message

unable to read tool file at %q: %w

What it means

During `toolbox migrate`, each resolved config file is read with os.ReadFile before conversion. If the file cannot be read (missing, permission denied, is a directory), this error is recorded per-file, the file is skipped, and migration continues with remaining files (cmd/internal/migrate/command.go:71-78). All per-file errors are joined and returned at the end.

Source

Thrown at cmd/internal/migrate/command.go:74

	defer func() {
		_ = shutdown(ctx)
	}()

	logger := opts.Logger
	filePaths, _, err := opts.GetCustomConfigFiles(ctx)
	if err != nil {
		errMsg := fmt.Errorf("error retrieving configuration file: %w", err)
		logger.ErrorContext(ctx, errMsg.Error())
		return errMsg
	}

	logger.InfoContext(ctx, "migration process will start; any comments (except for top-level comments) presented in the original configuration files will not be preserved in the migrated files")
	var errs []error
	// process each files independently.
	for _, filePath := range filePaths {
		buf, err := os.ReadFile(filePath)
		if err != nil {
			errMsg := fmt.Errorf("unable to read tool file at %q: %w", filePath, err)
			logger.ErrorContext(ctx, errMsg.Error())
			errs = append(errs, errMsg)
			continue
		}
		newBuf, err := internal.ConvertConfig(ctx, buf)
		if err != nil {
			logger.ErrorContext(ctx, err.Error())
			errs = append(errs, err)
			continue
		}
		if cmp.Equal(buf, newBuf) {
			continue
		}

		if cmd.dryRun {
			logger.DebugContext(ctx, fmt.Sprintf("printing migration to output for file: %s", filePath))
			fmt.Fprintln(opts.IOStreams.Out, string(newBuf))
		} else {

View on GitHub (pinned to 8cc6e09de2)

Solutions

  1. Check the wrapped OS error after the message (no such file / permission denied) and fix accordingly.
  2. Confirm each --configs argument is an existing regular file (ls -l <path>), not a directory.
  3. Grant read permission: chmod u+r <file>, or run as a user with access.
  4. In containers, ensure the config file is mounted into the image/container at the expected path.
  5. Re-run migrate; the command continues with other files, so verify the specific file after fixing.

Example fix

// before
$ toolbox migrate --configs ./configs
toolbox: unable to read tool file at "configs": read configs: is a directory
// after
$ toolbox migrate --configs ./configs/tools.yaml
Defensive patterns

Strategy: validation

Validate before calling

// Pre-validate every config path is a readable regular file:
for _, p := range configPaths {
    info, err := os.Stat(p)
    if err != nil { return fmt.Errorf("cannot stat %q: %w", p, err) }
    if info.IsDir() { return fmt.Errorf("%q is a directory, expected a file", p) }
    if info.Mode().Perm()&0o400 == 0 { return fmt.Errorf("%q is not readable", p) }
}

Try / catch

// migrate already collects per-file errors; surface and handle them jointly:
if err := runMigrate(); err != nil {
    if strings.Contains(err.Error(), "unable to read tool file") {
        // inspect each wrapped per-file read error via errors.Join/As
    }
    return err
}

Prevention

When it happens

Trigger: A --configs path (or glob-expanded entry) that doesn't exist; a path that is a directory instead of a file; file present but the process lacks read permission; file deleted/renamed between resolution and read.

Common situations: Passing a directory to --configs expecting recursive behavior; symlink pointing to a removed file; running in a container where the config wasn't volume-mounted; restrictive file modes after a checkout or chmod.

Understand the failure class

Background: "failed to read file", EACCES, ENOENT and "could not read <path>" errors: when a program can't read a file from disk — this error's family across 49 libraries.

Related errors


AI-assisted analysis of googleapis/mcp-toolbox@8cc6e09de2 (2026-09-05). Data as JSON: /api/errors/7bf85eb377b63a8a. Report an issue: GitHub.