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
14 changes: 11 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,30 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10"]
include:
- python-version: "3.9"
sync-extras: "--all-extras --no-extra mcp"
- python-version: "3.10"
sync-extras: "--all-extras"
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Install uv
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}
enable-cache: true

- name: Install dependencies
run: uv sync --all-extras
run: uv sync ${{ matrix.sync-extras }}

- name: Run Ruff
uses: astral-sh/ruff-action@0c50076f12c38c3d0115b7b519b54a91cb9cf0ad # v3.5.0
with:
args: check .

- name: Run Mypy
run: uv run mypy stackone_ai
run: uv run mypy stackone_ai --exclude stackone_ai/server.py
14 changes: 12 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.13"]
include:
- python-version: "3.9"
test-extras: "--all-extras --no-extra mcp"
- python-version: "3.10"
test-extras: "--all-extras"
- python-version: "3.13"
test-extras: "--all-extras"
env:
STACKONE_API_KEY: ${{ secrets.STACKONE_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Expand All @@ -17,11 +27,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}
enable-cache: true

- name: Install dependencies
run: uv sync --all-extras
run: uv sync ${{ matrix.test-extras }}

- name: Run tests
run: uv run pytest
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,32 @@ StackOne AI provides a unified interface for accessing various SaaS tools throug
- CrewAI Tools
- LangGraph Tool Node

## Requirements

- Python 3.9+ (core SDK functionality)
- Python 3.10+ (for MCP server and CrewAI examples)

## Installation

### Basic Installation

```bash
pip install stackone-ai
```

### Optional Features

```bash
# Install with MCP server support (requires Python 3.10+)
pip install 'stackone-ai[mcp]'

# Install with CrewAI examples (requires Python 3.10+)
pip install 'stackone-ai[examples]'

# Install everything
pip install 'stackone-ai[mcp,examples]'
```

## Quick Start

```python
Expand Down Expand Up @@ -82,10 +102,12 @@ for tool_call in response.tool_calls:
</details>

<details>
<summary>CrewAI Integration</summary>
<summary>CrewAI Integration (Python 3.10+)</summary>

CrewAI uses LangChain tools natively, making integration seamless:

> **Note**: CrewAI requires Python 3.10+. Install with `pip install 'stackone-ai[examples]'`

```python
from crewai import Agent, Crew, Task
from stackone_ai import StackOneToolSet
Expand Down
19 changes: 14 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "stackone-ai"
version = "0.3.1"
description = "agents performing actions on your SaaS"
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.9"
authors = [
{ name = "StackOne", email = "[email protected]" }
]
Expand All @@ -12,16 +12,21 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"pydantic>=2.10.6",
"requests>=2.32.3",
"langchain-core>=0.1.0",
"mcp[cli]>=1.3.0",
"bm25s>=0.2.2",
"numpy>=1.24.0",
"typing-extensions>=4.0.0",
"eval-type-backport; python_version<'3.10'", # TODO: Remove when Python 3.9 support is dropped
]

[project.scripts]
Expand All @@ -41,8 +46,12 @@ packages = ["stackone_ai"]
"py.typed" = "py.typed"

