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
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.11
3.10
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file.

*NOTE:* Version 0.X.X might have breaking changes in bumps of the minor version number. This is because the project is still in early development and the API is not yet stable. It will still be marked clearly in the release notes.

## [0.3.0] - 09-09-2025
- Added toolit `create-vscode-tasks-json` CLI command to create the `.vscode/tasks.json` file from the command line.
- Improved error handling and user feedback when the `devtools` folder does not exist.

## [0.2.0] - 16-06-2025
- Fix problem with subdependencies not being installed
- Add support for python 3.9
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Toolit
MCP Server and Typer CLI in one, provides an easy way to configure your own DevTools in a project.
Model Context Protocol (MCP) Server, Typer CLI and Visual Studio Code tasks in one, provides an easy way to configure your own DevTools in a project.

## Installation
To get started with Toolit, install the package via pip:
Expand All @@ -19,6 +19,7 @@ Note: MCP support is not available on python 3.9, since it is not supported by t
Add a folder called `devtools` to your project root. Create python modules, you decide the name, in this folder. Add the tool decorator to functions you want to expose as commands.

```python
# devtools/my_commands.py
from toolit import tool
@tool
def my_command(to_print: str = "Hello, World!") -> None:
Expand All @@ -40,7 +41,7 @@ You can automatically create a `tasks.json` file for Visual Studio Code to run y

To create the `.vscode/tasks.json` file, run the following command in your terminal:
```bash
python -m toolit.create_tasks_json
toolit create-vscode-tasks-json
```
NOTE: THIS WILL OVERWRITE YOUR EXISTING `.vscode/tasks.json` FILE IF IT EXISTS!

Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "toolit"
version = "0.2.0"
version = "0.3.0"
description = "MCP Server, Typer CLI and vscode tasks in one, provides an easy way to configure your own DevTools and python scripts in a project."
readme = "README.md"
requires-python = ">=3.9" # mcp[cli] requires Python 3.10+
Expand All @@ -25,7 +25,7 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
]
keywords = ["mcp", "cli", "devtools", "typer", "vscode", "scripts", "python", "toolkit", "tools"]
keywords = ["mcp", "cli", "devtools", "typer", "vscode", "scripts", "python", "toolkit", "tools", "development", "development tools"]
authors = [
{name = "Martin Møldrup"},
]
Expand All @@ -42,7 +42,9 @@ dev = [
"mypy>=1.16.0",
"pytest>=8.3.5",
"pytest-asyncio>=0.26.0",
"ruff>=0.11.13"
"requests>=2.32.4",
"ruff>=0.11.13",
"toml>=0.10.2",
]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions scripts/get_changelog.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Python script for getting the change log entry from the CHANGELOG.md file."""

import pathlib
import argparse

Expand Down
60 changes: 46 additions & 14 deletions toolit/auto_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,70 @@

import os
import sys
import typer
import inspect
import pathlib
import importlib
from toolit.constants import MARKER_TOOL, ToolitTypesEnum
from toolit.create_apps_and_register import register_command
from types import FunctionType, ModuleType
from typing import Callable


def load_tools_from_folder(folder_path: pathlib.Path) -> list[FunctionType]:
"""
Load all tools from a given folder and register them as commands.

Folder is relative to the project's working directory.
"""
# If folder_path is relative, compute its absolute path using the current working directory.
def get_items_from_folder(
folder_path: pathlib.Path,
strategy: Callable[[ModuleType], list[FunctionType]],
) -> list[FunctionType]:
"""Get items from a given folder using a strategy function."""
if not folder_path.is_absolute():
folder_path = pathlib.Path.cwd() / folder_path

tools: list[FunctionType] = []
tool_groups: list[FunctionType] = []
items: list[FunctionType] = []
project_root: str = str(pathlib.Path.cwd())
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Iterate over each .py file in the folder
for file in folder_path.iterdir():
if not (file.is_file() and file.suffix == ".py" and not file.name.startswith("__")):
continue
module = import_module(file)
tools_for_file: list[FunctionType] = load_tools_from_file(module, ToolitTypesEnum.TOOL)
tools.extend(tools_for_file)
tool_groups.extend(load_tools_from_file(module, ToolitTypesEnum.SEQUENTIAL_GROUP))
tool_groups.extend(load_tools_from_file(module, ToolitTypesEnum.PARALLEL_GROUP))
items.extend(strategy(module))
return items


def tool_strategy(module: ModuleType) -> list[FunctionType]:
"""Strategy to get tools from a module."""
return load_tools_from_file(module, ToolitTypesEnum.TOOL)


def tool_group_strategy(module: ModuleType) -> list[FunctionType]:
"""Strategy to get tool groups from a module."""
groups: list[FunctionType] = []
groups.extend(load_tools_from_file(module, ToolitTypesEnum.SEQUENTIAL_GROUP))
groups.extend(load_tools_from_file(module, ToolitTypesEnum.PARALLEL_GROUP))
return groups


def load_tools_from_folder(folder_path: pathlib.Path) -> list[FunctionType]:
"""
Load all tools from a given folder and register them as commands.

Folder is relative to the project's working directory.
"""
if not folder_path.exists() or not folder_path.is_dir():
msg = (
"No tools loaded.\n"
"The folder selected for devtools does not exist or is not a directory.\n"
f"{folder_path.absolute().as_posix()}\n"
"Please create it and add your tools there."
)
typer.secho(f"\n{'=' * 60}\nERROR: {msg}\n{'=' * 60}\n", fg=typer.colors.RED, bold=True)
return []
# If folder_path is relative, compute its absolute path using the current working directory.
if not folder_path.is_absolute():
folder_path = pathlib.Path.cwd() / folder_path

tools: list[FunctionType] = get_items_from_folder(folder_path, tool_strategy)
tool_groups: list[FunctionType] = get_items_from_folder(folder_path, tool_group_strategy)
# Register each tool as a command
for tool in tools:
register_command(tool)
Expand Down
4 changes: 3 additions & 1 deletion toolit/cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""CLI entry point for the toolit package."""
import pathlib
from .auto_loader import load_tools_from_folder
from .auto_loader import load_tools_from_folder, register_command
from .create_apps_and_register import app
from .create_tasks_json import create_vscode_tasks_json

PATH = pathlib.Path() / "devtools"
load_tools_from_folder(PATH)
register_command(create_vscode_tasks_json)

if __name__ == "__main__":
# Run the typer app
Expand Down
35 changes: 19 additions & 16 deletions toolit/create_apps_and_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,36 @@

import typer
from collections.abc import Callable
from typing import Any
from typing import TYPE_CHECKING, Any

# Try to import FastMCP, make MCP optional
try:
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP

_has_mcp: bool = True
except ImportError:
FastMCP: Any = None # type: ignore[no-redef]
_has_mcp = False
else:
# Make MCP optional
try:
from mcp.server.fastmcp import FastMCP
_has_mcp = True
except ImportError:
FastMCP: Any = None # type: ignore[no-redef]
_has_mcp = False

# Initialize the Typer app
app: typer.Typer = typer.Typer()
app: typer.Typer = typer.Typer(no_args_is_help=True)
# Initialize the MCP server with a name, if available
mcp: FastMCP | None = FastMCP("Toolit MCP Server") if _has_mcp else None


@app.callback()
def initialize() -> None:
"""Welcome to the Toolit CLI."""


def register_command(command_func: Callable[..., Any], name: str | None = None) -> None:
"""Register an external command to the CLI and MCP server if available."""
if not callable(command_func):
msg = f"Command function {command_func} is not callable."
raise TypeError(msg)
if name:
app.command(name=name)(command_func)
if mcp is not None:
mcp.tool(name)(command_func)
else:
app.command()(command_func)
if mcp is not None:
mcp.tool()(command_func)
app.command(name=name)(command_func)
if mcp is not None:
mcp.tool(name)(command_func)
56 changes: 30 additions & 26 deletions toolit/create_tasks_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import enum
import json
import typer
import inspect
import pathlib
from toolit.auto_loader import get_toolit_type, load_tools_from_folder
from toolit.auto_loader import get_items_from_folder, get_toolit_type, tool_group_strategy, tool_strategy
from toolit.constants import ToolitTypesEnum
from types import FunctionType
from typing import Any
Expand All @@ -13,18 +14,12 @@
output_file_path: pathlib.Path = pathlib.Path() / ".vscode" / "tasks.json"


def _is_enum(annotation: Any) -> bool: # noqa: ANN401
"""Check if the annotation is an Enum type."""
return isinstance(annotation, type) and issubclass(annotation, enum.Enum)


def _is_bool(annotation: Any) -> bool: # noqa: ANN401
"""Check if the annotation is a bool type."""
return annotation is bool


def create_vscode_tasks_json(tools: list[FunctionType]) -> None:
def create_vscode_tasks_json() -> None:
"""Create a tasks.json file based on the tools discovered in the project."""
typer.echo(f"Creating tasks.json at {output_file_path}")
tools: list[FunctionType] = get_items_from_folder(PATH, tool_strategy)
tool_groups: list[FunctionType] = get_items_from_folder(PATH, tool_group_strategy)
tools.extend(tool_groups)
json_builder = TaskJsonBuilder()
for tool in tools:
json_builder.process_tool(tool)
Expand All @@ -35,12 +30,22 @@ def create_vscode_tasks_json(tools: list[FunctionType]) -> None:
json.dump(tasks_json, f, indent=4)


def create_typer_command_name(tool: FunctionType) -> str:
def _is_enum(annotation: Any) -> bool: # noqa: ANN401
"""Check if the annotation is an Enum type."""
return isinstance(annotation, type) and issubclass(annotation, enum.Enum)


def _is_bool(annotation: Any) -> bool: # noqa: ANN401
"""Check if the annotation is a bool type."""
return annotation is bool


def _create_typer_command_name(tool: FunctionType) -> str:
"""Create a Typer command name from a tool function name."""
return tool.__name__.replace("_", "-").lower()


def create_display_name(tool: FunctionType) -> str:
def _create_display_name(tool: FunctionType) -> str:
"""Create a display name from a tool function name."""
return tool.__name__.replace("_", " ").title()

Expand All @@ -54,7 +59,7 @@ def __init__(self) -> None:
self.input_id_map: dict[tuple[str, str], str] = {}
self.tasks: list[dict[str, Any]] = []

def create_args_for_tool(self, tool: FunctionType) -> list[str]:
def _create_args_for_tool(self, tool: FunctionType) -> list[str]:
"""Create argument list and input entries for a given tool."""
sig = inspect.signature(tool)
args: list[str] = []
Expand Down Expand Up @@ -94,10 +99,10 @@ def create_args_for_tool(self, tool: FunctionType) -> list[str]:
args.append(f'"${{input:{input_id}}}"')
return args

def create_task_entry(self, tool: FunctionType, args: list[str]) -> None:
def _create_task_entry(self, tool: FunctionType, args: list[str]) -> None:
"""Create a task entry for a given tool."""
name_as_typer_command: str = create_typer_command_name(tool)
display_name: str = tool.__name__.replace("_", " ").title()
name_as_typer_command: str = _create_typer_command_name(tool)
display_name: str = _create_display_name(tool)
task: dict[str, Any] = {
"label": display_name,
"type": "shell",
Expand All @@ -108,13 +113,13 @@ def create_task_entry(self, tool: FunctionType, args: list[str]) -> None:
task["detail"] = tool.__doc__.strip()
self.tasks.append(task)

def create_task_group_entry(self, tool: FunctionType, tool_type: ToolitTypesEnum) -> None:
def _create_task_group_entry(self, tool: FunctionType, tool_type: ToolitTypesEnum) -> None:
"""Create a task group entry for a given tool."""
group_name: str = tool.__name__.replace("_", " ").title()
group_name: str = "Group: " + tool.__name__.replace("_", " ").title()
tools: list[FunctionType] = tool() # Call the tool to get the list of tools in the group
task: dict[str, Any] = {
"label": group_name,
"dependsOn": [f"{create_display_name(t)}" for t in tools],
"dependsOn": [f"{_create_display_name(t)}" for t in tools],
"problemMatcher": [],
}
if tool_type == ToolitTypesEnum.SEQUENTIAL_GROUP:
Expand All @@ -127,10 +132,10 @@ def process_tool(self, tool: FunctionType) -> None:
"""Process a single tool to create its task entry and inputs."""
tool_type = get_toolit_type(tool)
if tool_type == ToolitTypesEnum.TOOL:
args = self.create_args_for_tool(tool)
self.create_task_entry(tool, args)
args = self._create_args_for_tool(tool)
self._create_task_entry(tool, args)
elif tool_type in {ToolitTypesEnum.SEQUENTIAL_GROUP, ToolitTypesEnum.PARALLEL_GROUP}:
self.create_task_group_entry(tool, tool_type)
self._create_task_group_entry(tool, tool_type)

def create_tasks_json(self) -> dict:
"""Create the final tasks.json structure."""
Expand All @@ -143,5 +148,4 @@ def create_tasks_json(self) -> dict:


if __name__ == "__main__":
tools: list[FunctionType] = load_tools_from_folder(PATH)
create_vscode_tasks_json(tools)
create_vscode_tasks_json()
Loading