Skip to content

Commit 1a37776

Browse files
authored
feat: support Python 3.9+ with optional MCP server support (#28)
* fix: add type ignore for unreachable code in MCP server This is needed because mypy sees the Python version check as always false when running on Python 3.10+, making the subsequent code unreachable. The code is actually reachable at runtime when Python 3.10+ is used. * docs: update README with Python version requirements - Add Requirements section with Python 3.9+ for core SDK - Add installation options for optional features - Document MCP server requiring Python 3.10+ - Add note about CrewAI requiring Python 3.10+ * refactor: rename [server] to [mcp] for clarity - Rename optional dependency group from 'server' to 'mcp' for better clarity - Update CI workflows to use --without mcp instead of --without server - Update README installation instructions - Update error message in MCP server module * fix: correct uv sync syntax in CI workflows Use --all-extras --no-extra mcp instead of --without-group mcp for excluding optional dependencies in uv sync command. * fix: add eval-type-backport for Python 3.9 Pydantic compatibility - Add eval-type-backport dependency for Python <3.10 to handle union types - Fix mypy exclusion syntax for server.py when MCP is not available * docs: add TODO comments for Python 3.9 compatibility code - Add TODO for zip strict parameter - Add TODO for eval-type-backport dependency * fix: use Python 3.11 for docs build to support MCP examples The build_docs.py script requires Python 3.11+ and processes MCP examples, so we need Python 3.11 with all extras for proper documentation generation. * docs: quote extras in pip install commands to prevent shell globbing - Add quotes around extras in all pip install examples - Prevents 'no matches found' errors in zsh and similar shells - Affects both installation section and inline examples * security: fix SSRF vulnerability in URL path parameter replacement - Add urllib.parse.quote to safely encode path parameters - Use safe='' to encode all special characters including '/', ':', '@' - Prevents attackers from injecting malicious hosts or internal service paths - Applies to both explicit PATH parameters and default path replacement logic * ci: add Python 3.11 support to lint workflow - Add matrix strategy to test both Python 3.9 and 3.11 - Configure appropriate dependency installation for each version - Python 3.9: excludes MCP extras (not supported) - Python 3.11: includes all extras for full coverage - Aligns with existing test workflow pattern for consistency * ci: update lint workflow to test Python 3.9 and 3.10 - Change from 3.9/3.11 matrix to 3.9/3.10 for better coverage - Maintains appropriate dependency configurations for each version * ci: fix versions
1 parent 7ae344f commit 1a37776

File tree

10 files changed

+1488
-290
lines changed

10 files changed

+1488
-290
lines changed

.github/workflows/lint.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,30 @@ on:
88
jobs:
99
lint:
1010
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
python-version: ["3.9", "3.10"]
14+
include:
15+
- python-version: "3.9"
16+
sync-extras: "--all-extras --no-extra mcp"
17+
- python-version: "3.10"
18+
sync-extras: "--all-extras"
1119
steps:
1220
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
1321

1422
- name: Install uv
1523
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
1624
with:
17-
python-version: "3.11"
25+
python-version: ${{ matrix.python-version }}
1826
enable-cache: true
1927

2028
- name: Install dependencies
21-
run: uv sync --all-extras
29+
run: uv sync ${{ matrix.sync-extras }}
2230

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

2836
- name: Run Mypy
29-
run: uv run mypy stackone_ai
37+
run: uv run mypy stackone_ai --exclude stackone_ai/server.py

.github/workflows/test.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ on:
88
jobs:
99
test:
1010
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
python-version: ["3.9", "3.10", "3.13"]
14+
include:
15+
- python-version: "3.9"
16+
test-extras: "--all-extras --no-extra mcp"
17+
- python-version: "3.10"
18+
test-extras: "--all-extras"
19+
- python-version: "3.13"
20+
test-extras: "--all-extras"
1121
env:
1222
STACKONE_API_KEY: ${{ secrets.STACKONE_API_KEY }}
1323
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -17,11 +27,11 @@ jobs:
1727
- name: Install uv
1828
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
1929
with:
20-
python-version: "3.11"
30+
python-version: ${{ matrix.python-version }}
2131
enable-cache: true
2232

2333
- name: Install dependencies
24-
run: uv sync --all-extras
34+
run: uv sync ${{ matrix.test-extras }}
2535

2636
- name: Run tests
2737
run: uv run pytest

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,32 @@ StackOne AI provides a unified interface for accessing various SaaS tools throug
1818
- CrewAI Tools
1919
- LangGraph Tool Node
2020

21+
## Requirements
22+
23+
- Python 3.9+ (core SDK functionality)
24+
- Python 3.10+ (for MCP server and CrewAI examples)
25+
2126
## Installation
2227

28+
### Basic Installation
29+
2330
```bash
2431
pip install stackone-ai
2532
```
2633

34+
### Optional Features
35+
36+
```bash
37+
# Install with MCP server support (requires Python 3.10+)
38+
pip install 'stackone-ai[mcp]'
39+
40+
# Install with CrewAI examples (requires Python 3.10+)
41+
pip install 'stackone-ai[examples]'
42+
43+
# Install everything
44+
pip install 'stackone-ai[mcp,examples]'
45+
```
46+
2747
## Quick Start
2848

2949
```python
@@ -82,10 +102,12 @@ for tool_call in response.tool_calls:
82102
</details>
83103

84104
<details>
85-
<summary>CrewAI Integration</summary>
105+
<summary>CrewAI Integration (Python 3.10+)</summary>
86106

87107
CrewAI uses LangChain tools natively, making integration seamless:
88108

109+
> **Note**: CrewAI requires Python 3.10+. Install with `pip install 'stackone-ai[examples]'`
110+
89111
```python
90112
from crewai import Agent, Crew, Task
91113
from stackone_ai import StackOneToolSet

pyproject.toml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "stackone-ai"
33
version = "0.3.1"
44
description = "agents performing actions on your SaaS"
55
readme = "README.md"
6-
requires-python = ">=3.11"
6+
requires-python = ">=3.9"
77
authors = [
88
{ name = "StackOne", email = "[email protected]" }
99
]
@@ -12,16 +12,21 @@ classifiers = [
1212
"Intended Audience :: Developers",
1313
"License :: OSI Approved :: MIT License",
1414
"Programming Language :: Python :: 3",
15+
"Programming Language :: Python :: 3.9",
16+
"Programming Language :: Python :: 3.10",
1517
"Programming Language :: Python :: 3.11",
18+
"Programming Language :: Python :: 3.12",
19+
"Programming Language :: Python :: 3.13",
1620
"Topic :: Software Development :: Libraries :: Python Modules",
1721
]
1822
dependencies = [
1923
"pydantic>=2.10.6",
2024
"requests>=2.32.3",
2125
"langchain-core>=0.1.0",
22-
"mcp[cli]>=1.3.0",
2326
"bm25s>=0.2.2",
2427
"numpy>=1.24.0",
28+
"typing-extensions>=4.0.0",
29+
"eval-type-backport; python_version<'3.10'", # TODO: Remove when Python 3.9 support is dropped
2530
]
2631

2732
[project.scripts]
@@ -41,8 +46,12 @@ packages = ["stackone_ai"]
4146
"py.typed" = "py.typed"
4247

4348
[project.optional-dependencies]
49+
# TODO: Remove python_version conditions when Python 3.9 support is dropped
50+
mcp = [
51+
"mcp[cli]>=1.3.0; python_version>='3.10'",
52+
]
4453
examples = [
45-
"crewai>=0.102.0",
54+
"crewai>=0.102.0; python_version>='3.10'",
4655
"langchain-openai>=0.3.6",
4756
"openai>=1.63.2",
4857
"python-dotenv>=1.0.1",
@@ -82,7 +91,7 @@ markers = [
8291

8392
[tool.ruff]
8493
line-length = 110
85-
target-version = "py311"
94+
target-version = "py39"
8695

8796
[tool.ruff.lint]
8897
select = [
@@ -96,7 +105,7 @@ select = [
96105
]
97106

98107
[tool.mypy]
99-
python_version = "3.11"
108+
python_version = "3.9"
100109
disallow_untyped_defs = true
101110
disallow_incomplete_defs = true
102111
check_untyped_defs = true

stackone_ai/meta_tools.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ def search(self, query: str, limit: int = 5, min_score: float = 0.0) -> list[Met
8282

8383
# Process results
8484
search_results = []
85-
for idx, score in zip(results[0], scores[0], strict=False):
85+
# TODO: Add strict=False when Python 3.9 support is dropped
86+
for idx, score in zip(results[0], scores[0]):
8687
if score < min_score:
8788
continue
8889

stackone_ai/models.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1+
# TODO: Remove when Python 3.9 support is dropped
2+
from __future__ import annotations
3+
14
import asyncio
25
import base64
36
import json
47
from collections.abc import Sequence
58
from enum import Enum
69
from functools import partial
7-
from typing import Annotated, Any, TypeAlias, cast
10+
from typing import Annotated, Any, cast
11+
from urllib.parse import quote
812

913
import requests
1014
from langchain_core.tools import BaseTool
1115
from pydantic import BaseModel, BeforeValidator, Field, PrivateAttr
1216
from requests.exceptions import RequestException
1317

18+
# TODO: Remove when Python 3.9 support is dropped
19+
from typing_extensions import TypeAlias
20+
1421
# Type aliases for common types
1522
JsonDict: TypeAlias = dict[str, Any]
1623
Headers: TypeAlias = dict[str, str]
@@ -140,21 +147,24 @@ def _prepare_request_params(self, kwargs: JsonDict) -> tuple[str, JsonDict, Json
140147
for key, value in kwargs.items():
141148
param_location = self._execute_config.parameter_locations.get(key)
142149

143-
match param_location:
144-
case ParameterLocation.PATH:
145-
url = url.replace(f"{{{key}}}", str(value))
146-
case ParameterLocation.QUERY:
150+
if param_location == ParameterLocation.PATH:
151+
# Safely encode path parameters to prevent SSRF attacks
152+
encoded_value = quote(str(value), safe="")
153+
url = url.replace(f"{{{key}}}", encoded_value)
154+
elif param_location == ParameterLocation.QUERY:
155+
query_params[key] = value
156+
elif param_location in (ParameterLocation.BODY, ParameterLocation.FILE):
157+
body_params[key] = value
158+
else:
159+
# Default behavior
160+
if f"{{{key}}}" in url:
161+
# Safely encode path parameters to prevent SSRF attacks
162+
encoded_value = quote(str(value), safe="")
163+
url = url.replace(f"{{{key}}}", encoded_value)
164+
elif self._execute_config.method in {"GET", "DELETE"}:
147165
query_params[key] = value
148-
case ParameterLocation.BODY | ParameterLocation.FILE:
166+
else:
149167
body_params[key] = value
150-
case _:
151-
# Default behavior
152-
if f"{{{key}}}" in url:
153-
url = url.replace(f"{{{key}}}", str(value))
154-
elif self._execute_config.method in {"GET", "DELETE"}:
155-
query_params[key] = value
156-
else:
157-
body_params[key] = value
158168

159169
return url, body_params, query_params
160170

@@ -355,13 +365,12 @@ def to_langchain(self) -> BaseTool:
355365
python_type: type = str # Default to str
356366
if isinstance(details, dict):
357367
type_str = details.get("type", "string")
358-
match type_str:
359-
case "number":
360-
python_type = float
361-
case "integer":
362-
python_type = int
363-
case "boolean":
364-
python_type = bool
368+
if type_str == "number":
369+
python_type = float
370+
elif type_str == "integer":
371+
python_type = int
372+
elif type_str == "boolean":
373+
python_type = bool
365374

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

483-
def meta_tools(self) -> "Tools":
492+
def meta_tools(self) -> Tools:
484493
"""Return meta tools for tool discovery and execution
485494
486495
Meta tools enable dynamic tool discovery and execution based on natural language queries.

stackone_ai/server.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
1+
# TODO: Remove when Python 3.9 support is dropped
2+
from __future__ import annotations
3+
14
import argparse
25
import asyncio
36
import logging
47
import os
58
import sys
69
from typing import Any, TypeVar
710

8-
import mcp.types as types
9-
from mcp.server import NotificationOptions, Server
10-
from mcp.server.models import InitializationOptions
11-
from mcp.server.stdio import stdio_server
12-
from mcp.shared.exceptions import McpError
13-
from mcp.types import EmbeddedResource, ErrorData, ImageContent, TextContent, Tool
11+
# Check Python version for MCP server functionality
12+
if sys.version_info < (3, 10):
13+
raise RuntimeError(
14+
"MCP server functionality requires Python 3.10+. Current version: {}.{}.{}".format(
15+
*sys.version_info[:3]
16+
)
17+
)
18+
19+
try: # type: ignore[unreachable]
20+
import mcp.types as types
21+
from mcp.server import NotificationOptions, Server
22+
from mcp.server.models import InitializationOptions
23+
from mcp.server.stdio import stdio_server
24+
from mcp.shared.exceptions import McpError
25+
from mcp.types import EmbeddedResource, ErrorData, ImageContent, TextContent, Tool
26+
except ImportError as e:
27+
raise ImportError("MCP dependencies not found. Install with: pip install 'stackone-ai[mcp]'") from e
28+
1429
from pydantic import ValidationError
1530

1631
from stackone_ai import StackOneToolSet
@@ -41,7 +56,7 @@ def tool_needs_account_id(tool_name: str) -> bool:
4156
return True
4257

4358

44-
@app.list_tools() # type: ignore[misc]
59+
@app.list_tools()
4560
async def list_tools() -> list[Tool]:
4661
"""List all available StackOne tools as MCP tools."""
4762
if not toolset:
@@ -99,7 +114,7 @@ async def list_tools() -> list[Tool]:
99114
) from e
100115

101116

102-
@app.call_tool() # type: ignore[misc]
117+
@app.call_tool()
103118
async def call_tool(
104119
name: str, arguments: dict[str, Any]
105120
) -> list[TextContent | ImageContent | EmbeddedResource]:

stackone_ai/specs/parser.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# TODO: Remove when Python 3.9 support is dropped
2+
from __future__ import annotations
3+
14
import json
25
from pathlib import Path
36
from typing import Any
@@ -73,7 +76,7 @@ def _resolve_schema(
7376
visited = set()
7477

7578
# Handle primitive types (str, int, etc)
76-
if not isinstance(schema, dict | list):
79+
if not isinstance(schema, (dict, list)):
7780
return schema
7881

7982
if isinstance(schema, list):

stackone_ai/toolset.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# TODO: Remove when Python 3.9 support is dropped
2+
from __future__ import annotations
3+
14
import fnmatch
25
import os
36
import warnings

0 commit comments

Comments
 (0)