Skip to content

Integrations

Adapters that bridge Tulip to external frameworks, clouds, and vendor APIs.

FastMCP

Expose a Tulip agent (or any of its tools) as a Model Context Protocol server, and consume MCP tools from any compliant client as native Tulip Tools.

TulipMCPServer

Bases: BaseModel

Exposes a Tulip Agent as an MCP server.

This allows Tulip agents to be used by any MCP-compatible client.

Example

from tulip import Agent from tulip.integrations import TulipMCPServer

agent = Agent(model=model, tools=[...]) server = TulipMCPServer(agent=agent, name="my-agent") server.run() # Starts MCP server

run

run(transport: Literal['stdio', 'http', 'sse', 'streamable-http'] = 'stdio') -> None

Run the MCP server.

Parameters:

Name Type Description Default
transport Literal['stdio', 'http', 'sse', 'streamable-http']

Transport type ("stdio", "http", "sse", or "streamable-http").

'stdio'
Source code in .sdk/src/tulip/integrations/fastmcp.py
def run(self, transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio") -> None:
    """
    Run the MCP server.

    Args:
        transport: Transport type ("stdio", "http", "sse", or "streamable-http").
    """
    if self._mcp is None:
        self._mcp = self._create_mcp()

    self._mcp.run(transport=transport)

handle_request async

handle_request(request: dict[str, Any]) -> dict[str, Any]

Handle a single MCP request (for testing).

Source code in .sdk/src/tulip/integrations/fastmcp.py
async def handle_request(self, request: dict[str, Any]) -> dict[str, Any]:
    """Handle a single MCP request (for testing)."""
    if self._mcp is None:
        self._mcp = self._create_mcp()

    # Process based on method
    method = request.get("method", "")

    if method == "tools/list":
        tools = []
        if hasattr(self.agent, "_tool_registry"):
            self.agent._initialize()
            for tool_obj in self.agent._tool_registry.tools.values():
                tools.append(tulip_tool_to_mcp(tool_obj))
        return {"tools": tools}

    if method == "tools/call":
        params = request.get("params", {})
        tool_name = params.get("name", "")
        arguments = params.get("arguments", {})

        if tool_name == "run_agent":
            result = self.agent.run_sync(arguments.get("prompt", ""))
            return {"content": [{"type": "text", "text": result.message}]}

        # Find and execute the tool
        if hasattr(self.agent, "_tool_registry"):
            self.agent._initialize()
            tool_obj = self.agent._tool_registry.get(tool_name)
            if tool_obj:
                result = await tool_obj.execute(**arguments)
                text = result if isinstance(result, str) else json.dumps(result)
                return {"content": [{"type": "text", "text": text}]}

        return {"error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}

    return {"error": {"code": -32601, "message": f"Unknown method: {method}"}}

create_mcp_server

create_mcp_server(agent: Agent, name: str = 'tulip-agent', version: str = '1.0.0') -> TulipMCPServer

Create an MCP server from a Tulip Agent.

Parameters:

Name Type Description Default
agent Agent

Tulip Agent instance

required
name str

Server name

'tulip-agent'
version str

Server version

'1.0.0'

Returns:

Type Description
TulipMCPServer

TulipMCPServer instance

Example

server = create_mcp_server(agent, name="my-assistant") server.run()

Source code in .sdk/src/tulip/integrations/fastmcp.py
def create_mcp_server(
    agent: Agent,
    name: str = "tulip-agent",
    version: str = "1.0.0",
) -> TulipMCPServer:
    """
    Create an MCP server from a Tulip Agent.

    Args:
        agent: Tulip Agent instance
        name: Server name
        version: Server version

    Returns:
        TulipMCPServer instance

    Example:
        >>> server = create_mcp_server(agent, name="my-assistant")
        >>> server.run()
    """
    return TulipMCPServer(agent=agent, name=name, version=version)

mcp_tool_to_tulip

mcp_tool_to_tulip(name: str, description: str, func: Callable[..., Any], parameters: dict[str, Any] | None = None) -> Tool

Convert an MCP-style tool to a Tulip Tool.

When parameters is provided, the JSON Schema is used as-is to construct the Tool. This preserves the source tool's flat-field schema end-to-end so the LLM sees the original argument shape (e.g. {tenant_id, regex, limit}) instead of the generic {kwargs: …} shape that the @tool decorator would otherwise derive from the wrapper's **kwargs signature.

When parameters is omitted, falls back to the decorator-derived schema (parameter-less tool) for backward compatibility.

Parameters:

Name Type Description Default
name str

Tool name

required
description str

Tool description

required
func Callable[..., Any]

The async function to call

required
parameters dict[str, Any] | None

JSON Schema for parameters

None

Returns:

Type Description
Tool

Tulip Tool instance

Source code in .sdk/src/tulip/integrations/fastmcp.py
def mcp_tool_to_tulip(
    name: str,
    description: str,
    func: Callable[..., Any],
    parameters: dict[str, Any] | None = None,
) -> Tool:
    """
    Convert an MCP-style tool to a Tulip Tool.

    When ``parameters`` is provided, the JSON Schema is used **as-is** to
    construct the Tool. This preserves the source tool's flat-field
    schema end-to-end so the LLM sees the original argument shape
    (e.g. ``{tenant_id, regex, limit}``) instead of the generic
    ``{kwargs: …}`` shape that the ``@tool`` decorator would otherwise
    derive from the wrapper's ``**kwargs`` signature.

    When ``parameters`` is omitted, falls back to the decorator-derived
    schema (parameter-less tool) for backward compatibility.

    Args:
        name: Tool name
        description: Tool description
        func: The async function to call
        parameters: JSON Schema for parameters

    Returns:
        Tulip Tool instance
    """

    async def _invoke(**kwargs: Any) -> str:
        result = await func(**kwargs)
        if isinstance(result, str):
            return result
        return json.dumps(result)

    if parameters is not None:
        # Direct construction: keep the source MCP server's
        # inputSchema as the Tool's parameters dict. The Tool's
        # execute path forwards ``**kwargs`` to ``_invoke`` which
        # forwards them to the original ``func``, so the LLM's tool
        # call args land flat at the server.
        return Tool(
            name=name,
            description=description,
            parameters=parameters,
            fn=_invoke,
            idempotent=False,
        )

    # Fallback: derive the schema from the wrapper signature.
    # No-args tools work; tools that need typed args should pass
    # ``parameters=`` explicitly.
    @tool(name=name, description=description)
    async def wrapper(**kwargs: Any) -> str:
        return await _invoke(**kwargs)

    return wrapper

AWS cloud-posture (read-only)

Two generic, spec-driven tools driven by botocore's service models — the agent discovers the shape of AWS from the spec and runs read-only operations whose responses become grounded-finding evidence. Read-only by construction: use_aws refuses any non-read operation before a call is made. See the cloud-posture agent for the full workflow.

Install the extra: pip install 'tulip-agents[aws]'.

describe_aws

describe_aws(service: str | None = None, operation: str | None = None, region: str | None = None) -> dict[str, Any]

Introspect the AWS API spec (botocore service models).

  • service is None{"services": [...]} (all services).
  • service only → that service's read-only operations.
  • service + operation → the operation's parameter shape.
Source code in .sdk/src/tulip/security/aws.py
def describe_aws(
    service: str | None = None,
    operation: str | None = None,
    region: str | None = None,
) -> dict[str, Any]:
    """Introspect the AWS API spec (botocore service models).

    - ``service is None`` → ``{"services": [...]}`` (all services).
    - ``service`` only → that service's read-only operations.
    - ``service`` + ``operation`` → the operation's parameter shape.
    """
    if service is None:
        return {"services": aws_services(region)}

    client = _session(region).client(service)
    model = client.meta.service_model
    if operation is None:
        ops = sorted(o for o in model.operation_names if is_readonly_operation(o))
        return {"service": service, "readonly_operations": ops, "count": len(ops)}

    op_model = model.operation_model(operation)
    inp = op_model.input_shape
    params: dict[str, Any] = {}
    if inp is not None:
        required = set(getattr(inp, "required_members", []) or [])
        for name, shape in inp.members.items():
            params[name] = {"type": shape.type_name, "required": name in required}
    return {
        "service": service,
        "operation": operation,
        "readonly": is_readonly_operation(operation),
        "parameters": params,
    }

use_aws

use_aws(service: str, operation: str, parameters: dict[str, Any] | None = None, region: str | None = None) -> dict[str, Any]

Execute a read-only AWS operation and return the raw response.

Refuses any operation that is not a read verb (see :data:READONLY_PREFIXES) before making a call. The returned response is the evidence a grounded finding cites.

Source code in .sdk/src/tulip/security/aws.py
def use_aws(
    service: str,
    operation: str,
    parameters: dict[str, Any] | None = None,
    region: str | None = None,
) -> dict[str, Any]:
    """Execute a **read-only** AWS operation and return the raw response.

    Refuses any operation that is not a read verb (see
    :data:`READONLY_PREFIXES`) before making a call. The returned response
    is the evidence a grounded finding cites.
    """
    if not is_readonly_operation(operation):
        msg = (
            f"refused: {service}:{operation} is not a read-only operation. "
            "use_aws only runs describe/list/get-style calls."
        )
        raise PermissionError(msg)

    from botocore import xform_name  # noqa: PLC0415 — lazy, boto3 optional

    client = _session(region).client(service)
    method = getattr(client, xform_name(operation))
    response = cast("dict[str, Any]", method(**(parameters or {})))
    response.pop("ResponseMetadata", None)
    return response

is_readonly_operation

is_readonly_operation(operation: str) -> bool

Whether operation (a botocore PascalCase op name) only reads.

Source code in .sdk/src/tulip/security/aws.py
def is_readonly_operation(operation: str) -> bool:
    """Whether ``operation`` (a botocore PascalCase op name) only reads."""
    return operation.startswith(READONLY_PREFIXES)

aws_services

aws_services(region: str | None = None) -> list[str]

Every AWS service available in the spec — the shape of AWS.

Source code in .sdk/src/tulip/security/aws.py
def aws_services(region: str | None = None) -> list[str]:
    """Every AWS service available in the spec — the *shape* of AWS."""
    return list(_session(region).get_available_services())

The agent-facing @tool wrappers (describe_aws_tool, use_aws_tool) and the create_soc_analyst factory compose these into a grounded posture agent.

Vendor integration examples

The notebooks ship worked vendor integrations in examples/integrations/. Each is an ordinary Tulip @tool following one convention — bring your own credentials: read the vendor key from the environment and call the live API when it's set, otherwise return a deterministic offline sample so the example runs with no account. The return shape is identical either way, so the agent's reasoning doesn't change between the offline demo and a live deployment.

Tool Vendor shape Credential
enrich_indicator VirusTotal / GreyNoise IOC reputation VT_API_KEY
query_siem Splunk / Elastic log + alert search SIEM_URL, SIEM_TOKEN
dispatch_timing_probe RunPod / Lambda inference-fingerprint probe RUNPOD_API_KEY, LAMBDA_API_KEY
measure_endpoint_timing Streaming time-to-first-token / cadence probe none (uses any reachable endpoint)

Hand these to a triage agent end-to-end in live vendor integrations; the GPU probe grounds into a fingerprint finding in specialist agents. The bring-your-own-credentials contract is documented in examples/integrations/README.md.