Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions connector_builder_mcp/_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ def populate_dotenv_missing_secrets_stubs(
dotenv_path: Annotated[
str,
Field(
description="Absolute path to the .env file to add secrets to, or privatebin URL to check. "
description="Absolute path to the .env file to add secrets to, or privatebin URL to check."
+ DOTENV_FILE_URI_DESCRIPTION.strip()
),
],
Expand All @@ -396,7 +396,11 @@ def populate_dotenv_missing_secrets_stubs(
"'credentials.password,oauth.client_secret'"
),
] = None,
allow_create: Annotated[bool, Field(description="Create the file if it doesn't exist")] = True,
*,
allow_create: Annotated[
bool,
Field(description="Create the file if it doesn't exist"),
] = True,
) -> str:
"""Add secret stubs to the specified dotenv file for the user to fill in, or check privatebin URLs.

Expand Down
66 changes: 64 additions & 2 deletions connector_builder_mcp/_util.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Utility functions for Builder MCP server."""

import json
import logging
import sys
from pathlib import Path
from typing import Any, cast
from typing import Any, cast, overload

import yaml

Expand Down Expand Up @@ -144,7 +145,68 @@ def validate_manifest_structure(manifest: dict[str, Any]) -> bool:
"""
required_fields = ["version", "type", "check"]
has_required = all(field in manifest for field in required_fields)

has_streams = "streams" in manifest or "dynamic_streams" in manifest

return has_required and has_streams


def as_bool(
val: bool | str | None, # noqa: FBT001
/,
default: bool = False, # noqa: FBT001, FBT002
) -> bool:
"""Convert a string, boolean, or None value to a boolean.

Args:
val: The value to convert.
default: The default boolean value to return if the value is None.

Returns:
The converted boolean value.
"""
if isinstance(val, bool):
return val

if isinstance(val, str):
return val.lower() == "true"

return default


# Overload signatures predict nullability of output
@overload
def as_dict(
val: dict[str, Any] | str | None,
default: dict[str, Any],
) -> dict[str, Any]: ...
@overload
def as_dict(
val: dict[str, Any] | str,
) -> dict[str, Any]: ...
@overload
def as_dict(
val: None,
) -> None: ...


def as_dict(
val: dict[str, Any] | str | None,
default: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Convert a dict, str, or None value to a dict.

If the value is a string, it will be assumed to be a JSON string.

Returns:
The converted dictionary value.
"""
if isinstance(val, dict):
return val

if val is None:
return default

if isinstance(val, str):
return cast("dict[str, Any]", json.loads(val))
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The json.loads() call can raise JSONDecodeError but this is not handled or documented. Consider wrapping in a try-catch block and raising a more descriptive error message that indicates the JSON parsing failed.

Copilot uses AI. Check for mistakes.


raise TypeError("Could not convert value to a dictionary.")
43 changes: 11 additions & 32 deletions connector_builder_mcp/validation_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

from connector_builder_mcp._secrets import hydrate_config
from connector_builder_mcp._util import (
as_bool,
as_dict,
filter_config_secrets,
parse_manifest_input,
validate_manifest_structure,
Expand Down Expand Up @@ -92,29 +94,6 @@ class MultiStreamSmokeTest(BaseModel):
stream_results: dict[str, StreamSmokeTest]


def _as_bool(
val: bool | str | None, # noqa: FBT001
/,
default: bool = False, # noqa: FBT001, FBT002
) -> bool:
"""Convert a string, boolean, or None value to a boolean.

Args:
val: The value to convert.
default: The default boolean value to return if the value is None.

Returns:
The converted boolean value.
"""
if isinstance(val, bool):
return val

if isinstance(val, str):
return val.lower() == "true"

return default


def _calculate_record_stats(
records_data: list[dict[str, Any]],
) -> dict[str, Any]:
Expand Down Expand Up @@ -319,7 +298,7 @@ def validate_manifest(
)


def execute_stream_test_read(
def execute_stream_test_read( # noqa: PLR0914
manifest: Annotated[
str,
Field(description="The connector manifest. Can be raw a YAML string or path to YAML file"),
Expand All @@ -329,8 +308,8 @@ def execute_stream_test_read(
Field(description="Name of the stream to test"),
],
config: Annotated[
dict[str, Any] | None,
Field(description="Connector configuration"),
dict[str, Any] | str | None,
Field(description="Connector configuration dictionary."),
] = None,
*,
max_records: Annotated[
Expand All @@ -354,7 +333,7 @@ def execute_stream_test_read(
),
] = None,
dotenv_file_uris: Annotated[
str | list[str] | None,
list[str] | str | None,
Field(
description="Optional paths/URLs to local .env files or Privatebin.net URLs for secret hydration. Can be a single string, comma-separated string, or list of strings. Privatebin secrets may be created at privatebin.net, and must: contain text formatted as a dotenv file, use a password sent via the `PRIVATEBIN_PASSWORD` env var, and not include password text in the URL."
),
Expand All @@ -367,20 +346,20 @@ def execute_stream_test_read(
We do not attempt to sanitize record data, as it is expected to be user-defined.
"""
success: bool = True
include_records_data = _as_bool(
include_records_data = as_bool(
include_records_data,
default=False,
)
include_record_stats = _as_bool(
include_record_stats = as_bool(
include_record_stats,
default=False,
)
include_raw_responses_data = _as_bool(
include_raw_responses_data = as_bool(
include_raw_responses_data,
default=False,
)
logger.info(f"Testing stream read for stream: {stream_name}")
config = config or {}
config = as_dict(config, default={})

manifest_dict, _ = parse_manifest_input(manifest)

Expand Down Expand Up @@ -455,7 +434,7 @@ def execute_stream_test_read(
records_data.extend(page.pop("records"))

raw_responses_data = None
if include_raw_responses_data is True and slices and isinstance(slices, list):
if include_raw_responses_data is True and slices:
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explicit is True comparison is redundant since include_raw_responses_data is already a boolean. The condition can be simplified to if include_raw_responses_data and slices:.

Suggested change
if include_raw_responses_data is True and slices:
if include_raw_responses_data and slices:

Copilot uses AI. Check for mistakes.

raw_responses_data = slices

record_stats = None
Expand Down
Loading