[project.optional-dependencies]
# TODO: Remove python_version conditions when Python 3.9 support is dropped
mcp = [
"mcp[cli]>=1.3.0; python_version>='3.10'",
]
examples = [
"crewai>=0.102.0",
"crewai>=0.102.0; python_version>='3.10'",
"langchain-openai>=0.3.6",
"openai>=1.63.2",
"python-dotenv>=1.0.1",
Expand Down Expand Up @@ -82,7 +91,7 @@ markers = [

[tool.ruff]
line-length = 110
target-version = "py311"
target-version = "py39"

[tool.ruff.lint]
select = [
Expand All @@ -96,7 +105,7 @@ select = [
]

[tool.mypy]
python_version = "3.11"
python_version = "3.9"
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
Expand Down
3 changes: 2 additions & 1 deletion stackone_ai/meta_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ def search(self, query: str, limit: int = 5, min_score: float = 0.0) -> list[Met

# Process results
search_results = []
for idx, score in zip(results[0], scores[0], strict=False):
# TODO: Add strict=False when Python 3.9 support is dropped
for idx, score in zip(results[0], scores[0]):
if score < min_score:
continue

Expand Down
53 changes: 31 additions & 22 deletions stackone_ai/models.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
# TODO: Remove when Python 3.9 support is dropped
from __future__ import annotations

import asyncio
import base64
import json
from collections.abc import Sequence
from enum import Enum
from functools import partial
from typing import Annotated, Any, TypeAlias, cast
from typing import Annotated, Any, cast
from urllib.parse import quote

import requests
from langchain_core.tools import BaseTool
from pydantic import BaseModel, BeforeValidator, Field, PrivateAttr
from requests.exceptions import RequestException

# TODO: Remove when Python 3.9 support is dropped
from typing_extensions import TypeAlias

# Type aliases for common types
JsonDict: TypeAlias = dict[str, Any]
Headers: TypeAlias = dict[str, str]
Expand Down Expand Up @@ -140,21 +147,24 @@ def _prepare_request_params(self, kwargs: JsonDict) -> tuple[str, JsonDict, Json
for key, value in kwargs.items():
param_location = self._execute_config.parameter_locations.get(key)

match param_location:
case ParameterLocation.PATH:
url = url.replace(f"{{{key}}}", str(value))
case ParameterLocation.QUERY:
if param_location == ParameterLocation.PATH:
# Safely encode path parameters to prevent SSRF attacks
encoded_value = quote(str(value), safe="")
url = url.replace(f"{{{key}}}", encoded_value)
elif param_location == ParameterLocation.QUERY:
query_params[key] = value
elif param_location in (ParameterLocation.BODY, ParameterLocation.FILE):
body_params[key] = value
else:
# Default behavior
if f"{{{key}}}" in url:
# Safely encode path parameters to prevent SSRF attacks
encoded_value = quote(str(value), safe="")
url = url.replace(f"{{{key}}}", encoded_value)
elif self._execute_config.method in {"GET", "DELETE"}:
query_params[key] = value
case ParameterLocation.BODY | ParameterLocation.FILE:
else:
body_params[key] = value
case _:
# Default behavior
if f"{{{key}}}" in url:
url = url.replace(f"{{{key}}}", str(value))
elif self._execute_config.method in {"GET", "DELETE"}:
query_params[key] = value
else:
body_params[key] = value

return url, body_params, query_params

Expand Down Expand Up @@ -355,13 +365,12 @@ def to_langchain(self) -> BaseTool:
python_type: type = str # Default to str
if isinstance(details, dict):
type_str = details.get("type", "string")
match type_str:
Copy link
Member Author

Choose a reason for hiding this comment

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

not supported in 3.9

case "number":
python_type = float
case "integer":
python_type = int
case "boolean":
python_type = bool
if type_str == "number":
python_type = float
elif type_str == "integer":
python_type = int
elif type_str == "boolean":
python_type = bool

field = Field(description=details.get("description", ""))
else:
Expand Down Expand Up @@ -480,7 +489,7 @@ def to_langchain(self) -> Sequence[BaseTool]:
"""
return [tool.to_langchain() for tool in self.tools]

def meta_tools(self) -> "Tools":
def meta_tools(self) -> Tools:
"""Return meta tools for tool discovery and execution

Meta tools enable dynamic tool discovery and execution based on natural language queries.
Expand Down
31 changes: 23 additions & 8 deletions stackone_ai/server.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
# TODO: Remove when Python 3.9 support is dropped
from __future__ import annotations

import argparse
import asyncio
import logging
import os
import sys
from typing import Any, TypeVar

import mcp.types as types
from mcp.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.shared.exceptions import McpError
from mcp.types import EmbeddedResource, ErrorData, ImageContent, TextContent, Tool
# Check Python version for MCP server functionality
if sys.version_info < (3, 10):
raise RuntimeError(
"MCP server functionality requires Python 3.10+. Current version: {}.{}.{}".format(
*sys.version_info[:3]
)
)

try: # type: ignore[unreachable]
import mcp.types as types
from mcp.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.shared.exceptions import McpError
from mcp.types import EmbeddedResource, ErrorData, ImageContent, TextContent, Tool
except ImportError as e:
raise ImportError("MCP dependencies not found. Install with: pip install 'stackone-ai[mcp]'") from e

from pydantic import ValidationError

from stackone_ai import StackOneToolSet
Expand Down Expand Up @@ -41,7 +56,7 @@ def tool_needs_account_id(tool_name: str) -> bool:
return True


@app.list_tools() # type: ignore[misc]
@app.list_tools()
async def list_tools() -> list[Tool]:
"""List all available StackOne tools as MCP tools."""
if not toolset:
Expand Down Expand Up @@ -99,7 +114,7 @@ async def list_tools() -> list[Tool]:
) from e


@app.call_tool() # type: ignore[misc]
@app.call_tool()
async def call_tool(
name: str, arguments: dict[str, Any]
) -> list[TextContent | ImageContent | EmbeddedResource]:
Expand Down
5 changes: 4 additions & 1 deletion stackone_ai/specs/parser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# TODO: Remove when Python 3.9 support is dropped
from __future__ import annotations

import json
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -73,7 +76,7 @@ def _resolve_schema(
visited = set()

# Handle primitive types (str, int, etc)
if not isinstance(schema, dict | list):
if not isinstance(schema, (dict, list)):
return schema

if isinstance(schema, list):
Expand Down
3 changes: 3 additions & 0 deletions stackone_ai/toolset.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# TODO: Remove when Python 3.9 support is dropped
from __future__ import annotations

import fnmatch
import os
import warnings
Expand Down
Loading