From a19fec118b540291231758f349f9e4a39ad1c796 Mon Sep 17 00:00:00 2001 From: Maska Chung Date: Mon, 2 Mar 2026 18:03:51 +0000 Subject: [PATCH 01/11] upgrade to fastmcp v3 --- pyproject.toml | 2 +- src/humcp/decorator.py | 28 +++- src/humcp/server.py | 58 +++++++- uv.lock | 311 ++++++++++++++--------------------------- 4 files changed, 185 insertions(+), 214 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a93ad44..2681707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "fastapi>=0.104.0", "pydantic>=2.0.0", "uvicorn>=0.24.0", - "fastmcp>=2.13.0.2", + "fastmcp>=3.0.0", "pandas>=2.3.3", "duckdb>=1.4.2", "tavily-python>=0.7.13", diff --git a/src/humcp/decorator.py b/src/humcp/decorator.py index 3aca7cf..59e7b25 100644 --- a/src/humcp/decorator.py +++ b/src/humcp/decorator.py @@ -6,12 +6,10 @@ import inspect from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, NamedTuple -from fastmcp.tools import FunctionTool - __all__ = [ "tool", "is_tool", @@ -19,6 +17,7 @@ "get_tool_category", "RegisteredTool", "ToolMetadata", + "ToolInfo", ] TOOL_ATTR = "_humcp_tool" @@ -32,15 +31,34 @@ class ToolMetadata: category: str +@dataclass(frozen=True) +class ToolInfo: + """Tool metadata mirroring the fields previously provided by FunctionTool. + + Attributes: + name: Tool name for MCP registration. + description: Tool description (from docstring). + parameters: JSON Schema dict for tool input parameters. + fn: The original callable. + output_schema: Optional JSON Schema dict for output (default empty). + """ + + name: str + description: str + parameters: dict[str, Any] + fn: Callable[..., Any] + output_schema: dict[str, Any] = field(default_factory=dict) + + class RegisteredTool(NamedTuple): """A tool registered with FastMCP, with category for grouping. Attributes: - tool: The FastMCP FunctionTool object (has name, description, parameters, fn). + tool: ToolInfo object (has name, description, parameters, fn). category: Category for REST endpoint grouping. """ - tool: FunctionTool + tool: ToolInfo category: str diff --git a/src/humcp/server.py b/src/humcp/server.py index 2e0b22a..c6bdc54 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -4,18 +4,22 @@ import inspect import logging import sys +from collections.abc import Callable from contextlib import asynccontextmanager from pathlib import Path from types import ModuleType +from typing import Any, get_type_hints from dotenv import load_dotenv from fastapi import FastAPI from fastmcp import FastMCP +from pydantic import TypeAdapter from src.humcp.auth import create_auth_provider from src.humcp.config import DEFAULT_CONFIG_PATH, filter_tools, load_config from src.humcp.decorator import ( RegisteredTool, + ToolInfo, get_tool_category, get_tool_name, is_tool, @@ -56,12 +60,48 @@ def _load_modules(tools_path: Path) -> list[ModuleType]: return modules +def _build_parameters_schema(func: Callable[..., Any]) -> dict[str, Any]: + """Build JSON Schema for a function's parameters using pydantic.""" + sig = inspect.signature(func) + hints = get_type_hints(func) + + properties: dict[str, Any] = {} + required: list[str] = [] + + for name, param in sig.parameters.items(): + if name in ("self", "cls"): + continue + annotation = hints.get(name, Any) + try: + adapter = TypeAdapter(annotation) + prop_schema = adapter.json_schema() + except Exception: + prop_schema = {} + + # Add description from default if it's a pydantic Field, otherwise skip + properties[name] = prop_schema + + if param.default is inspect.Parameter.empty: + required.append(name) + elif param.default is not inspect.Parameter.empty and param.default is not None: + properties[name]["default"] = param.default + + schema: dict[str, Any] = { + "type": "object", + "properties": properties, + } + if required: + schema["required"] = required + + return schema + + def _discover_and_register( mcp: FastMCP, modules: list[ModuleType] ) -> list[RegisteredTool]: """Discover @tool functions and register with FastMCP. - Returns list of RegisteredTool (FunctionTool + category). + Returns list of RegisteredTool (ToolInfo + category). """ tools: list[RegisteredTool] = [] seen_names: set[str] = set() @@ -82,10 +122,18 @@ def _discover_and_register( seen_names.add(tool_name) - # Register with FastMCP using custom name - returns FunctionTool - fn_tool = mcp.tool(name=tool_name)(func) - tools.append(RegisteredTool(tool=fn_tool, category=category)) - logger.debug("Registered: %s (category: %s)", fn_tool.name, category) + # Register with FastMCP (v3 returns the original function, not FunctionTool) + mcp.tool(name=tool_name)(func) + + # Build ToolInfo ourselves + tool_info = ToolInfo( + name=tool_name, + description=func.__doc__ or "", + parameters=_build_parameters_schema(func), + fn=func, + ) + tools.append(RegisteredTool(tool=tool_info, category=category)) + logger.debug("Registered: %s (category: %s)", tool_name, category) return tools diff --git a/uv.lock b/uv.lock index d5ef27a..9848366 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,18 @@ resolution-markers = [ "sys_platform != 'win32'", ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -249,6 +261,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -545,15 +570,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] -[[package]] -name = "diskcache" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -652,24 +668,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "fakeredis" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, -] - [[package]] name = "fastapi" version = "0.126.0" @@ -687,29 +685,33 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.14.1" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "httpx" }, + { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, - { name = "pydocket" }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/50/d38e4371bdc34e709f4731b1e882cb7bc50e51c1a224859d4cd381b3a79b/fastmcp-2.14.1.tar.gz", hash = "sha256:132725cbf77b68fa3c3d165eff0cfa47e40c1479457419e6a2cfda65bd84c8d6", size = 8263331, upload-time = "2025-12-15T02:26:27.102Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/82/72401d09dc27c27fdf72ad6c2fe331e553e3c3646e01b5ff16473191033d/fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5", size = 412176, upload-time = "2025-12-15T02:26:25.356Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" }, ] [[package]] @@ -970,7 +972,7 @@ requires-dist = [ { name = "black", specifier = ">=25.12.0" }, { name = "duckdb", specifier = ">=1.4.2" }, { name = "fastapi", specifier = ">=0.104.0" }, - { name = "fastmcp", specifier = ">=2.13.0.2" }, + { name = "fastmcp", specifier = ">=3.0.0" }, { name = "google-api-python-client", specifier = ">=2.168.0" }, { name = "google-auth-httplib2", specifier = ">=0.2.0" }, { name = "google-auth-oauthlib", specifier = ">=1.2.2" }, @@ -1134,6 +1136,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, ] +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -1247,47 +1258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] -[[package]] -name = "lupa" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, -] - [[package]] name = "lxml" version = "6.0.2" @@ -1811,20 +1781,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, ] -[[package]] -name = "opentelemetry-exporter-prometheus" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "prometheus-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, -] - [[package]] name = "opentelemetry-instrumentation" version = "0.60b1" @@ -1946,15 +1902,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] -[[package]] -name = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, -] - [[package]] name = "pdfminer-six" version = "20251107" @@ -2060,15 +2007,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] -[[package]] -name = "prometheus-client" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, -] - [[package]] name = "proto-plus" version = "1.27.0" @@ -2098,21 +2036,21 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, ] keyring = [ { name = "keyring" }, @@ -2120,22 +2058,6 @@ keyring = [ memory = [ { name = "cachetools" }, ] -redis = [ - { name = "redis" }, -] - -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] [[package]] name = "pyasn1" @@ -2254,29 +2176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] -[[package]] -name = "pydocket" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "fakeredis", extra = ["lua"] }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-prometheus" }, - { name = "opentelemetry-instrumentation" }, - { name = "prometheus-client" }, - { name = "py-key-value-aio", extra = ["memory", "redis"] }, - { name = "python-json-logger" }, - { name = "redis" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2b/ff/87e931e4abc7efb1e8c16adaa55327452931e8c5f460f2ba089447673226/pydocket-0.16.1.tar.gz", hash = "sha256:8663cb6dc801d8b8d703541fb665f4099c84f4d10d8f3fd441e208b080aa4826", size = 289028, upload-time = "2025-12-19T19:43:48.773Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/ab/0da7d0397112546309709f464bdf65de4e1697e3caba07556751fc4d8bcd/pydocket-0.16.1-py3-none-any.whl", hash = "sha256:bc6ccf7e91164761def854b4014101abf23c3cc2fb7d0fa2c4e07ea3bf6a1826", size = 63208, upload-time = "2025-12-19T19:43:47.309Z" }, -] - [[package]] name = "pydub" version = "0.25.1" @@ -2399,15 +2298,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] -[[package]] -name = "python-json-logger" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, -] - [[package]] name = "python-multipart" version = "0.0.21" @@ -2508,15 +2398,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "redis" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, -] - [[package]] name = "referencing" version = "0.36.2" @@ -2765,15 +2646,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -2792,15 +2664,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "soupsieve" version = "2.8.1" @@ -2949,21 +2812,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] -[[package]] -name = "typer" -version = "0.20.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c1/933d30fd7a123ed981e2a1eedafceab63cb379db0402e438a13bc51bbb15/typer-0.20.1.tar.gz", hash = "sha256:68585eb1b01203689c4199bc440d6be616f0851e9f0eb41e4a778845c5a0fd5b", size = 105968, upload-time = "2025-12-19T16:48:56.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/52/1f2df7e7d1be3d65ddc2936d820d4a3d9777a54f4204f5ca46b8513eff77/typer-0.20.1-py3-none-any.whl", hash = "sha256:4b3bde918a67c8e03d861aa02deca90a95bbac572e71b1b9be56ff49affdb5a8", size = 47381, upload-time = "2025-12-19T16:48:53.679Z" }, -] - [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -3048,6 +2896,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + [[package]] name = "websockets" version = "15.0.1" From 2b3706bd9cb9e330bd4f93fbcf109707d87ffd53 Mon Sep 17 00:00:00 2001 From: Maska Chung Date: Mon, 2 Mar 2026 18:48:43 +0000 Subject: [PATCH 02/11] added minio (no visualization) --- pyproject.toml | 1 + src/humcp/decorator.py | 2 + src/humcp/schemas.py | 8 + src/tools/storage/SKILL.md | 148 +++++ src/tools/storage/minio.py | 1040 ++++++++++++++++++++++++++++++++++ src/tools/storage/schemas.py | 245 ++++++++ uv.lock | 97 +++- 7 files changed, 1539 insertions(+), 2 deletions(-) create mode 100644 src/tools/storage/SKILL.md create mode 100644 src/tools/storage/minio.py create mode 100644 src/tools/storage/schemas.py diff --git a/pyproject.toml b/pyproject.toml index 2681707..093c13d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "google-api-python-client>=2.168.0", "google-auth-httplib2>=0.2.0", "google-auth-oauthlib>=1.2.2", + "minio>=7.0.0", ] [dependency-groups] diff --git a/src/humcp/decorator.py b/src/humcp/decorator.py index 59e7b25..8e2fc59 100644 --- a/src/humcp/decorator.py +++ b/src/humcp/decorator.py @@ -121,3 +121,5 @@ def get_tool_category(func: Callable[..., Any]) -> str: if isinstance(metadata, ToolMetadata): return metadata.category return "uncategorized" + + diff --git a/src/humcp/schemas.py b/src/humcp/schemas.py index 0bd9653..24ebff5 100644 --- a/src/humcp/schemas.py +++ b/src/humcp/schemas.py @@ -5,6 +5,14 @@ from pydantic import BaseModel, Field +class ToolResponse[T](BaseModel): + """Generic response wrapper for tool outputs.""" + + success: bool + data: T | None = None + error: str | None = None + + # Shared models class ToolSummary(BaseModel): """Summary of a tool in listings.""" diff --git a/src/tools/storage/SKILL.md b/src/tools/storage/SKILL.md new file mode 100644 index 0000000..2c97d49 --- /dev/null +++ b/src/tools/storage/SKILL.md @@ -0,0 +1,148 @@ +--- +name: storage +description: Use these tools for S3-compatible object storage operations with MinIO +--- + +# Storage Tools (MinIO) + +Tools for interacting with MinIO S3-compatible object storage. Use these for file uploads, downloads, and bucket management. + +## Configuration + +Set these environment variables: + +```bash +MINIO_ENDPOINT=minio:9000 # MinIO server address +MINIO_ACCESS_KEY=minioadmin # Access key (required) +MINIO_SECRET_KEY=minioadmin # Secret key (required) +MINIO_SECURE=false # Use HTTPS (true/false) +MINIO_ALLOWED_BUCKETS=bucket1,bucket2 # Comma-separated allowlist +``` + +## Security + +All bucket operations validate against `MINIO_ALLOWED_BUCKETS`. If set, only those buckets can be accessed. + +## Available Tools + +### Bucket Operations + +| Tool | Description | +|------|-------------| +| `list_buckets` | List all accessible buckets | +| `create_bucket` | Create a new bucket | +| `delete_bucket` | Delete an empty bucket | +| `bucket_exists` | Check if a bucket exists | + +### Object Listing + +| Tool | Description | +|------|-------------| +| `list_objects` | List objects with optional prefix filter | + +### Upload + +| Tool | Description | +|------|-------------| +| `upload_content` | Upload base64-encoded content | +| `upload_from_path` | Upload from local file path | + +### Download + +| Tool | Description | +|------|-------------| +| `download_content` | Download as base64-encoded content | +| `download_to_path` | Download to local file path | + +### Delete & Copy + +| Tool | Description | +|------|-------------| +| `delete_object` | Delete an object | +| `copy_object` | Copy object between buckets | + +### Utilities + +| Tool | Description | +|------|-------------| +| `get_object_metadata` | Get object size, type, and metadata | +| `get_presigned_url` | Generate temporary download URL | + +## Usage Examples + +### Upload a file + +```python +# From base64 content +result = await upload_content( + bucket="my-bucket", + object_name="documents/report.pdf", + content_base64="JVBERi0xLjQK...", + content_type="application/pdf" +) + +# From local path +result = await upload_from_path( + bucket="my-bucket", + object_name="images/photo.jpg", + file_path="/tmp/photo.jpg", + content_type="image/jpeg" +) +``` + +### Download a file + +```python +# To base64 (for small files) +result = await download_content(bucket="my-bucket", object_name="doc.pdf") +content = base64.b64decode(result["data"]["content_base64"]) + +# To local path (for large files) +result = await download_to_path( + bucket="my-bucket", + object_name="video.mp4", + file_path="/tmp/video.mp4" +) +``` + +### List objects with prefix + +```python +result = await list_objects( + bucket="my-bucket", + prefix="documents/2024/", + recursive=True +) +# Returns all objects under documents/2024/ +``` + +### Generate presigned URL + +```python +result = await get_presigned_url( + bucket="my-bucket", + object_name="private/report.pdf", + expires_hours=24 +) +# Share result["data"]["url"] for temporary access +``` + +## Response Format + +All tools return a standardized response: + +```python +# Success +{"success": True, "data": {...}} + +# Error +{"success": False, "error": "Error message"} +``` + +## When to Use + +- **upload_content**: Small files (<10MB), content already in memory +- **upload_from_path**: Large files, files on disk +- **download_content**: Small files, need content in memory +- **download_to_path**: Large files, need to save to disk +- **get_presigned_url**: Share temporary access without exposing credentials \ No newline at end of file diff --git a/src/tools/storage/minio.py b/src/tools/storage/minio.py new file mode 100644 index 0000000..2ce8324 --- /dev/null +++ b/src/tools/storage/minio.py @@ -0,0 +1,1040 @@ +"""MinIO S3-compatible object storage tools. + +This module provides tools for interacting with MinIO/S3-compatible object storage. +All tools follow a standardized response format and validate bucket access against +an optional allowlist. + +Environment Variables: + MINIO_ENDPOINT: MinIO server address (default: "minio:9000") + MINIO_ACCESS_KEY: Access key for authentication (required) + MINIO_SECRET_KEY: Secret key for authentication (required) + MINIO_SECURE: Use HTTPS connection (default: "false") + MINIO_ALLOWED_BUCKETS: Comma-separated list of allowed bucket names (optional) + +Example: + >>> result = await list_buckets() + >>> if result.success: + ... for bucket in result.data.buckets: + ... print(bucket.name) +""" + +import asyncio +import base64 +import io +import logging +import os +from datetime import timedelta +from functools import partial +from pathlib import Path + +from minio import Minio +from minio.commonconfig import CopySource +from minio.error import S3Error + +from src.humcp.decorator import tool +from src.tools.storage.schemas import ( + BucketExistsData, + BucketExistsResponse, + BucketInfo, + CopyObjectData, + CopyObjectResponse, + CreateBucketData, + CreateBucketResponse, + DeleteBucketData, + DeleteBucketResponse, + DeleteObjectData, + DeleteObjectResponse, + DownloadContentData, + DownloadContentResponse, + DownloadToPathData, + DownloadToPathResponse, + GetObjectMetadataData, + GetObjectMetadataResponse, + GetPresignedUrlData, + GetPresignedUrlResponse, + ListBucketsData, + ListBucketsResponse, + ListObjectsData, + ListObjectsResponse, + ObjectInfo, + UploadContentData, + UploadContentResponse, + UploadFromPathData, + UploadFromPathResponse, +) + +logger = logging.getLogger(__name__) + +# Singleton MinIO client +_client: Minio | None = None +_allowed_buckets: set[str] | None = None + + +async def run_sync[T](func: partial[T]) -> T: + """Run a synchronous function in a thread pool to avoid blocking the event loop. + + The MinIO client uses synchronous HTTP calls. This helper runs those calls + in a thread pool executor to maintain async compatibility. + + Args: + func: A partial function wrapping the sync call. + + Returns: + The result of the synchronous function. + + Example: + result = await run_sync(partial(client.list_buckets)) + """ + return await asyncio.to_thread(func) + + +def get_client() -> Minio: + """Get or create the MinIO client singleton. + + Creates a MinIO client using environment variables for configuration. + The client is cached as a singleton for reuse across requests. + + Returns: + Minio: Configured MinIO client instance. + + Raises: + ValueError: If MINIO_ACCESS_KEY or MINIO_SECRET_KEY is not set. + """ + global _client + if _client is None: + endpoint = os.getenv("MINIO_ENDPOINT", "minio:9000") + access_key = os.getenv("MINIO_ACCESS_KEY") + secret_key = os.getenv("MINIO_SECRET_KEY") + secure = os.getenv("MINIO_SECURE", "false").lower() == "true" + + if not access_key or not secret_key: + raise ValueError("MINIO_ACCESS_KEY and MINIO_SECRET_KEY must be set") + + _client = Minio( + endpoint=endpoint, + access_key=access_key, + secret_key=secret_key, + secure=secure, + ) + return _client + + +def get_allowed_buckets() -> set[str]: + """Get the set of allowed bucket names from environment. + + Parses MINIO_ALLOWED_BUCKETS environment variable as a comma-separated + list of bucket names. If not set, returns an empty set (all buckets allowed). + + Returns: + set[str]: Set of allowed bucket names, or empty set if no restrictions. + """ + global _allowed_buckets + if _allowed_buckets is None: + buckets_str = os.getenv("MINIO_ALLOWED_BUCKETS", "") + _allowed_buckets = {b.strip() for b in buckets_str.split(",") if b.strip()} + return _allowed_buckets + + +def reset_client() -> None: + """Reset the client singleton and cached configuration. + + Clears the cached MinIO client and allowed buckets set. Primarily used + for testing to ensure a fresh client state between tests. + """ + global _client, _allowed_buckets + _client = None + _allowed_buckets = None + + +def validate_bucket(bucket: str) -> str | None: + """Validate that a bucket is in the allowed list. + + Checks if the given bucket name is permitted according to the + MINIO_ALLOWED_BUCKETS configuration. If no allowlist is configured, + all buckets are permitted. + + Args: + bucket: Name of the bucket to validate. + + Returns: + str | None: Error message if bucket is not allowed, None if permitted. + """ + allowed = get_allowed_buckets() + if allowed and bucket not in allowed: + return f"Bucket '{bucket}' not in allowed list. Allowed: {sorted(allowed)}" + return None + + +def validate_object_name(object_name: str) -> str | None: + """Validate that an object name is safe and well-formed. + + Checks for path traversal attempts and invalid characters in object names. + While S3/MinIO technically allows almost any characters in object names, + we restrict to safe patterns for security. + + Args: + object_name: Object key/path to validate. + + Returns: + str | None: Error message if validation fails, None if valid. + """ + if not object_name: + return "Object name cannot be empty" + + # Reject path traversal patterns + if ".." in object_name: + return "Object name cannot contain '..'" + + # Reject absolute paths (starting with /) + if object_name.startswith("/"): + return "Object name cannot start with '/'" + + # Reject names with null bytes + if "\x00" in object_name: + return "Object name cannot contain null bytes" + + # Limit length (S3 limit is 1024 bytes) + if len(object_name.encode("utf-8")) > 1024: + return "Object name exceeds maximum length of 1024 bytes" + + return None + + +def _allow_absolute_paths() -> bool: + """Check if absolute paths are allowed for downloads.""" + return os.getenv("MINIO_ALLOW_ABSOLUTE_PATHS", "").lower() == "true" + + +def validate_local_path(file_path: str) -> str | None: + """Validate that a local file path is safe for writing. + + By default, paths must be within the current working directory to prevent + directory traversal attacks. Set MINIO_ALLOW_ABSOLUTE_PATHS=true to allow + writing to any path (use with caution). + + Args: + file_path: Local file path to validate. + + Returns: + str | None: Error message if validation fails, None if valid. + """ + if not file_path: + return "File path cannot be empty" + + path = Path(file_path) + + # Reject path traversal patterns + if ".." in str(path): + return "File path cannot contain '..'" + + # If absolute paths are allowed, skip containment check + if _allow_absolute_paths(): + return None + + # Resolve both paths to compare + try: + resolved = path.resolve() + base_dir = Path.cwd().resolve() + + # Check if path is within base directory + resolved.relative_to(base_dir) + return None + except ValueError: + return ( + f"Path '{file_path}' is outside allowed directory '{base_dir}'. " + "Set MINIO_ALLOW_ABSOLUTE_PATHS=true to allow absolute paths." + ) + except OSError as e: + return f"Invalid path: {e}" + + +# ============================================================================= +# Bucket Operations +# ============================================================================= + + +@tool() +async def list_buckets() -> ListBucketsResponse: + """List all accessible buckets in MinIO. + + Retrieves all buckets visible to the configured credentials. If + MINIO_ALLOWED_BUCKETS is set, only those buckets are included in the result. + + Returns: + ListBucketsResponse: Response containing bucket list. + On success: success=True, data=ListBucketsData(buckets=[...]) + On error: success=False, error=str + """ + try: + client = get_client() + # Run sync MinIO call in thread pool to avoid blocking event loop + all_buckets = await run_sync(partial(client.list_buckets)) + allowed = get_allowed_buckets() + + buckets = [] + for b in all_buckets: + if not allowed or b.name in allowed: + buckets.append( + BucketInfo( + name=b.name, + creation_date=b.creation_date.isoformat() + if b.creation_date + else None, + ) + ) + + return ListBucketsResponse(success=True, data=ListBucketsData(buckets=buckets)) + except S3Error as e: + logger.error("Failed to list buckets: %s", e) + return ListBucketsResponse(success=False, error=str(e)) + except ValueError as e: + return ListBucketsResponse(success=False, error=str(e)) + + +@tool() +async def create_bucket(bucket: str) -> CreateBucketResponse: + """Create a new bucket in MinIO. + + Creates a bucket with the specified name. The bucket must be in the + allowed list if MINIO_ALLOWED_BUCKETS is configured. + + Args: + bucket: Name of the bucket to create. Must follow S3 bucket naming rules: + - 3-63 characters long + - Lowercase letters, numbers, and hyphens only + - Must start and end with a letter or number + + Returns: + CreateBucketResponse: Response indicating creation result. + On success: success=True, data=CreateBucketData(bucket=str, created=True) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return CreateBucketResponse(success=False, error=error) + + try: + client = get_client() + if await run_sync(partial(client.bucket_exists, bucket)): + return CreateBucketResponse( + success=False, error=f"Bucket '{bucket}' already exists" + ) + + await run_sync(partial(client.make_bucket, bucket)) + return CreateBucketResponse( + success=True, data=CreateBucketData(bucket=bucket, created=True) + ) + except S3Error as e: + logger.error("Failed to create bucket '%s': %s", bucket, e) + return CreateBucketResponse(success=False, error=str(e)) + except ValueError as e: + return CreateBucketResponse(success=False, error=str(e)) + + +@tool() +async def delete_bucket(bucket: str) -> DeleteBucketResponse: + """Delete an empty bucket from MinIO. + + Removes a bucket from the storage. The bucket must be empty before + deletion. Use list_objects to verify contents and delete_object to + remove objects first if needed. + + Args: + bucket: Name of the bucket to delete. + + Returns: + DeleteBucketResponse: Response indicating deletion result. + On success: success=True, data=DeleteBucketData(bucket=str, deleted=True) + On error: success=False, error=str + Common errors: bucket not empty, bucket does not exist + """ + if error := validate_bucket(bucket): + return DeleteBucketResponse(success=False, error=error) + + try: + client = get_client() + if not await run_sync(partial(client.bucket_exists, bucket)): + return DeleteBucketResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + await run_sync(partial(client.remove_bucket, bucket)) + return DeleteBucketResponse( + success=True, data=DeleteBucketData(bucket=bucket, deleted=True) + ) + except S3Error as e: + logger.error("Failed to delete bucket '%s': %s", bucket, e) + return DeleteBucketResponse(success=False, error=str(e)) + except ValueError as e: + return DeleteBucketResponse(success=False, error=str(e)) + + +@tool() +async def bucket_exists(bucket: str) -> BucketExistsResponse: + """Check if a bucket exists in MinIO. + + Verifies whether a bucket with the given name exists and is accessible. + + Args: + bucket: Name of the bucket to check. + + Returns: + BucketExistsResponse: Response indicating bucket existence. + On success: success=True, data=BucketExistsData(bucket=str, exists=bool) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return BucketExistsResponse(success=False, error=error) + + try: + client = get_client() + exists = await run_sync(partial(client.bucket_exists, bucket)) + return BucketExistsResponse( + success=True, data=BucketExistsData(bucket=bucket, exists=exists) + ) + except S3Error as e: + logger.error("Failed to check bucket '%s': %s", bucket, e) + return BucketExistsResponse(success=False, error=str(e)) + except ValueError as e: + return BucketExistsResponse(success=False, error=str(e)) + + +# ============================================================================= +# Object Listing +# ============================================================================= + + +@tool() +async def list_objects( + bucket: str, prefix: str = "", recursive: bool = True +) -> ListObjectsResponse: + """List objects in a bucket with optional prefix filtering. + + Retrieves a list of objects in the specified bucket. Supports prefix + filtering for listing objects in a specific "directory" and recursive + listing to include objects in subdirectories. + + Args: + bucket: Name of the bucket to list objects from. + prefix: Filter objects by key prefix (e.g., "documents/2024/"). + Defaults to "" (no filter). + recursive: If True, list all objects including those in subdirectories. + If False, list only objects at the current level. Defaults to True. + + Returns: + ListObjectsResponse: Response containing object list. + On success: success=True, data=ListObjectsData(bucket, prefix, recursive, objects, count) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return ListObjectsResponse(success=False, error=error) + + try: + client = get_client() + if not await run_sync(partial(client.bucket_exists, bucket)): + return ListObjectsResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + # list_objects returns an iterator, so we need to collect in thread pool + def _list_objects() -> list[ObjectInfo]: + result = [] + for obj in client.list_objects(bucket, prefix=prefix, recursive=recursive): + result.append( + ObjectInfo( + name=obj.object_name, + size=obj.size, + last_modified=obj.last_modified.isoformat() + if obj.last_modified + else None, + etag=obj.etag, + is_dir=obj.is_dir, + ) + ) + return result + + objects = await run_sync(partial(_list_objects)) + + return ListObjectsResponse( + success=True, + data=ListObjectsData( + bucket=bucket, + prefix=prefix, + recursive=recursive, + objects=objects, + count=len(objects), + ), + ) + except S3Error as e: + logger.error("Failed to list objects in '%s': %s", bucket, e) + return ListObjectsResponse(success=False, error=str(e)) + except ValueError as e: + return ListObjectsResponse(success=False, error=str(e)) + + +# ============================================================================= +# Upload Tools +# ============================================================================= + + +@tool() +async def upload_content( + bucket: str, + object_name: str, + content_base64: str, + content_type: str = "application/octet-stream", +) -> UploadContentResponse: + """Upload base64-encoded content to MinIO. + + Uploads data provided as a base64-encoded string to the specified + bucket and object path. Best suited for small files (< 10MB) where + content is already in memory. + + Args: + bucket: Name of the destination bucket. + object_name: Object key/path in the bucket (e.g., "documents/report.pdf"). + content_base64: Base64-encoded file content. + content_type: MIME type of the content (e.g., "application/pdf", "image/jpeg"). + Defaults to "application/octet-stream". + + Returns: + UploadContentResponse: Response containing upload result. + On success: success=True, data=UploadContentData(bucket, object_name, size, etag, version_id) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return UploadContentResponse(success=False, error=error) + if error := validate_object_name(object_name): + return UploadContentResponse(success=False, error=error) + + try: + client = get_client() + + # Ensure bucket exists + if not await run_sync(partial(client.bucket_exists, bucket)): + return UploadContentResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + # Decode base64 content + try: + content = base64.b64decode(content_base64) + except Exception as e: + return UploadContentResponse( + success=False, error=f"Invalid base64 content: {e}" + ) + + # Upload + data = io.BytesIO(content) + result = await run_sync( + partial( + client.put_object, + bucket, + object_name, + data, + length=len(content), + content_type=content_type, + ) + ) + + return UploadContentResponse( + success=True, + data=UploadContentData( + bucket=bucket, + object_name=object_name, + size=len(content), + etag=result.etag, + version_id=result.version_id, + ), + ) + except S3Error as e: + logger.error("Failed to upload to '%s/%s': %s", bucket, object_name, e) + return UploadContentResponse(success=False, error=str(e)) + except ValueError as e: + return UploadContentResponse(success=False, error=str(e)) + + +@tool() +async def upload_from_path( + bucket: str, + object_name: str, + file_path: str, + content_type: str = "application/octet-stream", +) -> UploadFromPathResponse: + """Upload a file from the local filesystem to MinIO. + + Uploads a file directly from disk to the specified bucket and object path. + Suitable for large files as content is streamed rather than loaded into memory. + + Args: + bucket: Name of the destination bucket. + object_name: Object key/path in the bucket (e.g., "backups/data.zip"). + file_path: Absolute or relative path to the local file. + content_type: MIME type of the content. Defaults to "application/octet-stream". + + Returns: + UploadFromPathResponse: Response containing upload result. + On success: success=True, data=UploadFromPathData(bucket, object_name, file_path, size, etag, version_id) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return UploadFromPathResponse(success=False, error=error) + if error := validate_object_name(object_name): + return UploadFromPathResponse(success=False, error=error) + + try: + client = get_client() + + # Ensure bucket exists + if not await run_sync(partial(client.bucket_exists, bucket)): + return UploadFromPathResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + # Validate file path + path = Path(file_path) + if not path.exists(): + return UploadFromPathResponse( + success=False, error=f"File not found: {file_path}" + ) + if not path.is_file(): + return UploadFromPathResponse( + success=False, error=f"Path is not a file: {file_path}" + ) + + # Upload + result = await run_sync( + partial( + client.fput_object, + bucket, + object_name, + file_path, + content_type=content_type, + ) + ) + + return UploadFromPathResponse( + success=True, + data=UploadFromPathData( + bucket=bucket, + object_name=object_name, + file_path=file_path, + size=path.stat().st_size, + etag=result.etag, + version_id=result.version_id, + ), + ) + except S3Error as e: + logger.error( + "Failed to upload '%s' to '%s/%s': %s", file_path, bucket, object_name, e + ) + return UploadFromPathResponse(success=False, error=str(e)) + except ValueError as e: + return UploadFromPathResponse(success=False, error=str(e)) + + +# ============================================================================= +# Download Tools +# ============================================================================= + + +@tool() +async def download_content(bucket: str, object_name: str) -> DownloadContentResponse: + """Download an object and return its content as a base64-encoded string. + + Retrieves the complete content of an object and encodes it as base64. + Best suited for small files (< 10MB) where content is needed in memory. + For large files, use download_to_path instead. + + Args: + bucket: Name of the source bucket. + object_name: Object key/path to download. + + Returns: + DownloadContentResponse: Response containing the object content. + On success: success=True, data=DownloadContentData(bucket, object_name, content_base64, size, content_type, etag) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return DownloadContentResponse(success=False, error=error) + if error := validate_object_name(object_name): + return DownloadContentResponse(success=False, error=error) + + try: + client = get_client() + + # Get object content and metadata in thread pool + def _download() -> tuple[bytes, str, str]: + response = client.get_object(bucket, object_name) + try: + content = response.read() + finally: + response.close() + response.release_conn() + stat = client.stat_object(bucket, object_name) + return content, stat.content_type, stat.etag + + content, content_type_val, etag = await run_sync(partial(_download)) + + return DownloadContentResponse( + success=True, + data=DownloadContentData( + bucket=bucket, + object_name=object_name, + content_base64=base64.b64encode(content).decode("utf-8"), + size=len(content), + content_type=content_type_val, + etag=etag, + ), + ) + except S3Error as e: + logger.error("Failed to download '%s/%s': %s", bucket, object_name, e) + return DownloadContentResponse(success=False, error=str(e)) + except ValueError as e: + return DownloadContentResponse(success=False, error=str(e)) + + +@tool() +async def download_to_path( + bucket: str, object_name: str, file_path: str +) -> DownloadToPathResponse: + """Download an object to a local file. + + Downloads an object from MinIO and saves it to the specified path. + Parent directories are created automatically if they don't exist. + Suitable for large files as content is streamed directly to disk. + + Args: + bucket: Name of the source bucket. + object_name: Object key/path to download. + file_path: Destination path for the downloaded file. + + Returns: + DownloadToPathResponse: Response confirming the download. + On success: success=True, data=DownloadToPathData(bucket, object_name, file_path, size) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return DownloadToPathResponse(success=False, error=error) + if error := validate_object_name(object_name): + return DownloadToPathResponse(success=False, error=error) + if error := validate_local_path(file_path): + return DownloadToPathResponse(success=False, error=error) + + try: + client = get_client() + + # Ensure parent directory exists + path = Path(file_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + return DownloadToPathResponse( + success=False, + error=f"Permission denied creating directory: {path.parent}", + ) + except OSError as e: + return DownloadToPathResponse( + success=False, error=f"Failed to create directory: {e}" + ) + + # Download + await run_sync(partial(client.fget_object, bucket, object_name, file_path)) + + # Get file size + size = path.stat().st_size + + return DownloadToPathResponse( + success=True, + data=DownloadToPathData( + bucket=bucket, + object_name=object_name, + file_path=file_path, + size=size, + ), + ) + except S3Error as e: + logger.error( + "Failed to download '%s/%s' to '%s': %s", bucket, object_name, file_path, e + ) + return DownloadToPathResponse(success=False, error=str(e)) + except ValueError as e: + return DownloadToPathResponse(success=False, error=str(e)) + + +# ============================================================================= +# Delete & Copy +# ============================================================================= + + +@tool() +async def delete_object(bucket: str, object_name: str) -> DeleteObjectResponse: + """Delete an object from a bucket. + + Permanently removes an object from the specified bucket. This operation + cannot be undone unless versioning is enabled on the bucket. + + Args: + bucket: Name of the bucket containing the object. + object_name: Object key/path to delete. + + Returns: + DeleteObjectResponse: Response confirming the deletion. + On success: success=True, data=DeleteObjectData(bucket, object_name, deleted=True) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return DeleteObjectResponse(success=False, error=error) + if error := validate_object_name(object_name): + return DeleteObjectResponse(success=False, error=error) + + try: + client = get_client() + + # Check if object exists first + try: + await run_sync(partial(client.stat_object, bucket, object_name)) + except S3Error as e: + if e.code == "NoSuchKey": + return DeleteObjectResponse( + success=False, + error=f"Object '{object_name}' not found in bucket '{bucket}'", + ) + raise + + await run_sync(partial(client.remove_object, bucket, object_name)) + return DeleteObjectResponse( + success=True, + data=DeleteObjectData( + bucket=bucket, + object_name=object_name, + deleted=True, + ), + ) + except S3Error as e: + logger.error("Failed to delete '%s/%s': %s", bucket, object_name, e) + return DeleteObjectResponse(success=False, error=str(e)) + except ValueError as e: + return DeleteObjectResponse(success=False, error=str(e)) + + +@tool() +async def copy_object( + source_bucket: str, + source_object: str, + dest_bucket: str, + dest_object: str, +) -> CopyObjectResponse: + """Copy an object from one location to another. + + Copies an object between buckets or within the same bucket. Both the + source and destination buckets must be in the allowed list if + MINIO_ALLOWED_BUCKETS is configured. The copy is performed server-side. + + Args: + source_bucket: Name of the bucket containing the source object. + source_object: Object key/path of the source object. + dest_bucket: Name of the destination bucket. + dest_object: Object key/path for the copied object. + + Returns: + CopyObjectResponse: Response confirming the copy operation. + On success: success=True, data=CopyObjectData(source, destination, etag, version_id) + On error: success=False, error=str + """ + # Validate both buckets + if error := validate_bucket(source_bucket): + return CopyObjectResponse(success=False, error=error) + if error := validate_bucket(dest_bucket): + return CopyObjectResponse(success=False, error=error) + # Validate both object names + if error := validate_object_name(source_object): + return CopyObjectResponse(success=False, error=error) + if error := validate_object_name(dest_object): + return CopyObjectResponse(success=False, error=error) + + try: + client = get_client() + + # Verify source exists + try: + await run_sync(partial(client.stat_object, source_bucket, source_object)) + except S3Error as e: + if e.code == "NoSuchKey": + return CopyObjectResponse( + success=False, + error=f"Source object '{source_object}' not found in bucket '{source_bucket}'", + ) + raise + + # Ensure destination bucket exists + if not await run_sync(partial(client.bucket_exists, dest_bucket)): + return CopyObjectResponse( + success=False, + error=f"Destination bucket '{dest_bucket}' does not exist", + ) + + # Copy object + result = await run_sync( + partial( + client.copy_object, + dest_bucket, + dest_object, + CopySource(source_bucket, source_object), + ) + ) + + return CopyObjectResponse( + success=True, + data=CopyObjectData( + source=f"{source_bucket}/{source_object}", + destination=f"{dest_bucket}/{dest_object}", + etag=result.etag, + version_id=result.version_id, + ), + ) + except S3Error as e: + logger.error( + "Failed to copy '%s/%s' to '%s/%s': %s", + source_bucket, + source_object, + dest_bucket, + dest_object, + e, + ) + return CopyObjectResponse(success=False, error=str(e)) + except ValueError as e: + return CopyObjectResponse(success=False, error=str(e)) + + +# ============================================================================= +# Utilities +# ============================================================================= + + +@tool() +async def get_object_metadata( + bucket: str, object_name: str +) -> GetObjectMetadataResponse: + """Get metadata for an object without downloading its content. + + Retrieves object metadata including size, content type, modification time, + ETag, and any custom user-defined metadata. + + Args: + bucket: Name of the bucket containing the object. + object_name: Object key/path to get metadata for. + + Returns: + GetObjectMetadataResponse: Response containing object metadata. + On success: success=True, data=GetObjectMetadataData(bucket, object_name, size, last_modified, etag, content_type, version_id, metadata) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return GetObjectMetadataResponse(success=False, error=error) + if error := validate_object_name(object_name): + return GetObjectMetadataResponse(success=False, error=error) + + try: + client = get_client() + stat = await run_sync(partial(client.stat_object, bucket, object_name)) + + return GetObjectMetadataResponse( + success=True, + data=GetObjectMetadataData( + bucket=bucket, + object_name=object_name, + size=stat.size, + last_modified=stat.last_modified.isoformat() + if stat.last_modified + else None, + etag=stat.etag, + content_type=stat.content_type, + version_id=stat.version_id, + metadata=dict(stat.metadata) if stat.metadata else {}, + ), + ) + except S3Error as e: + if e.code == "NoSuchKey": + return GetObjectMetadataResponse( + success=False, + error=f"Object '{object_name}' not found in bucket '{bucket}'", + ) + logger.error("Failed to get metadata for '%s/%s': %s", bucket, object_name, e) + return GetObjectMetadataResponse(success=False, error=str(e)) + except ValueError as e: + return GetObjectMetadataResponse(success=False, error=str(e)) + + +@tool() +async def get_presigned_url( + bucket: str, + object_name: str, + expires_hours: int = 1, +) -> GetPresignedUrlResponse: + """Generate a presigned URL for temporary object access. + + Creates a time-limited URL that allows downloading an object without + authentication. Useful for sharing files temporarily or generating + download links for end users. + + Args: + bucket: Name of the bucket containing the object. + object_name: Object key/path to generate URL for. + expires_hours: URL validity period in hours. Must be between 1 and 168 + (7 days). Defaults to 1 hour. + + Returns: + GetPresignedUrlResponse: Response containing the presigned URL. + On success: success=True, data=GetPresignedUrlData(bucket, object_name, url, expires_in_hours) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return GetPresignedUrlResponse(success=False, error=error) + if error := validate_object_name(object_name): + return GetPresignedUrlResponse(success=False, error=error) + + if expires_hours < 1 or expires_hours > 168: # Max 7 days + return GetPresignedUrlResponse( + success=False, + error="expires_hours must be between 1 and 168 (7 days)", + ) + + try: + client = get_client() + + # Verify object exists + try: + await run_sync(partial(client.stat_object, bucket, object_name)) + except S3Error as e: + if e.code == "NoSuchKey": + return GetPresignedUrlResponse( + success=False, + error=f"Object '{object_name}' not found in bucket '{bucket}'", + ) + raise + + url = await run_sync( + partial( + client.presigned_get_object, + bucket, + object_name, + expires=timedelta(hours=expires_hours), + ) + ) + + return GetPresignedUrlResponse( + success=True, + data=GetPresignedUrlData( + bucket=bucket, + object_name=object_name, + url=url, + expires_in_hours=expires_hours, + ), + ) + except S3Error as e: + logger.error( + "Failed to generate presigned URL for '%s/%s': %s", bucket, object_name, e + ) + return GetPresignedUrlResponse(success=False, error=str(e)) + except ValueError as e: + return GetPresignedUrlResponse(success=False, error=str(e)) diff --git a/src/tools/storage/schemas.py b/src/tools/storage/schemas.py new file mode 100644 index 0000000..bbd48ae --- /dev/null +++ b/src/tools/storage/schemas.py @@ -0,0 +1,245 @@ +"""Pydantic output schemas for MinIO storage tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Data Models for Response Fields +# ============================================================================= + + +class BucketInfo(BaseModel): + """Information about a bucket.""" + + name: str = Field(..., description="Bucket name") + creation_date: str | None = Field( + None, description="Bucket creation date in ISO format" + ) + + +class ObjectInfo(BaseModel): + """Information about an object in a bucket.""" + + name: str = Field(..., description="Object name/path") + size: int | None = Field(None, description="Object size in bytes") + last_modified: str | None = Field( + None, description="Last modification date in ISO format" + ) + etag: str | None = Field(None, description="Object ETag") + is_dir: bool = Field(False, description="Whether this is a directory marker") + + +# ============================================================================= +# Output Data Schemas (the 'data' field content) +# ============================================================================= + + +class ListBucketsData(BaseModel): + """Output data for list_buckets tool.""" + + buckets: list[BucketInfo] = Field(..., description="List of accessible buckets") + + +class CreateBucketData(BaseModel): + """Output data for create_bucket tool.""" + + bucket: str = Field(..., description="Name of the created bucket") + created: bool = Field(True, description="Whether the bucket was created") + + +class DeleteBucketData(BaseModel): + """Output data for delete_bucket tool.""" + + bucket: str = Field(..., description="Name of the deleted bucket") + deleted: bool = Field(True, description="Whether the bucket was deleted") + + +class BucketExistsData(BaseModel): + """Output data for bucket_exists tool.""" + + bucket: str = Field(..., description="Bucket name that was checked") + exists: bool = Field(..., description="Whether the bucket exists") + + +class ListObjectsData(BaseModel): + """Output data for list_objects tool.""" + + bucket: str = Field(..., description="Bucket name") + prefix: str = Field("", description="Prefix filter used") + recursive: bool = Field(True, description="Whether listing was recursive") + objects: list[ObjectInfo] = Field(..., description="List of objects") + count: int = Field(..., description="Number of objects returned") + + +class UploadContentData(BaseModel): + """Output data for upload_content tool.""" + + bucket: str = Field(..., description="Destination bucket name") + object_name: str = Field(..., description="Object key/path") + size: int = Field(..., description="Size of uploaded content in bytes") + etag: str = Field(..., description="ETag of the uploaded object") + version_id: str | None = Field( + None, description="Version ID if versioning is enabled" + ) + + +class UploadFromPathData(BaseModel): + """Output data for upload_from_path tool.""" + + bucket: str = Field(..., description="Destination bucket name") + object_name: str = Field(..., description="Object key/path") + file_path: str = Field(..., description="Source file path") + size: int = Field(..., description="Size of uploaded file in bytes") + etag: str = Field(..., description="ETag of the uploaded object") + version_id: str | None = Field( + None, description="Version ID if versioning is enabled" + ) + + +class DownloadContentData(BaseModel): + """Output data for download_content tool.""" + + bucket: str = Field(..., description="Source bucket name") + object_name: str = Field(..., description="Object key/path") + content_base64: str = Field(..., description="Base64-encoded file content") + size: int = Field(..., description="Size of content in bytes") + content_type: str = Field(..., description="MIME type of the content") + etag: str = Field(..., description="ETag of the object") + + +class DownloadToPathData(BaseModel): + """Output data for download_to_path tool.""" + + bucket: str = Field(..., description="Source bucket name") + object_name: str = Field(..., description="Object key/path") + file_path: str = Field(..., description="Destination file path") + size: int = Field(..., description="Size of downloaded file in bytes") + + +class DeleteObjectData(BaseModel): + """Output data for delete_object tool.""" + + bucket: str = Field(..., description="Bucket name") + object_name: str = Field(..., description="Deleted object key/path") + deleted: bool = Field(True, description="Whether the object was deleted") + + +class CopyObjectData(BaseModel): + """Output data for copy_object tool.""" + + source: str = Field(..., description="Source path (bucket/object)") + destination: str = Field(..., description="Destination path (bucket/object)") + etag: str = Field(..., description="ETag of the copied object") + version_id: str | None = Field( + None, description="Version ID if versioning is enabled" + ) + + +class GetObjectMetadataData(BaseModel): + """Output data for get_object_metadata tool.""" + + bucket: str = Field(..., description="Bucket name") + object_name: str = Field(..., description="Object key/path") + size: int = Field(..., description="Object size in bytes") + last_modified: str | None = Field( + None, description="Last modification date in ISO format" + ) + etag: str = Field(..., description="Object ETag") + content_type: str = Field(..., description="MIME type of the content") + version_id: str | None = Field(None, description="Version ID") + metadata: dict[str, str] = Field( + default_factory=dict, description="Custom user metadata" + ) + + +class GetPresignedUrlData(BaseModel): + """Output data for get_presigned_url tool.""" + + bucket: str = Field(..., description="Bucket name") + object_name: str = Field(..., description="Object key/path") + url: str = Field(..., description="Presigned URL for downloading the object") + expires_in_hours: int = Field(..., description="URL validity period in hours") + + +# ============================================================================= +# Full Response Schemas (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ListBucketsResponse(ToolResponse[ListBucketsData]): + """Response schema for list_buckets tool.""" + + pass + + +class CreateBucketResponse(ToolResponse[CreateBucketData]): + """Response schema for create_bucket tool.""" + + pass + + +class DeleteBucketResponse(ToolResponse[DeleteBucketData]): + """Response schema for delete_bucket tool.""" + + pass + + +class BucketExistsResponse(ToolResponse[BucketExistsData]): + """Response schema for bucket_exists tool.""" + + pass + + +class ListObjectsResponse(ToolResponse[ListObjectsData]): + """Response schema for list_objects tool.""" + + pass + + +class UploadContentResponse(ToolResponse[UploadContentData]): + """Response schema for upload_content tool.""" + + pass + + +class UploadFromPathResponse(ToolResponse[UploadFromPathData]): + """Response schema for upload_from_path tool.""" + + pass + + +class DownloadContentResponse(ToolResponse[DownloadContentData]): + """Response schema for download_content tool.""" + + pass + + +class DownloadToPathResponse(ToolResponse[DownloadToPathData]): + """Response schema for download_to_path tool.""" + + pass + + +class DeleteObjectResponse(ToolResponse[DeleteObjectData]): + """Response schema for delete_object tool.""" + + pass + + +class CopyObjectResponse(ToolResponse[CopyObjectData]): + """Response schema for copy_object tool.""" + + pass + + +class GetObjectMetadataResponse(ToolResponse[GetObjectMetadataData]): + """Response schema for get_object_metadata tool.""" + + pass + + +class GetPresignedUrlResponse(ToolResponse[GetPresignedUrlData]): + """Response schema for get_presigned_url tool.""" + + pass diff --git a/uv.lock b/uv.lock index 9848366..3582750 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.13" resolution-markers = [ - "sys_platform == 'win32'", - "sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", ] [[package]] @@ -48,6 +50,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "arize-otel" version = "0.11.0" @@ -949,6 +994,7 @@ dependencies = [ { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "markitdown", extra = ["all"] }, + { name = "minio" }, { name = "pandas" }, { name = "pydantic" }, { name = "tavily-python" }, @@ -977,6 +1023,7 @@ requires-dist = [ { name = "google-auth-httplib2", specifier = ">=0.2.0" }, { name = "google-auth-oauthlib", specifier = ">=1.2.2" }, { name = "markitdown", extras = ["all"], specifier = ">=0.1.4" }, + { name = "minio", specifier = ">=7.0.0" }, { name = "pandas", specifier = ">=2.3.3" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "tavily-python", specifier = ">=0.7.13" }, @@ -1444,6 +1491,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -2089,6 +2152,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" From 948ad142a8e7df569899a52c6f4eabdbc3c4813b Mon Sep 17 00:00:00 2001 From: Maska Chung Date: Mon, 9 Mar 2026 18:27:11 +0000 Subject: [PATCH 03/11] refactor repo from graphite build --- pyproject.toml | 4 + src/apps/.gitkeep | 0 src/humcp/auth.py | 35 + src/humcp/decorator.py | 28 +- src/humcp/middleware.py | 83 + src/humcp/permissions.py | 55 + src/humcp/playground.py | 495 +++ src/humcp/routes.py | 34 +- src/humcp/server.py | 207 +- src/tools/messaging/SKILL.md | 110 + src/tools/messaging/__init__.py | 0 src/tools/messaging/discord.py | 411 ++ src/tools/messaging/schemas.py | 393 ++ src/tools/messaging/slack.py | 588 +++ src/tools/messaging/telegram.py | 436 +++ src/tools/messaging/whatsapp.py | 311 ++ src/tools/social/SKILL.md | 72 + src/tools/social/__init__.py | 0 src/tools/social/schemas.py | 110 + src/tools/social/x.py | 400 ++ toolset/.dockerignore | 76 + toolset/.env.example | 59 + toolset/.python-version | 1 + toolset/.vscode/settings.json | 4 + toolset/CLAUDE.md | 125 + toolset/Dockerfile | 52 + toolset/Dockerfile.dockerignore | 20 + toolset/LICENSE | 21 + toolset/README.md | 482 +++ toolset/config/tools.yaml | 101 + toolset/docker/Dockerfile | 30 + toolset/pyproject.toml | 146 + toolset/src/__init__.py | 0 toolset/src/apps/api/http_request.html | 208 + .../src/apps/audio/cartesia_list_voices.html | 169 + .../apps/audio/cartesia_text_to_speech.html | 149 + .../apps/audio/desi_vocal_list_voices.html | 187 + toolset/src/apps/audio/desi_vocal_tts.html | 130 + .../apps/audio/elevenlabs_list_voices.html | 153 + .../apps/audio/elevenlabs_text_to_speech.html | 156 + toolset/src/apps/audio/mlx_transcribe.html | 139 + .../src/apps/audio/spotify_get_playlist.html | 183 + toolset/src/apps/audio/spotify_get_track.html | 165 + toolset/src/apps/audio/spotify_search.html | 187 + .../src/apps/calculator/absolute_value.html | 114 + toolset/src/apps/calculator/add.html | 122 + toolset/src/apps/calculator/divide.html | 119 + toolset/src/apps/calculator/exponentiate.html | 115 + toolset/src/apps/calculator/factorial.html | 144 + .../calculator/greatest_common_divisor.html | 128 + toolset/src/apps/calculator/is_prime.html | 145 + toolset/src/apps/calculator/logarithm.html | 128 + toolset/src/apps/calculator/modulo.html | 109 + toolset/src/apps/calculator/multiply.html | 113 + toolset/src/apps/calculator/square_root.html | 111 + toolset/src/apps/calculator/subtract.html | 112 + .../apps/calendar/calcom_create_booking.html | 146 + .../calendar/calcom_get_availability.html | 160 + .../apps/calendar/calcom_list_bookings.html | 183 + .../apps/calendar/zoom_create_meeting.html | 156 + .../src/apps/calendar/zoom_get_meeting.html | 182 + .../src/apps/calendar/zoom_list_meetings.html | 193 + .../src/apps/cloud/airflow_get_dag_run.html | 165 + toolset/src/apps/cloud/airflow_list_dags.html | 169 + .../src/apps/cloud/airflow_trigger_dag.html | 156 + toolset/src/apps/cloud/aws_lambda_invoke.html | 147 + .../apps/cloud/aws_lambda_list_functions.html | 163 + .../src/apps/cloud/aws_ses_send_email.html | 136 + .../apps/cloud/daytona_create_workspace.html | 130 + .../apps/cloud/daytona_list_workspaces.html | 172 + .../src/apps/cloud/daytona_run_command.html | 137 + .../apps/cloud/docker_list_containers.html | 185 + .../src/apps/cloud/docker_run_container.html | 136 + .../src/apps/cloud/docker_stop_container.html | 130 + toolset/src/apps/cloud/e2b_run_code.html | 159 + toolset/src/apps/cloud/e2b_run_command.html | 142 + toolset/src/apps/data/query_csv_file.html | 141 + toolset/src/apps/data/read_csv_file.html | 150 + toolset/src/apps/database/describe_table.html | 141 + .../src/apps/database/duckdb_list_tables.html | 148 + toolset/src/apps/database/duckdb_query.html | 140 + .../src/apps/database/duckdb_read_file.html | 86 + toolset/src/apps/database/execute_query.html | 140 + toolset/src/apps/database/list_tables.html | 138 + .../src/apps/database/neo4j_get_schema.html | 171 + toolset/src/apps/database/neo4j_query.html | 148 + toolset/src/apps/database/redshift_query.html | 149 + toolset/src/apps/database/sql_query.html | 150 + .../apps/ecommerce/brandfetch_get_brand.html | 241 ++ .../ecommerce/shopify_create_product.html | 206 + .../apps/ecommerce/shopify_get_customers.html | 180 + .../apps/ecommerce/shopify_get_product.html | 228 ++ .../apps/ecommerce/shopify_list_orders.html | 189 + .../apps/ecommerce/shopify_list_products.html | 179 + .../apps/files/convert_pdf_to_markdown.html | 143 + toolset/src/apps/files/markdown_to_table.html | 204 + toolset/src/apps/finance/evm_get_balance.html | 131 + toolset/src/apps/finance/evm_get_block.html | 150 + .../src/apps/finance/evm_get_gas_price.html | 126 + .../src/apps/finance/evm_get_transaction.html | 169 + .../financial_datasets_get_financials.html | 158 + ...financial_datasets_get_insider_trades.html | 202 + .../financial_datasets_get_prices.html | 154 + .../apps/finance/openbb_get_market_news.html | 179 + .../apps/finance/openbb_get_stock_data.html | 154 + .../apps/finance/openbb_search_stocks.html | 128 + .../apps/finance/yfinance_get_dividends.html | 146 + .../finance/yfinance_get_historical_data.html | 155 + .../apps/finance/yfinance_get_options.html | 192 + .../apps/finance/yfinance_get_stock_info.html | 181 + .../finance/yfinance_get_stock_price.html | 170 + .../google/google_bigquery_list_datasets.html | 138 + .../google/google_bigquery_list_tables.html | 161 + .../apps/google/google_bigquery_query.html | 158 + .../apps/google/google_maps_directions.html | 175 + .../src/apps/google/google_maps_geocode.html | 148 + .../google/google_maps_reverse_geocode.html | 142 + .../google/google_maps_search_places.html | 152 + .../apps/image/extract_text_from_image.html | 148 + .../src/apps/local/execute_shell_command.html | 157 + .../src/apps/media/dalle_generate_image.html | 157 + toolset/src/apps/media/fal_run_model.html | 168 + toolset/src/apps/media/giphy_random.html | 150 + toolset/src/apps/media/giphy_search.html | 150 + toolset/src/apps/media/giphy_trending.html | 147 + .../apps/media/lumalab_generate_video.html | 148 + .../media/models_labs_generate_image.html | 168 + .../src/apps/media/moviepy_create_video.html | 124 + .../src/apps/media/moviepy_trim_video.html | 131 + toolset/src/apps/media/nano_banana_run.html | 172 + .../src/apps/media/opencv_convert_format.html | 124 + toolset/src/apps/media/opencv_crop_image.html | 119 + .../src/apps/media/opencv_resize_image.html | 122 + .../src/apps/media/opencv_rotate_image.html | 119 + .../apps/media/replicate_get_prediction.html | 179 + .../src/apps/media/replicate_run_model.html | 168 + .../apps/media/replicate_search_models.html | 158 + .../src/apps/media/unsplash_get_photo.html | 158 + .../apps/media/unsplash_get_random_photo.html | 186 + .../apps/media/unsplash_search_photos.html | 168 + toolset/src/apps/memory/mem0_add_memory.html | 168 + .../src/apps/memory/mem0_get_memories.html | 160 + .../src/apps/memory/mem0_search_memory.html | 162 + toolset/src/apps/memory/zep_add_memory.html | 197 + toolset/src/apps/memory/zep_get_session.html | 183 + .../src/apps/memory/zep_search_memory.html | 182 + .../apps/messaging/discord_add_reaction.html | 117 + .../apps/messaging/discord_get_messages.html | 128 + .../apps/messaging/discord_list_channels.html | 159 + .../apps/messaging/discord_send_message.html | 149 + .../src/apps/messaging/resend_send_email.html | 149 + toolset/src/apps/messaging/send_email.html | 149 + .../apps/messaging/slack_add_reaction.html | 117 + .../messaging/slack_get_channel_history.html | 155 + .../messaging/slack_get_thread_replies.html | 142 + .../apps/messaging/slack_get_user_info.html | 123 + .../apps/messaging/slack_list_channels.html | 159 + .../apps/messaging/slack_search_messages.html | 178 + .../apps/messaging/slack_send_message.html | 154 + .../apps/messaging/telegram_edit_message.html | 126 + .../apps/messaging/telegram_get_updates.html | 178 + .../apps/messaging/telegram_send_message.html | 149 + .../apps/messaging/telegram_send_photo.html | 124 + .../apps/messaging/twilio_get_sms_status.html | 145 + .../src/apps/messaging/twilio_make_call.html | 118 + .../src/apps/messaging/twilio_send_sms.html | 152 + .../src/apps/messaging/webex_create_room.html | 119 + .../src/apps/messaging/webex_list_rooms.html | 159 + .../apps/messaging/webex_send_message.html | 149 + .../apps/messaging/whatsapp_send_message.html | 149 + .../bitbucket_create_pr.html | 159 + .../bitbucket_get_repo.html | 149 + .../bitbucket_list_repos.html | 165 + .../clickup_create_task.html | 158 + .../project_management/clickup_get_task.html | 155 + .../confluence_create_page.html | 157 + .../confluence_get_page.html | 154 + .../project_management/confluence_search.html | 168 + .../github_create_issue.html | 168 + .../project_management/github_get_repo.html | 161 + .../github_list_commits.html | 167 + .../github_list_issues.html | 182 + .../github_list_pull_requests.html | 181 + .../github_list_releases.html | 174 + .../project_management/jira_create_issue.html | 172 + .../project_management/jira_get_issue.html | 176 + .../jira_search_issues.html | 177 + .../linear_create_issue.html | 167 + .../project_management/linear_get_issue.html | 164 + .../linear_list_issues.html | 176 + .../notion_create_page.html | 150 + .../project_management/notion_get_page.html | 147 + .../project_management/notion_search.html | 153 + .../todoist_create_task.html | 180 + .../project_management/todoist_get_tasks.html | 171 + .../trello_create_card.html | 150 + .../project_management/trello_get_boards.html | 164 + .../zendesk_create_ticket.html | 175 + .../zendesk_get_ticket.html | 182 + .../zendesk_search_tickets.html | 188 + .../src/apps/research/arxiv_read_paper.html | 212 ++ toolset/src/apps/research/arxiv_search.html | 181 + .../src/apps/research/pubmed_get_article.html | 267 ++ toolset/src/apps/research/pubmed_search.html | 194 + .../src/apps/research/wikipedia_get_page.html | 172 + .../src/apps/research/wikipedia_search.html | 144 + toolset/src/apps/search/baidu_search.html | 145 + .../src/apps/search/brave_image_search.html | 86 + .../src/apps/search/brave_news_search.html | 122 + toolset/src/apps/search/brave_web_search.html | 145 + .../src/apps/search/duckduckgo_images.html | 77 + toolset/src/apps/search/duckduckgo_news.html | 140 + .../src/apps/search/duckduckgo_search.html | 145 + toolset/src/apps/search/exa_find_similar.html | 92 + toolset/src/apps/search/exa_search.html | 164 + toolset/src/apps/search/searxng_search.html | 145 + toolset/src/apps/search/seltz_search.html | 151 + toolset/src/apps/search/serpapi_search.html | 210 ++ .../src/apps/search/serper_google_search.html | 145 + .../src/apps/search/serper_image_search.html | 76 + .../src/apps/search/serper_news_search.html | 78 + toolset/src/apps/search/tavily_extract.html | 83 + .../src/apps/search/tavily_web_search.html | 149 + toolset/src/apps/search/valyu_search.html | 162 + .../apps/social/hackernews_get_comments.html | 134 + .../src/apps/social/hackernews_get_story.html | 202 + .../social/hackernews_get_top_stories.html | 193 + .../src/apps/social/hackernews_get_user.html | 146 + .../src/apps/social/hackernews_search.html | 203 + .../social/hackernews_search_by_date.html | 152 + .../src/apps/social/reddit_get_comments.html | 116 + .../src/apps/social/reddit_get_hot_posts.html | 139 + toolset/src/apps/social/reddit_get_post.html | 197 + .../social/reddit_get_subreddit_info.html | 155 + .../src/apps/social/reddit_get_top_posts.html | 158 + .../src/apps/social/reddit_search_posts.html | 186 + toolset/src/apps/social/x_get_user.html | 167 + toolset/src/apps/social/x_post_tweet.html | 147 + toolset/src/apps/social/x_search_tweets.html | 181 + .../src/apps/social/youtube_get_captions.html | 167 + .../apps/social/youtube_get_video_info.html | 222 ++ toolset/src/apps/social/youtube_search.html | 200 + .../src/apps/storage/get_object_metadata.html | 166 + .../src/apps/storage/get_presigned_url.html | 138 + toolset/src/apps/storage/list_buckets.html | 140 + toolset/src/apps/storage/list_objects.html | 179 + .../apps/weather/openweather_get_current.html | 215 ++ .../weather/openweather_get_forecast.html | 230 ++ .../src/apps/web_scraping/agentql_query.html | 128 + .../apps/web_scraping/apify_run_actor.html | 205 + .../apps/web_scraping/brightdata_scrape.html | 165 + .../apps/web_scraping/browserbase_scrape.html | 165 + .../apps/web_scraping/crawl4ai_scrape.html | 165 + .../apps/web_scraping/firecrawl_crawl.html | 177 + .../src/apps/web_scraping/firecrawl_map.html | 134 + .../apps/web_scraping/firecrawl_scrape.html | 165 + .../apps/web_scraping/firecrawl_search.html | 151 + .../src/apps/web_scraping/jina_reader.html | 165 + .../src/apps/web_scraping/jina_search.html | 162 + .../src/apps/web_scraping/linkup_search.html | 162 + .../apps/web_scraping/newspaper_extract.html | 165 + .../src/apps/web_scraping/oxylabs_scrape.html | 165 + .../web_scraping/scrapegraph_markdownify.html | 165 + .../apps/web_scraping/scrapegraph_scrape.html | 128 + .../apps/web_scraping/scrapegraph_search.html | 119 + .../src/apps/web_scraping/spider_crawl.html | 176 + .../src/apps/web_scraping/spider_links.html | 134 + .../src/apps/web_scraping/spider_scrape.html | 165 + .../src/apps/web_scraping/spider_search.html | 157 + .../web_scraping/trafilatura_extract.html | 165 + toolset/src/humcp/__init__.py | 12 + toolset/src/humcp/auth.py | 40 + toolset/src/humcp/config.py | 268 ++ toolset/src/humcp/decorator.py | 108 + toolset/src/humcp/middleware.py | 83 + toolset/src/humcp/permissions.py | 187 + toolset/src/humcp/playground.py | 495 +++ toolset/src/humcp/routes.py | 220 ++ toolset/src/humcp/schemas.py | 180 + toolset/src/humcp/server.py | 277 ++ toolset/src/humcp/skills.py | 128 + toolset/src/humcp/storage_path.py | 201 + toolset/src/logging_setup.py | 47 + toolset/src/main.py | 22 + toolset/src/tools/__init__.py | 41 + toolset/src/tools/api/SKILL.md | 90 + toolset/src/tools/api/__init__.py | 1 + toolset/src/tools/api/http_client.py | 138 + toolset/src/tools/api/schemas.py | 37 + toolset/src/tools/audio/SKILL.md | 131 + toolset/src/tools/audio/__init__.py | 1 + toolset/src/tools/audio/cartesia.py | 148 + toolset/src/tools/audio/desi_vocal.py | 129 + toolset/src/tools/audio/eleven_labs.py | 141 + toolset/src/tools/audio/mlx_transcribe.py | 103 + toolset/src/tools/audio/schemas.py | 313 ++ toolset/src/tools/audio/spotify.py | 386 ++ toolset/src/tools/builder/SKILL.md | 173 + toolset/src/tools/builder/__init__.py | 1 + toolset/src/tools/builder/manager.py | 181 + toolset/src/tools/builder/sandbox.py | 278 ++ toolset/src/tools/builder/schemas.py | 93 + toolset/src/tools/builder/storage.py | 155 + toolset/src/tools/builder/tools.py | 412 ++ toolset/src/tools/calendar/SKILL.md | 93 + toolset/src/tools/calendar/__init__.py | 1 + toolset/src/tools/calendar/calcom.py | 466 +++ toolset/src/tools/calendar/schemas.py | 258 ++ toolset/src/tools/calendar/zoom.py | 474 +++ toolset/src/tools/cloud/SKILL.md | 182 + toolset/src/tools/cloud/__init__.py | 1 + toolset/src/tools/cloud/airflow.py | 427 +++ toolset/src/tools/cloud/aws_lambda.py | 348 ++ toolset/src/tools/cloud/aws_ses.py | 292 ++ toolset/src/tools/cloud/daytona.py | 390 ++ toolset/src/tools/cloud/docker.py | 339 ++ toolset/src/tools/cloud/e2b.py | 331 ++ toolset/src/tools/cloud/schemas.py | 751 ++++ toolset/src/tools/data/SKILL.md | 100 + toolset/src/tools/data/csv.py | 462 +++ toolset/src/tools/data/pandas.py | 619 +++ toolset/src/tools/data/schemas.py | 209 ++ toolset/src/tools/database/SKILL.md | 79 + toolset/src/tools/database/__init__.py | 0 toolset/src/tools/database/duckdb.py | 211 ++ toolset/src/tools/database/neo4j.py | 147 + toolset/src/tools/database/postgres.py | 284 ++ toolset/src/tools/database/redshift.py | 128 + toolset/src/tools/database/schemas.py | 241 ++ toolset/src/tools/database/sql.py | 99 + toolset/src/tools/ecommerce/SKILL.md | 86 + toolset/src/tools/ecommerce/__init__.py | 1 + toolset/src/tools/ecommerce/brandfetch.py | 125 + toolset/src/tools/ecommerce/schemas.py | 192 + toolset/src/tools/ecommerce/shopify.py | 433 +++ toolset/src/tools/files/SKILL.md | 88 + toolset/src/tools/files/markdown_table.py | 180 + toolset/src/tools/files/pdf_to_markdown.py | 69 + toolset/src/tools/files/schemas.py | 50 + toolset/src/tools/finance/SKILL.md | 85 + toolset/src/tools/finance/__init__.py | 1 + toolset/src/tools/finance/evm.py | 358 ++ .../src/tools/finance/financial_datasets.py | 248 ++ toolset/src/tools/finance/openbb.py | 172 + toolset/src/tools/finance/schemas.py | 397 ++ toolset/src/tools/finance/yfinance_tool.py | 362 ++ toolset/src/tools/google/README.md | 196 + toolset/src/tools/google/SKILL.md | 185 + toolset/src/tools/google/__init__.py | 4 + toolset/src/tools/google/auth.py | 234 ++ toolset/src/tools/google/calendar.py | 283 ++ toolset/src/tools/google/chat.py | 242 ++ toolset/src/tools/google/docs.py | 316 ++ toolset/src/tools/google/drive.py | 355 ++ toolset/src/tools/google/forms.py | 306 ++ toolset/src/tools/google/gmail.py | 337 ++ toolset/src/tools/google/google_bigquery.py | 228 ++ toolset/src/tools/google/google_maps.py | 307 ++ toolset/src/tools/google/schemas/__init__.py | 345 ++ toolset/src/tools/google/schemas/bigquery.py | 132 + toolset/src/tools/google/schemas/calendar.py | 99 + toolset/src/tools/google/schemas/chat.py | 105 + toolset/src/tools/google/schemas/docs.py | 98 + toolset/src/tools/google/schemas/drive.py | 108 + toolset/src/tools/google/schemas/forms.py | 132 + toolset/src/tools/google/schemas/gmail.py | 113 + toolset/src/tools/google/schemas/maps.py | 172 + toolset/src/tools/google/schemas/sheets.py | 142 + toolset/src/tools/google/schemas/slides.py | 121 + toolset/src/tools/google/schemas/tasks.py | 157 + toolset/src/tools/google/sheets.py | 406 ++ toolset/src/tools/google/slides.py | 382 ++ toolset/src/tools/google/tasks.py | 452 +++ toolset/src/tools/image/SKILL.md | 79 + toolset/src/tools/image/__init__.py | 1 + toolset/src/tools/image/ocr.py | 109 + toolset/src/tools/image/schemas.py | 24 + toolset/src/tools/local/SKILL.md | 146 + toolset/src/tools/local/calculator.py | 162 + toolset/src/tools/local/local_file_system.py | 669 ++++ toolset/src/tools/local/schemas.py | 328 ++ toolset/src/tools/local/shell.py | 448 +++ toolset/src/tools/media/SKILL.md | 126 + toolset/src/tools/media/__init__.py | 1 + toolset/src/tools/media/dalle.py | 125 + toolset/src/tools/media/fal.py | 122 + toolset/src/tools/media/giphy.py | 231 ++ toolset/src/tools/media/lumalab.py | 140 + toolset/src/tools/media/models_labs.py | 126 + toolset/src/tools/media/moviepy_video.py | 209 ++ toolset/src/tools/media/nano_banana.py | 120 + toolset/src/tools/media/opencv.py | 420 +++ toolset/src/tools/media/replicate.py | 273 ++ toolset/src/tools/media/schemas.py | 429 +++ toolset/src/tools/media/unsplash.py | 287 ++ toolset/src/tools/memory/SKILL.md | 97 + toolset/src/tools/memory/__init__.py | 1 + toolset/src/tools/memory/mem0.py | 250 ++ toolset/src/tools/memory/schemas.py | 129 + toolset/src/tools/memory/zep.py | 272 ++ toolset/src/tools/messaging/SKILL.md | 158 + toolset/src/tools/messaging/__init__.py | 1 + toolset/src/tools/messaging/discord.py | 411 ++ toolset/src/tools/messaging/email_smtp.py | 137 + toolset/src/tools/messaging/resend.py | 197 + toolset/src/tools/messaging/schemas.py | 618 +++ toolset/src/tools/messaging/slack.py | 588 +++ toolset/src/tools/messaging/telegram.py | 436 +++ toolset/src/tools/messaging/twilio.py | 257 ++ toolset/src/tools/messaging/webex.py | 296 ++ toolset/src/tools/messaging/whatsapp.py | 311 ++ toolset/src/tools/project_management/SKILL.md | 97 + .../src/tools/project_management/__init__.py | 0 .../src/tools/project_management/bitbucket.py | 391 ++ .../src/tools/project_management/clickup.py | 329 ++ .../tools/project_management/confluence.py | 389 ++ .../src/tools/project_management/github.py | 637 ++++ toolset/src/tools/project_management/jira.py | 428 +++ .../src/tools/project_management/linear.py | 489 +++ .../src/tools/project_management/notion.py | 505 +++ .../src/tools/project_management/schemas.py | 1072 ++++++ .../src/tools/project_management/todoist.py | 389 ++ .../src/tools/project_management/trello.py | 358 ++ .../src/tools/project_management/zendesk.py | 415 ++ toolset/src/tools/research/SKILL.md | 101 + toolset/src/tools/research/__init__.py | 1 + toolset/src/tools/research/arxiv.py | 267 ++ toolset/src/tools/research/pubmed.py | 357 ++ toolset/src/tools/research/schemas.py | 237 ++ toolset/src/tools/research/wikipedia.py | 223 ++ toolset/src/tools/search/SKILL.md | 176 + toolset/src/tools/search/baidu.py | 73 + toolset/src/tools/search/brave.py | 257 ++ toolset/src/tools/search/duckduckgo.py | 228 ++ toolset/src/tools/search/exa.py | 176 + toolset/src/tools/search/schemas.py | 411 ++ toolset/src/tools/search/searxng.py | 104 + toolset/src/tools/search/seltz.py | 111 + toolset/src/tools/search/serpapi.py | 96 + toolset/src/tools/search/serper.py | 218 ++ toolset/src/tools/search/tavily_tool.py | 214 ++ toolset/src/tools/search/valyu.py | 121 + toolset/src/tools/social/SKILL.md | 135 + toolset/src/tools/social/__init__.py | 1 + toolset/src/tools/social/hackernews.py | 374 ++ toolset/src/tools/social/reddit.py | 394 ++ toolset/src/tools/social/schemas.py | 545 +++ toolset/src/tools/social/x.py | 400 ++ toolset/src/tools/social/youtube.py | 443 +++ toolset/src/tools/storage/SKILL.md | 149 + toolset/src/tools/storage/__init__.py | 1 + toolset/src/tools/storage/bucket_tools.py | 185 + toolset/src/tools/storage/client.py | 208 + toolset/src/tools/storage/metadata_tools.py | 161 + toolset/src/tools/storage/object_tools.py | 566 +++ toolset/src/tools/storage/schemas.py | 245 ++ toolset/src/tools/storage/tools.py | 87 + toolset/src/tools/weather/SKILL.md | 82 + toolset/src/tools/weather/__init__.py | 1 + toolset/src/tools/weather/openweather.py | 435 +++ toolset/src/tools/weather/schemas.py | 174 + toolset/src/tools/web_scraping/SKILL.md | 178 + toolset/src/tools/web_scraping/__init__.py | 0 toolset/src/tools/web_scraping/agentql.py | 105 + toolset/src/tools/web_scraping/apify.py | 92 + toolset/src/tools/web_scraping/brightdata.py | 93 + toolset/src/tools/web_scraping/browserbase.py | 129 + toolset/src/tools/web_scraping/crawl4ai.py | 267 ++ toolset/src/tools/web_scraping/firecrawl.py | 301 ++ toolset/src/tools/web_scraping/jina.py | 186 + toolset/src/tools/web_scraping/linkup.py | 94 + toolset/src/tools/web_scraping/newspaper.py | 107 + toolset/src/tools/web_scraping/oxylabs.py | 109 + toolset/src/tools/web_scraping/schemas.py | 231 ++ toolset/src/tools/web_scraping/scrapegraph.py | 189 + toolset/src/tools/web_scraping/spider.py | 266 ++ toolset/src/tools/web_scraping/trafilatura.py | 115 + toolset/tests/conftest.py | 67 + toolset/tests/humcp/__init__.py | 1 + toolset/tests/humcp/test_apps.py | 227 ++ toolset/tests/humcp/test_auth.py | 63 + toolset/tests/humcp/test_config.py | 383 ++ toolset/tests/humcp/test_decorator.py | 202 + toolset/tests/humcp/test_models.py | 129 + toolset/tests/humcp/test_permissions.py | 186 + toolset/tests/humcp/test_routes.py | 192 + toolset/tests/humcp/test_server.py | 111 + toolset/tests/humcp/test_skills.py | 254 ++ toolset/tests/humcp/test_storage_path.py | 79 + toolset/tests/tools/builder/__init__.py | 1 + toolset/tests/tools/builder/test_builder.py | 521 +++ toolset/tests/tools/data/test_csv.py | 154 + toolset/tests/tools/data/test_pandas.py | 203 + toolset/tests/tools/database/__init__.py | 0 toolset/tests/tools/database/test_postgres.py | 267 ++ .../tests/tools/files/test_markdown_table.py | 219 ++ .../tests/tools/files/test_pdf_to_markdown.py | 123 + toolset/tests/tools/google/test_calendar.py | 196 + toolset/tests/tools/google/test_chat.py | 216 ++ toolset/tests/tools/google/test_docs.py | 244 ++ toolset/tests/tools/google/test_drive.py | 200 + toolset/tests/tools/google/test_forms.py | 270 ++ toolset/tests/tools/google/test_gmail.py | 198 + toolset/tests/tools/google/test_sheets.py | 273 ++ toolset/tests/tools/google/test_slides.py | 225 ++ toolset/tests/tools/google/test_tasks.py | 299 ++ toolset/tests/tools/image/__init__.py | 1 + toolset/tests/tools/image/test_ocr.py | 110 + toolset/tests/tools/local/test_calculator.py | 217 ++ .../tools/local/test_local_file_system.py | 205 + toolset/tests/tools/local/test_shell.py | 146 + .../tests/tools/search/test_tavily_tool.py | 266 ++ toolset/tests/tools/storage/__init__.py | 1 + toolset/tests/tools/storage/test_tools.py | 536 +++ toolset/tests_integration/conftest.py | 84 + .../simple_function_call_assistant.py | 161 + .../test_simple_function_call_assistant.py | 83 + toolset/uv.lock | 3329 +++++++++++++++++ uv.lock | 52 + 519 files changed, 98168 insertions(+), 108 deletions(-) create mode 100644 src/apps/.gitkeep create mode 100644 src/humcp/middleware.py create mode 100644 src/humcp/permissions.py create mode 100644 src/humcp/playground.py create mode 100644 src/tools/messaging/SKILL.md create mode 100644 src/tools/messaging/__init__.py create mode 100644 src/tools/messaging/discord.py create mode 100644 src/tools/messaging/schemas.py create mode 100644 src/tools/messaging/slack.py create mode 100644 src/tools/messaging/telegram.py create mode 100644 src/tools/messaging/whatsapp.py create mode 100644 src/tools/social/SKILL.md create mode 100644 src/tools/social/__init__.py create mode 100644 src/tools/social/schemas.py create mode 100644 src/tools/social/x.py create mode 100644 toolset/.dockerignore create mode 100644 toolset/.env.example create mode 100644 toolset/.python-version create mode 100644 toolset/.vscode/settings.json create mode 100644 toolset/CLAUDE.md create mode 100644 toolset/Dockerfile create mode 100644 toolset/Dockerfile.dockerignore create mode 100644 toolset/LICENSE create mode 100644 toolset/README.md create mode 100644 toolset/config/tools.yaml create mode 100644 toolset/docker/Dockerfile create mode 100644 toolset/pyproject.toml create mode 100644 toolset/src/__init__.py create mode 100644 toolset/src/apps/api/http_request.html create mode 100644 toolset/src/apps/audio/cartesia_list_voices.html create mode 100644 toolset/src/apps/audio/cartesia_text_to_speech.html create mode 100644 toolset/src/apps/audio/desi_vocal_list_voices.html create mode 100644 toolset/src/apps/audio/desi_vocal_tts.html create mode 100644 toolset/src/apps/audio/elevenlabs_list_voices.html create mode 100644 toolset/src/apps/audio/elevenlabs_text_to_speech.html create mode 100644 toolset/src/apps/audio/mlx_transcribe.html create mode 100644 toolset/src/apps/audio/spotify_get_playlist.html create mode 100644 toolset/src/apps/audio/spotify_get_track.html create mode 100644 toolset/src/apps/audio/spotify_search.html create mode 100644 toolset/src/apps/calculator/absolute_value.html create mode 100644 toolset/src/apps/calculator/add.html create mode 100644 toolset/src/apps/calculator/divide.html create mode 100644 toolset/src/apps/calculator/exponentiate.html create mode 100644 toolset/src/apps/calculator/factorial.html create mode 100644 toolset/src/apps/calculator/greatest_common_divisor.html create mode 100644 toolset/src/apps/calculator/is_prime.html create mode 100644 toolset/src/apps/calculator/logarithm.html create mode 100644 toolset/src/apps/calculator/modulo.html create mode 100644 toolset/src/apps/calculator/multiply.html create mode 100644 toolset/src/apps/calculator/square_root.html create mode 100644 toolset/src/apps/calculator/subtract.html create mode 100644 toolset/src/apps/calendar/calcom_create_booking.html create mode 100644 toolset/src/apps/calendar/calcom_get_availability.html create mode 100644 toolset/src/apps/calendar/calcom_list_bookings.html create mode 100644 toolset/src/apps/calendar/zoom_create_meeting.html create mode 100644 toolset/src/apps/calendar/zoom_get_meeting.html create mode 100644 toolset/src/apps/calendar/zoom_list_meetings.html create mode 100644 toolset/src/apps/cloud/airflow_get_dag_run.html create mode 100644 toolset/src/apps/cloud/airflow_list_dags.html create mode 100644 toolset/src/apps/cloud/airflow_trigger_dag.html create mode 100644 toolset/src/apps/cloud/aws_lambda_invoke.html create mode 100644 toolset/src/apps/cloud/aws_lambda_list_functions.html create mode 100644 toolset/src/apps/cloud/aws_ses_send_email.html create mode 100644 toolset/src/apps/cloud/daytona_create_workspace.html create mode 100644 toolset/src/apps/cloud/daytona_list_workspaces.html create mode 100644 toolset/src/apps/cloud/daytona_run_command.html create mode 100644 toolset/src/apps/cloud/docker_list_containers.html create mode 100644 toolset/src/apps/cloud/docker_run_container.html create mode 100644 toolset/src/apps/cloud/docker_stop_container.html create mode 100644 toolset/src/apps/cloud/e2b_run_code.html create mode 100644 toolset/src/apps/cloud/e2b_run_command.html create mode 100644 toolset/src/apps/data/query_csv_file.html create mode 100644 toolset/src/apps/data/read_csv_file.html create mode 100644 toolset/src/apps/database/describe_table.html create mode 100644 toolset/src/apps/database/duckdb_list_tables.html create mode 100644 toolset/src/apps/database/duckdb_query.html create mode 100644 toolset/src/apps/database/duckdb_read_file.html create mode 100644 toolset/src/apps/database/execute_query.html create mode 100644 toolset/src/apps/database/list_tables.html create mode 100644 toolset/src/apps/database/neo4j_get_schema.html create mode 100644 toolset/src/apps/database/neo4j_query.html create mode 100644 toolset/src/apps/database/redshift_query.html create mode 100644 toolset/src/apps/database/sql_query.html create mode 100644 toolset/src/apps/ecommerce/brandfetch_get_brand.html create mode 100644 toolset/src/apps/ecommerce/shopify_create_product.html create mode 100644 toolset/src/apps/ecommerce/shopify_get_customers.html create mode 100644 toolset/src/apps/ecommerce/shopify_get_product.html create mode 100644 toolset/src/apps/ecommerce/shopify_list_orders.html create mode 100644 toolset/src/apps/ecommerce/shopify_list_products.html create mode 100644 toolset/src/apps/files/convert_pdf_to_markdown.html create mode 100644 toolset/src/apps/files/markdown_to_table.html create mode 100644 toolset/src/apps/finance/evm_get_balance.html create mode 100644 toolset/src/apps/finance/evm_get_block.html create mode 100644 toolset/src/apps/finance/evm_get_gas_price.html create mode 100644 toolset/src/apps/finance/evm_get_transaction.html create mode 100644 toolset/src/apps/finance/financial_datasets_get_financials.html create mode 100644 toolset/src/apps/finance/financial_datasets_get_insider_trades.html create mode 100644 toolset/src/apps/finance/financial_datasets_get_prices.html create mode 100644 toolset/src/apps/finance/openbb_get_market_news.html create mode 100644 toolset/src/apps/finance/openbb_get_stock_data.html create mode 100644 toolset/src/apps/finance/openbb_search_stocks.html create mode 100644 toolset/src/apps/finance/yfinance_get_dividends.html create mode 100644 toolset/src/apps/finance/yfinance_get_historical_data.html create mode 100644 toolset/src/apps/finance/yfinance_get_options.html create mode 100644 toolset/src/apps/finance/yfinance_get_stock_info.html create mode 100644 toolset/src/apps/finance/yfinance_get_stock_price.html create mode 100644 toolset/src/apps/google/google_bigquery_list_datasets.html create mode 100644 toolset/src/apps/google/google_bigquery_list_tables.html create mode 100644 toolset/src/apps/google/google_bigquery_query.html create mode 100644 toolset/src/apps/google/google_maps_directions.html create mode 100644 toolset/src/apps/google/google_maps_geocode.html create mode 100644 toolset/src/apps/google/google_maps_reverse_geocode.html create mode 100644 toolset/src/apps/google/google_maps_search_places.html create mode 100644 toolset/src/apps/image/extract_text_from_image.html create mode 100644 toolset/src/apps/local/execute_shell_command.html create mode 100644 toolset/src/apps/media/dalle_generate_image.html create mode 100644 toolset/src/apps/media/fal_run_model.html create mode 100644 toolset/src/apps/media/giphy_random.html create mode 100644 toolset/src/apps/media/giphy_search.html create mode 100644 toolset/src/apps/media/giphy_trending.html create mode 100644 toolset/src/apps/media/lumalab_generate_video.html create mode 100644 toolset/src/apps/media/models_labs_generate_image.html create mode 100644 toolset/src/apps/media/moviepy_create_video.html create mode 100644 toolset/src/apps/media/moviepy_trim_video.html create mode 100644 toolset/src/apps/media/nano_banana_run.html create mode 100644 toolset/src/apps/media/opencv_convert_format.html create mode 100644 toolset/src/apps/media/opencv_crop_image.html create mode 100644 toolset/src/apps/media/opencv_resize_image.html create mode 100644 toolset/src/apps/media/opencv_rotate_image.html create mode 100644 toolset/src/apps/media/replicate_get_prediction.html create mode 100644 toolset/src/apps/media/replicate_run_model.html create mode 100644 toolset/src/apps/media/replicate_search_models.html create mode 100644 toolset/src/apps/media/unsplash_get_photo.html create mode 100644 toolset/src/apps/media/unsplash_get_random_photo.html create mode 100644 toolset/src/apps/media/unsplash_search_photos.html create mode 100644 toolset/src/apps/memory/mem0_add_memory.html create mode 100644 toolset/src/apps/memory/mem0_get_memories.html create mode 100644 toolset/src/apps/memory/mem0_search_memory.html create mode 100644 toolset/src/apps/memory/zep_add_memory.html create mode 100644 toolset/src/apps/memory/zep_get_session.html create mode 100644 toolset/src/apps/memory/zep_search_memory.html create mode 100644 toolset/src/apps/messaging/discord_add_reaction.html create mode 100644 toolset/src/apps/messaging/discord_get_messages.html create mode 100644 toolset/src/apps/messaging/discord_list_channels.html create mode 100644 toolset/src/apps/messaging/discord_send_message.html create mode 100644 toolset/src/apps/messaging/resend_send_email.html create mode 100644 toolset/src/apps/messaging/send_email.html create mode 100644 toolset/src/apps/messaging/slack_add_reaction.html create mode 100644 toolset/src/apps/messaging/slack_get_channel_history.html create mode 100644 toolset/src/apps/messaging/slack_get_thread_replies.html create mode 100644 toolset/src/apps/messaging/slack_get_user_info.html create mode 100644 toolset/src/apps/messaging/slack_list_channels.html create mode 100644 toolset/src/apps/messaging/slack_search_messages.html create mode 100644 toolset/src/apps/messaging/slack_send_message.html create mode 100644 toolset/src/apps/messaging/telegram_edit_message.html create mode 100644 toolset/src/apps/messaging/telegram_get_updates.html create mode 100644 toolset/src/apps/messaging/telegram_send_message.html create mode 100644 toolset/src/apps/messaging/telegram_send_photo.html create mode 100644 toolset/src/apps/messaging/twilio_get_sms_status.html create mode 100644 toolset/src/apps/messaging/twilio_make_call.html create mode 100644 toolset/src/apps/messaging/twilio_send_sms.html create mode 100644 toolset/src/apps/messaging/webex_create_room.html create mode 100644 toolset/src/apps/messaging/webex_list_rooms.html create mode 100644 toolset/src/apps/messaging/webex_send_message.html create mode 100644 toolset/src/apps/messaging/whatsapp_send_message.html create mode 100644 toolset/src/apps/project_management/bitbucket_create_pr.html create mode 100644 toolset/src/apps/project_management/bitbucket_get_repo.html create mode 100644 toolset/src/apps/project_management/bitbucket_list_repos.html create mode 100644 toolset/src/apps/project_management/clickup_create_task.html create mode 100644 toolset/src/apps/project_management/clickup_get_task.html create mode 100644 toolset/src/apps/project_management/confluence_create_page.html create mode 100644 toolset/src/apps/project_management/confluence_get_page.html create mode 100644 toolset/src/apps/project_management/confluence_search.html create mode 100644 toolset/src/apps/project_management/github_create_issue.html create mode 100644 toolset/src/apps/project_management/github_get_repo.html create mode 100644 toolset/src/apps/project_management/github_list_commits.html create mode 100644 toolset/src/apps/project_management/github_list_issues.html create mode 100644 toolset/src/apps/project_management/github_list_pull_requests.html create mode 100644 toolset/src/apps/project_management/github_list_releases.html create mode 100644 toolset/src/apps/project_management/jira_create_issue.html create mode 100644 toolset/src/apps/project_management/jira_get_issue.html create mode 100644 toolset/src/apps/project_management/jira_search_issues.html create mode 100644 toolset/src/apps/project_management/linear_create_issue.html create mode 100644 toolset/src/apps/project_management/linear_get_issue.html create mode 100644 toolset/src/apps/project_management/linear_list_issues.html create mode 100644 toolset/src/apps/project_management/notion_create_page.html create mode 100644 toolset/src/apps/project_management/notion_get_page.html create mode 100644 toolset/src/apps/project_management/notion_search.html create mode 100644 toolset/src/apps/project_management/todoist_create_task.html create mode 100644 toolset/src/apps/project_management/todoist_get_tasks.html create mode 100644 toolset/src/apps/project_management/trello_create_card.html create mode 100644 toolset/src/apps/project_management/trello_get_boards.html create mode 100644 toolset/src/apps/project_management/zendesk_create_ticket.html create mode 100644 toolset/src/apps/project_management/zendesk_get_ticket.html create mode 100644 toolset/src/apps/project_management/zendesk_search_tickets.html create mode 100644 toolset/src/apps/research/arxiv_read_paper.html create mode 100644 toolset/src/apps/research/arxiv_search.html create mode 100644 toolset/src/apps/research/pubmed_get_article.html create mode 100644 toolset/src/apps/research/pubmed_search.html create mode 100644 toolset/src/apps/research/wikipedia_get_page.html create mode 100644 toolset/src/apps/research/wikipedia_search.html create mode 100644 toolset/src/apps/search/baidu_search.html create mode 100644 toolset/src/apps/search/brave_image_search.html create mode 100644 toolset/src/apps/search/brave_news_search.html create mode 100644 toolset/src/apps/search/brave_web_search.html create mode 100644 toolset/src/apps/search/duckduckgo_images.html create mode 100644 toolset/src/apps/search/duckduckgo_news.html create mode 100644 toolset/src/apps/search/duckduckgo_search.html create mode 100644 toolset/src/apps/search/exa_find_similar.html create mode 100644 toolset/src/apps/search/exa_search.html create mode 100644 toolset/src/apps/search/searxng_search.html create mode 100644 toolset/src/apps/search/seltz_search.html create mode 100644 toolset/src/apps/search/serpapi_search.html create mode 100644 toolset/src/apps/search/serper_google_search.html create mode 100644 toolset/src/apps/search/serper_image_search.html create mode 100644 toolset/src/apps/search/serper_news_search.html create mode 100644 toolset/src/apps/search/tavily_extract.html create mode 100644 toolset/src/apps/search/tavily_web_search.html create mode 100644 toolset/src/apps/search/valyu_search.html create mode 100644 toolset/src/apps/social/hackernews_get_comments.html create mode 100644 toolset/src/apps/social/hackernews_get_story.html create mode 100644 toolset/src/apps/social/hackernews_get_top_stories.html create mode 100644 toolset/src/apps/social/hackernews_get_user.html create mode 100644 toolset/src/apps/social/hackernews_search.html create mode 100644 toolset/src/apps/social/hackernews_search_by_date.html create mode 100644 toolset/src/apps/social/reddit_get_comments.html create mode 100644 toolset/src/apps/social/reddit_get_hot_posts.html create mode 100644 toolset/src/apps/social/reddit_get_post.html create mode 100644 toolset/src/apps/social/reddit_get_subreddit_info.html create mode 100644 toolset/src/apps/social/reddit_get_top_posts.html create mode 100644 toolset/src/apps/social/reddit_search_posts.html create mode 100644 toolset/src/apps/social/x_get_user.html create mode 100644 toolset/src/apps/social/x_post_tweet.html create mode 100644 toolset/src/apps/social/x_search_tweets.html create mode 100644 toolset/src/apps/social/youtube_get_captions.html create mode 100644 toolset/src/apps/social/youtube_get_video_info.html create mode 100644 toolset/src/apps/social/youtube_search.html create mode 100644 toolset/src/apps/storage/get_object_metadata.html create mode 100644 toolset/src/apps/storage/get_presigned_url.html create mode 100644 toolset/src/apps/storage/list_buckets.html create mode 100644 toolset/src/apps/storage/list_objects.html create mode 100644 toolset/src/apps/weather/openweather_get_current.html create mode 100644 toolset/src/apps/weather/openweather_get_forecast.html create mode 100644 toolset/src/apps/web_scraping/agentql_query.html create mode 100644 toolset/src/apps/web_scraping/apify_run_actor.html create mode 100644 toolset/src/apps/web_scraping/brightdata_scrape.html create mode 100644 toolset/src/apps/web_scraping/browserbase_scrape.html create mode 100644 toolset/src/apps/web_scraping/crawl4ai_scrape.html create mode 100644 toolset/src/apps/web_scraping/firecrawl_crawl.html create mode 100644 toolset/src/apps/web_scraping/firecrawl_map.html create mode 100644 toolset/src/apps/web_scraping/firecrawl_scrape.html create mode 100644 toolset/src/apps/web_scraping/firecrawl_search.html create mode 100644 toolset/src/apps/web_scraping/jina_reader.html create mode 100644 toolset/src/apps/web_scraping/jina_search.html create mode 100644 toolset/src/apps/web_scraping/linkup_search.html create mode 100644 toolset/src/apps/web_scraping/newspaper_extract.html create mode 100644 toolset/src/apps/web_scraping/oxylabs_scrape.html create mode 100644 toolset/src/apps/web_scraping/scrapegraph_markdownify.html create mode 100644 toolset/src/apps/web_scraping/scrapegraph_scrape.html create mode 100644 toolset/src/apps/web_scraping/scrapegraph_search.html create mode 100644 toolset/src/apps/web_scraping/spider_crawl.html create mode 100644 toolset/src/apps/web_scraping/spider_links.html create mode 100644 toolset/src/apps/web_scraping/spider_scrape.html create mode 100644 toolset/src/apps/web_scraping/spider_search.html create mode 100644 toolset/src/apps/web_scraping/trafilatura_extract.html create mode 100644 toolset/src/humcp/__init__.py create mode 100644 toolset/src/humcp/auth.py create mode 100644 toolset/src/humcp/config.py create mode 100644 toolset/src/humcp/decorator.py create mode 100644 toolset/src/humcp/middleware.py create mode 100644 toolset/src/humcp/permissions.py create mode 100644 toolset/src/humcp/playground.py create mode 100644 toolset/src/humcp/routes.py create mode 100644 toolset/src/humcp/schemas.py create mode 100644 toolset/src/humcp/server.py create mode 100644 toolset/src/humcp/skills.py create mode 100644 toolset/src/humcp/storage_path.py create mode 100644 toolset/src/logging_setup.py create mode 100644 toolset/src/main.py create mode 100644 toolset/src/tools/__init__.py create mode 100644 toolset/src/tools/api/SKILL.md create mode 100644 toolset/src/tools/api/__init__.py create mode 100644 toolset/src/tools/api/http_client.py create mode 100644 toolset/src/tools/api/schemas.py create mode 100644 toolset/src/tools/audio/SKILL.md create mode 100644 toolset/src/tools/audio/__init__.py create mode 100644 toolset/src/tools/audio/cartesia.py create mode 100644 toolset/src/tools/audio/desi_vocal.py create mode 100644 toolset/src/tools/audio/eleven_labs.py create mode 100644 toolset/src/tools/audio/mlx_transcribe.py create mode 100644 toolset/src/tools/audio/schemas.py create mode 100644 toolset/src/tools/audio/spotify.py create mode 100644 toolset/src/tools/builder/SKILL.md create mode 100644 toolset/src/tools/builder/__init__.py create mode 100644 toolset/src/tools/builder/manager.py create mode 100644 toolset/src/tools/builder/sandbox.py create mode 100644 toolset/src/tools/builder/schemas.py create mode 100644 toolset/src/tools/builder/storage.py create mode 100644 toolset/src/tools/builder/tools.py create mode 100644 toolset/src/tools/calendar/SKILL.md create mode 100644 toolset/src/tools/calendar/__init__.py create mode 100644 toolset/src/tools/calendar/calcom.py create mode 100644 toolset/src/tools/calendar/schemas.py create mode 100644 toolset/src/tools/calendar/zoom.py create mode 100644 toolset/src/tools/cloud/SKILL.md create mode 100644 toolset/src/tools/cloud/__init__.py create mode 100644 toolset/src/tools/cloud/airflow.py create mode 100644 toolset/src/tools/cloud/aws_lambda.py create mode 100644 toolset/src/tools/cloud/aws_ses.py create mode 100644 toolset/src/tools/cloud/daytona.py create mode 100644 toolset/src/tools/cloud/docker.py create mode 100644 toolset/src/tools/cloud/e2b.py create mode 100644 toolset/src/tools/cloud/schemas.py create mode 100644 toolset/src/tools/data/SKILL.md create mode 100644 toolset/src/tools/data/csv.py create mode 100644 toolset/src/tools/data/pandas.py create mode 100644 toolset/src/tools/data/schemas.py create mode 100644 toolset/src/tools/database/SKILL.md create mode 100644 toolset/src/tools/database/__init__.py create mode 100644 toolset/src/tools/database/duckdb.py create mode 100644 toolset/src/tools/database/neo4j.py create mode 100644 toolset/src/tools/database/postgres.py create mode 100644 toolset/src/tools/database/redshift.py create mode 100644 toolset/src/tools/database/schemas.py create mode 100644 toolset/src/tools/database/sql.py create mode 100644 toolset/src/tools/ecommerce/SKILL.md create mode 100644 toolset/src/tools/ecommerce/__init__.py create mode 100644 toolset/src/tools/ecommerce/brandfetch.py create mode 100644 toolset/src/tools/ecommerce/schemas.py create mode 100644 toolset/src/tools/ecommerce/shopify.py create mode 100644 toolset/src/tools/files/SKILL.md create mode 100644 toolset/src/tools/files/markdown_table.py create mode 100644 toolset/src/tools/files/pdf_to_markdown.py create mode 100644 toolset/src/tools/files/schemas.py create mode 100644 toolset/src/tools/finance/SKILL.md create mode 100644 toolset/src/tools/finance/__init__.py create mode 100644 toolset/src/tools/finance/evm.py create mode 100644 toolset/src/tools/finance/financial_datasets.py create mode 100644 toolset/src/tools/finance/openbb.py create mode 100644 toolset/src/tools/finance/schemas.py create mode 100644 toolset/src/tools/finance/yfinance_tool.py create mode 100644 toolset/src/tools/google/README.md create mode 100644 toolset/src/tools/google/SKILL.md create mode 100644 toolset/src/tools/google/__init__.py create mode 100644 toolset/src/tools/google/auth.py create mode 100644 toolset/src/tools/google/calendar.py create mode 100644 toolset/src/tools/google/chat.py create mode 100644 toolset/src/tools/google/docs.py create mode 100644 toolset/src/tools/google/drive.py create mode 100644 toolset/src/tools/google/forms.py create mode 100644 toolset/src/tools/google/gmail.py create mode 100644 toolset/src/tools/google/google_bigquery.py create mode 100644 toolset/src/tools/google/google_maps.py create mode 100644 toolset/src/tools/google/schemas/__init__.py create mode 100644 toolset/src/tools/google/schemas/bigquery.py create mode 100644 toolset/src/tools/google/schemas/calendar.py create mode 100644 toolset/src/tools/google/schemas/chat.py create mode 100644 toolset/src/tools/google/schemas/docs.py create mode 100644 toolset/src/tools/google/schemas/drive.py create mode 100644 toolset/src/tools/google/schemas/forms.py create mode 100644 toolset/src/tools/google/schemas/gmail.py create mode 100644 toolset/src/tools/google/schemas/maps.py create mode 100644 toolset/src/tools/google/schemas/sheets.py create mode 100644 toolset/src/tools/google/schemas/slides.py create mode 100644 toolset/src/tools/google/schemas/tasks.py create mode 100644 toolset/src/tools/google/sheets.py create mode 100644 toolset/src/tools/google/slides.py create mode 100644 toolset/src/tools/google/tasks.py create mode 100644 toolset/src/tools/image/SKILL.md create mode 100644 toolset/src/tools/image/__init__.py create mode 100644 toolset/src/tools/image/ocr.py create mode 100644 toolset/src/tools/image/schemas.py create mode 100644 toolset/src/tools/local/SKILL.md create mode 100644 toolset/src/tools/local/calculator.py create mode 100644 toolset/src/tools/local/local_file_system.py create mode 100644 toolset/src/tools/local/schemas.py create mode 100644 toolset/src/tools/local/shell.py create mode 100644 toolset/src/tools/media/SKILL.md create mode 100644 toolset/src/tools/media/__init__.py create mode 100644 toolset/src/tools/media/dalle.py create mode 100644 toolset/src/tools/media/fal.py create mode 100644 toolset/src/tools/media/giphy.py create mode 100644 toolset/src/tools/media/lumalab.py create mode 100644 toolset/src/tools/media/models_labs.py create mode 100644 toolset/src/tools/media/moviepy_video.py create mode 100644 toolset/src/tools/media/nano_banana.py create mode 100644 toolset/src/tools/media/opencv.py create mode 100644 toolset/src/tools/media/replicate.py create mode 100644 toolset/src/tools/media/schemas.py create mode 100644 toolset/src/tools/media/unsplash.py create mode 100644 toolset/src/tools/memory/SKILL.md create mode 100644 toolset/src/tools/memory/__init__.py create mode 100644 toolset/src/tools/memory/mem0.py create mode 100644 toolset/src/tools/memory/schemas.py create mode 100644 toolset/src/tools/memory/zep.py create mode 100644 toolset/src/tools/messaging/SKILL.md create mode 100644 toolset/src/tools/messaging/__init__.py create mode 100644 toolset/src/tools/messaging/discord.py create mode 100644 toolset/src/tools/messaging/email_smtp.py create mode 100644 toolset/src/tools/messaging/resend.py create mode 100644 toolset/src/tools/messaging/schemas.py create mode 100644 toolset/src/tools/messaging/slack.py create mode 100644 toolset/src/tools/messaging/telegram.py create mode 100644 toolset/src/tools/messaging/twilio.py create mode 100644 toolset/src/tools/messaging/webex.py create mode 100644 toolset/src/tools/messaging/whatsapp.py create mode 100644 toolset/src/tools/project_management/SKILL.md create mode 100644 toolset/src/tools/project_management/__init__.py create mode 100644 toolset/src/tools/project_management/bitbucket.py create mode 100644 toolset/src/tools/project_management/clickup.py create mode 100644 toolset/src/tools/project_management/confluence.py create mode 100644 toolset/src/tools/project_management/github.py create mode 100644 toolset/src/tools/project_management/jira.py create mode 100644 toolset/src/tools/project_management/linear.py create mode 100644 toolset/src/tools/project_management/notion.py create mode 100644 toolset/src/tools/project_management/schemas.py create mode 100644 toolset/src/tools/project_management/todoist.py create mode 100644 toolset/src/tools/project_management/trello.py create mode 100644 toolset/src/tools/project_management/zendesk.py create mode 100644 toolset/src/tools/research/SKILL.md create mode 100644 toolset/src/tools/research/__init__.py create mode 100644 toolset/src/tools/research/arxiv.py create mode 100644 toolset/src/tools/research/pubmed.py create mode 100644 toolset/src/tools/research/schemas.py create mode 100644 toolset/src/tools/research/wikipedia.py create mode 100644 toolset/src/tools/search/SKILL.md create mode 100644 toolset/src/tools/search/baidu.py create mode 100644 toolset/src/tools/search/brave.py create mode 100644 toolset/src/tools/search/duckduckgo.py create mode 100644 toolset/src/tools/search/exa.py create mode 100644 toolset/src/tools/search/schemas.py create mode 100644 toolset/src/tools/search/searxng.py create mode 100644 toolset/src/tools/search/seltz.py create mode 100644 toolset/src/tools/search/serpapi.py create mode 100644 toolset/src/tools/search/serper.py create mode 100644 toolset/src/tools/search/tavily_tool.py create mode 100644 toolset/src/tools/search/valyu.py create mode 100644 toolset/src/tools/social/SKILL.md create mode 100644 toolset/src/tools/social/__init__.py create mode 100644 toolset/src/tools/social/hackernews.py create mode 100644 toolset/src/tools/social/reddit.py create mode 100644 toolset/src/tools/social/schemas.py create mode 100644 toolset/src/tools/social/x.py create mode 100644 toolset/src/tools/social/youtube.py create mode 100644 toolset/src/tools/storage/SKILL.md create mode 100644 toolset/src/tools/storage/__init__.py create mode 100644 toolset/src/tools/storage/bucket_tools.py create mode 100644 toolset/src/tools/storage/client.py create mode 100644 toolset/src/tools/storage/metadata_tools.py create mode 100644 toolset/src/tools/storage/object_tools.py create mode 100644 toolset/src/tools/storage/schemas.py create mode 100644 toolset/src/tools/storage/tools.py create mode 100644 toolset/src/tools/weather/SKILL.md create mode 100644 toolset/src/tools/weather/__init__.py create mode 100644 toolset/src/tools/weather/openweather.py create mode 100644 toolset/src/tools/weather/schemas.py create mode 100644 toolset/src/tools/web_scraping/SKILL.md create mode 100644 toolset/src/tools/web_scraping/__init__.py create mode 100644 toolset/src/tools/web_scraping/agentql.py create mode 100644 toolset/src/tools/web_scraping/apify.py create mode 100644 toolset/src/tools/web_scraping/brightdata.py create mode 100644 toolset/src/tools/web_scraping/browserbase.py create mode 100644 toolset/src/tools/web_scraping/crawl4ai.py create mode 100644 toolset/src/tools/web_scraping/firecrawl.py create mode 100644 toolset/src/tools/web_scraping/jina.py create mode 100644 toolset/src/tools/web_scraping/linkup.py create mode 100644 toolset/src/tools/web_scraping/newspaper.py create mode 100644 toolset/src/tools/web_scraping/oxylabs.py create mode 100644 toolset/src/tools/web_scraping/schemas.py create mode 100644 toolset/src/tools/web_scraping/scrapegraph.py create mode 100644 toolset/src/tools/web_scraping/spider.py create mode 100644 toolset/src/tools/web_scraping/trafilatura.py create mode 100644 toolset/tests/conftest.py create mode 100644 toolset/tests/humcp/__init__.py create mode 100644 toolset/tests/humcp/test_apps.py create mode 100644 toolset/tests/humcp/test_auth.py create mode 100644 toolset/tests/humcp/test_config.py create mode 100644 toolset/tests/humcp/test_decorator.py create mode 100644 toolset/tests/humcp/test_models.py create mode 100644 toolset/tests/humcp/test_permissions.py create mode 100644 toolset/tests/humcp/test_routes.py create mode 100644 toolset/tests/humcp/test_server.py create mode 100644 toolset/tests/humcp/test_skills.py create mode 100644 toolset/tests/humcp/test_storage_path.py create mode 100644 toolset/tests/tools/builder/__init__.py create mode 100644 toolset/tests/tools/builder/test_builder.py create mode 100644 toolset/tests/tools/data/test_csv.py create mode 100644 toolset/tests/tools/data/test_pandas.py create mode 100644 toolset/tests/tools/database/__init__.py create mode 100644 toolset/tests/tools/database/test_postgres.py create mode 100644 toolset/tests/tools/files/test_markdown_table.py create mode 100644 toolset/tests/tools/files/test_pdf_to_markdown.py create mode 100644 toolset/tests/tools/google/test_calendar.py create mode 100644 toolset/tests/tools/google/test_chat.py create mode 100644 toolset/tests/tools/google/test_docs.py create mode 100644 toolset/tests/tools/google/test_drive.py create mode 100644 toolset/tests/tools/google/test_forms.py create mode 100644 toolset/tests/tools/google/test_gmail.py create mode 100644 toolset/tests/tools/google/test_sheets.py create mode 100644 toolset/tests/tools/google/test_slides.py create mode 100644 toolset/tests/tools/google/test_tasks.py create mode 100644 toolset/tests/tools/image/__init__.py create mode 100644 toolset/tests/tools/image/test_ocr.py create mode 100644 toolset/tests/tools/local/test_calculator.py create mode 100644 toolset/tests/tools/local/test_local_file_system.py create mode 100644 toolset/tests/tools/local/test_shell.py create mode 100644 toolset/tests/tools/search/test_tavily_tool.py create mode 100644 toolset/tests/tools/storage/__init__.py create mode 100644 toolset/tests/tools/storage/test_tools.py create mode 100644 toolset/tests_integration/conftest.py create mode 100644 toolset/tests_integration/graphite/simple_function_call_assistant.py create mode 100644 toolset/tests_integration/graphite/test_simple_function_call_assistant.py create mode 100644 toolset/uv.lock diff --git a/pyproject.toml b/pyproject.toml index 093c13d..af125b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,10 @@ dependencies = [ "google-auth-httplib2>=0.2.0", "google-auth-oauthlib>=1.2.2", "minio>=7.0.0", + "slack-sdk>=3.33.0", + "httpx>=0.28.1", + "authlib>=1.3.0", + "python-jose[cryptography]>=3.3.0", ] [dependency-groups] diff --git a/src/apps/.gitkeep b/src/apps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/humcp/auth.py b/src/humcp/auth.py index 3190838..418fa5a 100644 --- a/src/humcp/auth.py +++ b/src/humcp/auth.py @@ -4,7 +4,9 @@ import logging import os import time +from contextvars import ContextVar from urllib.parse import unquote +from uuid import UUID from dotenv import load_dotenv from fastmcp.server.auth.providers.google import GoogleProvider @@ -18,6 +20,39 @@ logger = logging.getLogger("humcp.auth") +# ContextVar set by APIKeyMiddleware for REST requests +_current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None) + + +def get_current_user_id() -> UUID | None: + """Return the authenticated user's UUID, or None if unauthenticated. + + Resolution order: + 1. FastMCP access token (MCP path — set by JWTVerifier middleware) + 2. ContextVar (REST path — set by APIKeyMiddleware) + """ + # 1. MCP path: FastMCP's get_access_token() + try: + from fastmcp.server.dependencies import get_access_token + + token = get_access_token() + if token is not None: + sub = token.client_id or token.claims.get("sub") + if sub: + return UUID(sub) + except (RuntimeError, ValueError, ImportError): + pass + + # 2. REST path: ContextVar set by middleware + user_id_str = _current_user_id.get() + if user_id_str: + try: + return UUID(user_id_str) + except ValueError: + logger.warning("Invalid user_id in ContextVar: %s", user_id_str) + + return None + # Enable debug logging for OAuth to diagnose authentication issues logging.getLogger("fastmcp.server.auth").setLevel(logging.DEBUG) logging.getLogger("mcp.server.auth").setLevel(logging.DEBUG) diff --git a/src/humcp/decorator.py b/src/humcp/decorator.py index 8e2fc59..a1b1b45 100644 --- a/src/humcp/decorator.py +++ b/src/humcp/decorator.py @@ -6,10 +6,12 @@ import inspect from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Any, NamedTuple +from fastmcp.tools import FunctionTool + __all__ = [ "tool", "is_tool", @@ -17,7 +19,6 @@ "get_tool_category", "RegisteredTool", "ToolMetadata", - "ToolInfo", ] TOOL_ATTR = "_humcp_tool" @@ -31,34 +32,15 @@ class ToolMetadata: category: str -@dataclass(frozen=True) -class ToolInfo: - """Tool metadata mirroring the fields previously provided by FunctionTool. - - Attributes: - name: Tool name for MCP registration. - description: Tool description (from docstring). - parameters: JSON Schema dict for tool input parameters. - fn: The original callable. - output_schema: Optional JSON Schema dict for output (default empty). - """ - - name: str - description: str - parameters: dict[str, Any] - fn: Callable[..., Any] - output_schema: dict[str, Any] = field(default_factory=dict) - - class RegisteredTool(NamedTuple): """A tool registered with FastMCP, with category for grouping. Attributes: - tool: ToolInfo object (has name, description, parameters, fn). + tool: The FastMCP FunctionTool object (has name, description, parameters, fn). category: Category for REST endpoint grouping. """ - tool: ToolInfo + tool: FunctionTool category: str diff --git a/src/humcp/middleware.py b/src/humcp/middleware.py new file mode 100644 index 0000000..5bac4cc --- /dev/null +++ b/src/humcp/middleware.py @@ -0,0 +1,83 @@ +"""API Key authentication middleware for service-to-service calls.""" + +import logging +import os +import secrets + +from fastapi import HTTPException, Request +from jose import JWTError, jwt +from starlette.middleware.base import BaseHTTPMiddleware + +from src.humcp.auth import _current_user_id + +logger = logging.getLogger(__name__) + +SERVICE_API_KEY = os.getenv("SERVICE_API_KEY", "") +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") +JWT_ALGORITHM = "HS256" + +# Log warning if no API key is configured +if not SERVICE_API_KEY: + logger.warning( + "SERVICE_API_KEY not configured. API key authentication is disabled. " + "Set SERVICE_API_KEY environment variable for production." + ) + + +class APIKeyMiddleware(BaseHTTPMiddleware): + """ + Middleware to validate X-API-Key header for service-to-service calls. + + Also extracts user identity from Authorization Bearer JWT when present + and stores it in a ContextVar for downstream tool handlers. + + Public paths (/, /docs, etc.) are exempt from authentication. + Uses constant-time comparison to prevent timing attacks. + """ + + # Paths that don't require authentication + PUBLIC_PATHS = {"/", "/docs", "/openapi.json", "/redoc", "/playground"} + + async def dispatch(self, request: Request, call_next): + # Skip auth for public paths + if request.url.path in self.PUBLIC_PATHS: + return await call_next(request) + + # Skip auth if no API key is configured (development mode) + if not SERVICE_API_KEY: + self._extract_user_id(request) + return await call_next(request) + + # Validate API key using constant-time comparison + api_key = request.headers.get("X-API-Key") + if not api_key or not secrets.compare_digest(api_key, SERVICE_API_KEY): + logger.warning("Invalid API key attempt for path: %s", request.url.path) + raise HTTPException( + status_code=401, + detail="Invalid or missing API key", + ) + + # Extract user identity from Bearer JWT (if present) + self._extract_user_id(request) + + return await call_next(request) + + @staticmethod + def _extract_user_id(request: Request) -> None: + """Decode Bearer JWT and store user_id in ContextVar for REST tool handlers.""" + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer ") or not JWT_SECRET_KEY: + _current_user_id.set(None) + return + + token = auth_header[7:] + try: + payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + sub = payload.get("sub") + if sub: + _current_user_id.set(sub) + else: + _current_user_id.set(None) + except JWTError: + logger.debug("Failed to decode Bearer JWT in REST request") + _current_user_id.set(None) diff --git a/src/humcp/permissions.py b/src/humcp/permissions.py new file mode 100644 index 0000000..2841ccf --- /dev/null +++ b/src/humcp/permissions.py @@ -0,0 +1,55 @@ +"""Simplified permission checker for standalone HuMCP deployment. + +This module provides auth/permission primitives without requiring the +shared.auth IAM library (graphite-workflow-builder internal). It can be +upgraded to full IAM checks when shared.auth is available. +""" + +import logging +import os +from uuid import UUID + +from fastapi import HTTPException, status + +from src.humcp.auth import get_current_user_id + +logger = logging.getLogger(__name__) + +TOOLSET_REQUIRE_AUTH = os.getenv("TOOLSET_REQUIRE_AUTH", "").lower() == "true" + + +async def require_auth() -> UUID | None: + """Require authentication without checking a specific resource permission. + + Returns: + user_id if authenticated, None if no auth context (dev mode). + + Raises: + HTTPException 401: if TOOLSET_REQUIRE_AUTH is true and no user context. + """ + user_id = get_current_user_id() + if user_id is None and TOOLSET_REQUIRE_AUTH: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + return user_id + + +async def check_permission( + object_type: str, + object_id: str, + relation: str = "viewer", +) -> UUID | None: + """Get current user and check permission on a resource. + + Simplified version: delegates to require_auth() only. + Upgrade to full IAM checks when shared.auth is available. + + Returns: + user_id if authenticated, None if no auth context (dev mode). + + Raises: + HTTPException 401: if TOOLSET_REQUIRE_AUTH is true and no user context. + """ + return await require_auth() diff --git a/src/humcp/playground.py b/src/humcp/playground.py new file mode 100644 index 0000000..5346e81 --- /dev/null +++ b/src/humcp/playground.py @@ -0,0 +1,495 @@ +"""HuMCP Playground - interactive tool browser and executor.""" + + +def get_playground_html() -> str: + """Return self-contained HTML playground page.""" + return """ + + + + + HuMCP Playground + + + + + + +
+
+
+ +
+

HuMCP Playground

+
+
+ + Swagger + MCP +
+
+ + +
+ + + + + +
+
+ Select a tool from the sidebar to get started +
+ +
+
+ + + +""" diff --git a/src/humcp/routes.py b/src/humcp/routes.py index d2b5275..4ee00f4 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -4,7 +4,6 @@ import base64 import hashlib import logging -import os import secrets from pathlib import Path from typing import Any @@ -134,41 +133,16 @@ async def optional_rest_auth(request: Request): def _register_auth_routes( app: FastAPI, - tools: list[RegisteredTool], auth_provider: Any, - title: str, - version: str, ) -> None: - """Register authentication and info routes. + """Register authentication routes (login/logout). - When auth_provider is None (AUTH_ENABLED=false), only the root info endpoint - is registered. Login/logout endpoints are skipped. + When auth_provider is None (AUTH_ENABLED=false), no routes are registered. Args: app: FastAPI application. - tools: List of registered tools. auth_provider: FastMCP auth provider for OAuth operations (None if disabled). - title: App title for info endpoint. - version: App version for info endpoint. """ - mcp_url = os.getenv("MCP_SERVER_URL", "http://0.0.0.0:8080/mcp") - auth_enabled = auth_provider is not None - - @app.get("/", tags=["Info"]) - async def root(): - """Server information endpoint.""" - endpoints = {"docs": "/docs", "tools": "/tools", "mcp": "/mcp"} - if auth_enabled: - endpoints["login"] = "/login" - return { - "name": title, - "version": version, - "mcp_server": mcp_url, - "tools_count": len(tools), - "auth_enabled": auth_enabled, - "endpoints": endpoints, - } - # Skip login/logout endpoints if auth is disabled if not auth_provider: logger.info( @@ -377,6 +351,7 @@ def register_routes( auth_provider: Any = None, title: str = "HuMCP Server", version: str = "1.0.0", + apps_count: int = 0, ) -> None: """Register all REST routes including tools and auth endpoints. @@ -387,6 +362,7 @@ def register_routes( auth_provider: FastMCP auth provider for OAuth operations (None if auth disabled). title: App title for info endpoint. version: App version for info endpoint. + apps_count: Number of MCP App bundles available. """ # Build lookups categories = _build_categories(tools) @@ -412,7 +388,7 @@ def register_routes( skills = discover_skills(tools_path) # Register auth endpoints only when auth is enabled - _register_auth_routes(app, tools, auth_provider, title, version) + _register_auth_routes(app, auth_provider) # Info endpoints @app.get("/tools", tags=["Info"], response_model=ListToolsResponse) diff --git a/src/humcp/server.py b/src/humcp/server.py index c6bdc54..4d21e8b 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -3,27 +3,29 @@ import importlib.util import inspect import logging +import os import sys -from collections.abc import Callable from contextlib import asynccontextmanager from pathlib import Path from types import ModuleType -from typing import Any, get_type_hints from dotenv import load_dotenv from fastapi import FastAPI +from fastapi.responses import HTMLResponse from fastmcp import FastMCP -from pydantic import TypeAdapter +from fastmcp.server.apps import AppConfig +from fastmcp.server.auth.providers.jwt import JWTVerifier from src.humcp.auth import create_auth_provider from src.humcp.config import DEFAULT_CONFIG_PATH, filter_tools, load_config from src.humcp.decorator import ( RegisteredTool, - ToolInfo, get_tool_category, get_tool_name, is_tool, ) +from src.humcp.middleware import APIKeyMiddleware +from src.humcp.playground import get_playground_html from src.humcp.routes import build_openapi_tags, register_routes load_dotenv() @@ -60,49 +62,61 @@ def _load_modules(tools_path: Path) -> list[ModuleType]: return modules -def _build_parameters_schema(func: Callable[..., Any]) -> dict[str, Any]: - """Build JSON Schema for a function's parameters using pydantic.""" - sig = inspect.signature(func) - hints = get_type_hints(func) +def _discover_apps(apps_path: Path) -> dict[str, Path]: + """Discover MCP App HTML bundles in the apps directory. - properties: dict[str, Any] = {} - required: list[str] = [] + Returns mapping of tool_name -> html_file_path. + Convention: apps/{category}/{tool_name}.html + """ + if not apps_path.exists(): + return {} - for name, param in sig.parameters.items(): - if name in ("self", "cls"): - continue - annotation = hints.get(name, Any) - try: - adapter = TypeAdapter(annotation) - prop_schema = adapter.json_schema() - except Exception: - prop_schema = {} + app_map: dict[str, Path] = {} + for html_file in sorted(apps_path.rglob("*.html")): + tool_name = html_file.stem + app_map[tool_name] = html_file + logger.debug("Discovered app: %s -> %s", tool_name, html_file) + + return app_map - # Add description from default if it's a pydantic Field, otherwise skip - properties[name] = prop_schema - if param.default is inspect.Parameter.empty: - required.append(name) - elif param.default is not inspect.Parameter.empty and param.default is not None: - properties[name]["default"] = param.default +def _make_app_resource_fn(path: Path): + """Create a named function for serving an app HTML file as MCP resource.""" - schema: dict[str, Any] = { - "type": "object", - "properties": properties, - } - if required: - schema["required"] = required + async def read_app_html() -> str: + return path.read_text(encoding="utf-8") - return schema + read_app_html.__name__ = f"app_{path.stem}" + read_app_html.__qualname__ = f"app_{path.stem}" + return read_app_html + + +def _register_app_resources( + mcp: FastMCP, apps_path: Path, app_map: dict[str, Path] +) -> None: + """Register ui:// MCP resources for discovered app HTML bundles.""" + for tool_name, html_file in app_map.items(): + relative = html_file.relative_to(apps_path) + uri = f"ui://{relative.as_posix()}" + reader_fn = _make_app_resource_fn(html_file) + mcp.resource(uri, name=f"app_{tool_name}")(reader_fn) + logger.debug("Registered ui:// resource: %s", uri) def _discover_and_register( - mcp: FastMCP, modules: list[ModuleType] + mcp: FastMCP, + modules: list[ModuleType], + app_map: dict[str, Path] | None = None, + apps_path: Path | None = None, ) -> list[RegisteredTool]: """Discover @tool functions and register with FastMCP. - Returns list of RegisteredTool (ToolInfo + category). + If app_map is provided, tools with matching HTML apps get AppConfig attached + for MCP Apps support. + + Returns list of RegisteredTool (FunctionTool + category). """ + app_map = app_map or {} tools: list[RegisteredTool] = [] seen_names: set[str] = set() @@ -122,18 +136,21 @@ def _discover_and_register( seen_names.add(tool_name) + # Build app config if matching HTML app exists + app_config = None + if tool_name in app_map and apps_path: + relative = app_map[tool_name].relative_to(apps_path) + resource_uri = f"ui://{relative.as_posix()}" + app_config = AppConfig(resource_uri=resource_uri) + logger.debug("Attached app to tool '%s': %s", tool_name, resource_uri) + # Register with FastMCP (v3 returns the original function, not FunctionTool) - mcp.tool(name=tool_name)(func) - - # Build ToolInfo ourselves - tool_info = ToolInfo( - name=tool_name, - description=func.__doc__ or "", - parameters=_build_parameters_schema(func), - fn=func, - ) - tools.append(RegisteredTool(tool=tool_info, category=category)) - logger.debug("Registered: %s (category: %s)", tool_name, category) + mcp.tool(name=tool_name, app=app_config)(func) + + # Access the FunctionTool synchronously from FastMCP's internal registry + fn_tool = mcp._local_provider._components[f"tool:{tool_name}@"] + tools.append(RegisteredTool(tool=fn_tool, category=category)) + logger.debug("Registered: %s (category: %s)", fn_tool.name, category) return tools @@ -141,22 +158,41 @@ def _discover_and_register( def create_app( tools_path: Path | str | None = None, config_path: Path | str | None = None, + apps_path: Path | str | None = None, title: str = "HuMCP Server", description: str = "REST and MCP endpoints for tools", version: str = "1.0.0", ) -> FastAPI: - """Create FastAPI app with REST (/tools) and MCP (/mcp) endpoints.""" + """Create FastAPI app with REST (/tools), MCP (/mcp), and Apps (/apps) endpoints.""" path = Path(tools_path) if tools_path else Path(__file__).parent.parent / "tools" - - # Create auth provider (respects AUTH_ENABLED env var) - auth_provider = create_auth_provider() - - # Create MCP server - mcp = FastMCP("HuMCP Server", auth=auth_provider) + a_path = Path(apps_path) if apps_path else Path(__file__).parent.parent / "apps" + + # Select MCP auth provider: + # - JWT_SECRET_KEY set → JWTVerifier (graphite/service-to-service mode) + # - GOOGLE_OAUTH_CLIENT_ID + AUTH_ENABLED=true → GoogleProvider (standalone mode) + # - Neither → no MCP auth (dev mode) + jwt_secret = os.getenv("JWT_SECRET_KEY", "") + if jwt_secret: + mcp_auth = JWTVerifier(public_key=jwt_secret, algorithm="HS256") + mcp = FastMCP("HuMCP Server", auth=mcp_auth) + auth_provider = None # Google OAuth not used when JWT mode is active + logger.info("Using JWT auth for MCP (JWT_SECRET_KEY set)") + else: + # Fall back to Google OAuth (or None if not configured) + auth_provider = create_auth_provider() + mcp = FastMCP("HuMCP Server", auth=auth_provider) + if auth_provider: + logger.info("Using Google OAuth for MCP") + + # Discover MCP App HTML bundles + app_map = _discover_apps(a_path) + if app_map: + logger.info("Discovered %d MCP App bundles from %s", len(app_map), a_path) + _register_app_resources(mcp, a_path, app_map) # Load modules and register tools with FastMCP modules = _load_modules(path) - tools = _discover_and_register(mcp, modules) + tools = _discover_and_register(mcp, modules, app_map=app_map, apps_path=a_path) logger.info("Registered %d tools from %s", len(tools), path) # Filter tools by config @@ -182,7 +218,10 @@ async def lifespan(_: FastAPI): openapi_tags=build_openapi_tags(filtered), ) - # Register all REST routes (tools, auth, info endpoints) + # Add API key authentication middleware + app.add_middleware(APIKeyMiddleware) + + # Register REST routes (tools, auth, info endpoints) register_routes( app, tools_path=path, @@ -190,16 +229,72 @@ async def lifespan(_: FastAPI): auth_provider=auth_provider, title=title, version=version, + apps_count=len(app_map), ) logger.info("Registered %d REST endpoints", len(filtered)) - # Mount OAuth routes at root level - # This includes: /.well-known/*, /authorize, /token, /register, /auth/callback, /consent + # Mount OAuth routes at root level (Google OAuth mode only) if auth_provider: oauth_routes = auth_provider.get_routes(mcp_path="/mcp") for route in oauth_routes: app.routes.append(route) logger.info("Mounted %d OAuth routes at root level", len(oauth_routes)) + # Apps REST delivery endpoint + @app.get("/apps/{tool_name}", tags=["Apps"], response_class=HTMLResponse) + async def get_app_html(tool_name: str) -> HTMLResponse: + """Serve MCP App HTML bundle for a tool (REST delivery).""" + html_file = app_map.get(tool_name) + if not html_file or not html_file.exists(): + return HTMLResponse(status_code=404, content="App not found") + return HTMLResponse(content=html_file.read_text(encoding="utf-8")) + + @app.get("/apps", tags=["Apps"]) + async def list_apps() -> dict: + """List available MCP App bundles.""" + return { + "total_apps": len(app_map), + "apps": [ + { + "tool_name": name, + "rest_endpoint": f"/apps/{name}", + "mcp_resource": f"ui://{p.relative_to(a_path).as_posix()}", + } + for name, p in sorted(app_map.items()) + ], + } + + # Playground endpoint + @app.get("/playground", tags=["Info"], response_class=HTMLResponse) + async def playground(): + """Interactive tool browser and executor.""" + return HTMLResponse(content=get_playground_html()) + + # Root info endpoint + mcp_url = os.getenv("MCP_SERVER_URL", "http://0.0.0.0:8080/mcp") + auth_enabled = auth_provider is not None + + @app.get("/", tags=["Info"]) + async def root(): + """Server information endpoint.""" + endpoints: dict[str, str] = { + "docs": "/docs", + "playground": "/playground", + "tools": "/tools", + "apps": "/apps", + "mcp": "/mcp", + } + if auth_enabled: + endpoints["login"] = "/login" + return { + "name": title, + "version": version, + "mcp_server": mcp_url, + "tools_count": len(filtered), + "apps_count": len(app_map), + "auth_enabled": auth_enabled, + "endpoints": endpoints, + } + app.mount("/mcp", mcp_http_app) return app diff --git a/src/tools/messaging/SKILL.md b/src/tools/messaging/SKILL.md new file mode 100644 index 0000000..72f9444 --- /dev/null +++ b/src/tools/messaging/SKILL.md @@ -0,0 +1,110 @@ +--- +name: messaging-communication +description: Send messages and notifications across messaging platforms including Slack, Discord, Telegram, and WhatsApp. Use when the user needs to send messages, list channels, search messages, or retrieve chat history. +--- + +# Messaging & Communication Tools + +Tools for sending messages and interacting with popular messaging platforms. + +## Available Tools + +| Tool | Service | Functions | +|------|---------|-----------| +| Slack | Slack | Send messages, list channels, get history, search, reactions, threads | +| Discord | Discord | Send messages, list guild channels, reactions, threads | +| Telegram | Telegram Bot API | Send messages, photos, edit messages, get updates, pin messages | +| WhatsApp | WhatsApp Cloud API | Send text, template, and media messages | + +## Requirements + +Set environment variables for each service you want to use: + +- **Slack**: `SLACK_TOKEN` +- **Discord**: `DISCORD_BOT_TOKEN` +- **Telegram**: `TELEGRAM_BOT_TOKEN` +- **WhatsApp**: `WHATSAPP_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID` + +## Examples + +### Send a Slack message + +```python +result = await slack_send_message( + channel="#general", + text="Hello from the workflow!" +) +``` + +### List Slack channels + +```python +result = await slack_list_channels() +``` + +### Search Slack messages + +```python +result = await slack_search_messages( + query="in:#engineering deployment", + limit=10 +) +``` + +### Send a Discord message + +```python +result = await discord_send_message( + channel_id="1234567890", + content="Build completed successfully!" +) +``` + +### Send a Telegram message + +```python +result = await telegram_send_message( + chat_id="@mychannel", + text="Deployment finished." +) +``` + +### Send a WhatsApp message + +```python +result = await whatsapp_send_message( + to="1234567890", + message="Your order has been shipped!" +) +``` + +### Response format + +All tools return a consistent response format: + +```json +{ + "success": true, + "data": { + "message_id": "abc123", + "channel": "#general", + "timestamp": "1234567890.123456" + } +} +``` + +On error: + +```json +{ + "success": false, + "error": "Slack not configured. Set SLACK_TOKEN environment variable." +} +``` + +## When to Use + +- Sending notifications from automated workflows +- Posting alerts to team channels +- Integrating messaging into multi-step agent pipelines +- Forwarding information between communication platforms diff --git a/src/tools/messaging/__init__.py b/src/tools/messaging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/messaging/discord.py b/src/tools/messaging/discord.py new file mode 100644 index 0000000..88ec3a8 --- /dev/null +++ b/src/tools/messaging/discord.py @@ -0,0 +1,411 @@ +"""Discord messaging tools for sending messages, managing reactions, threads, and channels. + +Uses the Discord Bot API v10 (https://discord.com/developers/docs). +Requires the DISCORD_BOT_TOKEN environment variable to be set. +""" + +from __future__ import annotations + +import logging +import os +from urllib.parse import quote as url_quote + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + AddReactionResponse, + ChannelInfo, + DiscordCreateThreadResponse, + DiscordMessageInfo, + DiscordThreadCreatedData, + GetMessagesData, + GetMessagesResponse, + ListChannelsData, + ListChannelsResponse, + MessageSentData, + ReactionAddedData, + SendMessageResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Discord tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.discord") + +DISCORD_API_BASE = "https://discord.com/api/v10" + + +def _get_headers() -> dict[str, str] | None: + """Build Discord API headers from the environment token.""" + token = os.getenv("DISCORD_BOT_TOKEN") + if not token: + return None + return { + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + } + + +@tool() +async def discord_send_message( + channel_id: str, + content: str, + embed_title: str | None = None, + embed_description: str | None = None, +) -> SendMessageResponse: + """Send a message to a Discord channel, optionally with a rich embed. + + Uses the POST /channels/{channel_id}/messages endpoint. + Requires the SEND_MESSAGES permission in the target channel. + + Args: + channel_id: The ID of the Discord channel to send the message to. + content: The text content of the message (up to 2000 characters). + embed_title: Optional title for a rich embed attached to the message. + embed_description: Optional description for the rich embed (up to 4096 characters). + + Returns: + Response indicating success with message details, or an error. + """ + try: + headers = _get_headers() + if headers is None: + return SendMessageResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + payload: dict = {"content": content} + if embed_title or embed_description: + embed: dict = {} + if embed_title: + embed["title"] = embed_title + if embed_description: + embed["description"] = embed_description + payload["embeds"] = [embed] + + logger.info("Sending Discord message to channel_id=%s", channel_id) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{DISCORD_API_BASE}/channels/{channel_id}/messages", + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=data.get("id"), + channel=data.get("channel_id"), + timestamp=data.get("timestamp"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord send_message HTTP error") + return SendMessageResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send Discord message: {str(e)}" + ) + + +@tool() +async def discord_add_reaction( + channel_id: str, message_id: str, emoji: str +) -> AddReactionResponse: + """Add a reaction emoji to a Discord message. + + Uses the PUT /channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me endpoint. + Requires READ_MESSAGE_HISTORY permission. Also requires ADD_REACTIONS if no one + else has reacted with the same emoji yet. + + Args: + channel_id: The ID of the channel containing the message. + message_id: The ID of the message to react to. + emoji: The emoji to react with. Use Unicode emoji (e.g., "thumbsup") or + custom emoji in the format "name:id" (e.g., "myemoji:123456789"). + + Returns: + Response indicating success, or an error. + """ + try: + headers = _get_headers() + if headers is None: + return AddReactionResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + encoded_emoji = url_quote(emoji) + url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/reactions/{encoded_emoji}/@me" + + logger.info( + "Adding reaction emoji=%s to message=%s in channel=%s", + emoji, + message_id, + channel_id, + ) + async with httpx.AsyncClient() as client: + response = await client.put( + url, + headers=headers, + timeout=30, + ) + response.raise_for_status() + + return AddReactionResponse( + success=True, + data=ReactionAddedData( + message_id=message_id, + emoji=emoji, + channel_id=channel_id, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord add_reaction HTTP error") + return AddReactionResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord add_reaction failed") + return AddReactionResponse( + success=False, error=f"Failed to add Discord reaction: {str(e)}" + ) + + +@tool() +async def discord_get_messages(channel_id: str, limit: int = 50) -> GetMessagesResponse: + """Fetch recent messages from a Discord channel. + + Uses the GET /channels/{channel_id}/messages endpoint. + Requires the READ_MESSAGE_HISTORY permission. + + Args: + channel_id: The ID of the Discord channel to fetch messages from. + limit: Maximum number of messages to retrieve (1-100, default 50). + + Returns: + Response containing a list of messages from the channel. + """ + try: + headers = _get_headers() + if headers is None: + return GetMessagesResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + if limit < 1: + return GetMessagesResponse(success=False, error="limit must be at least 1") + + capped_limit = min(limit, 100) + logger.info( + "Fetching Discord messages channel_id=%s limit=%d", channel_id, capped_limit + ) + async with httpx.AsyncClient() as client: + response = await client.get( + f"{DISCORD_API_BASE}/channels/{channel_id}/messages", + headers=headers, + params={"limit": capped_limit}, + timeout=30, + ) + response.raise_for_status() + raw_messages = response.json() + + messages = [ + DiscordMessageInfo( + id=msg["id"], + content=msg.get("content", ""), + author=msg.get("author", {}).get("username"), + timestamp=msg.get("timestamp"), + channel_id=msg.get("channel_id"), + ) + for msg in raw_messages + ] + + return GetMessagesResponse( + success=True, + data=GetMessagesData( + messages=messages, count=len(messages), channel_id=channel_id + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord get_messages HTTP error") + return GetMessagesResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord get_messages failed") + return GetMessagesResponse( + success=False, error=f"Failed to get Discord messages: {str(e)}" + ) + + +@tool() +async def discord_list_channels(guild_id: str) -> ListChannelsResponse: + """List all channels in a Discord server (guild). + + Uses the GET /guilds/{guild_id}/channels endpoint. + Returns text, voice, category, news, stage, and forum channel types. + + Args: + guild_id: The ID of the Discord server (guild) to list channels from. + + Returns: + Response containing a list of channels with their IDs, names, and types. + """ + try: + headers = _get_headers() + if headers is None: + return ListChannelsResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + logger.info("Listing Discord channels for guild_id=%s", guild_id) + async with httpx.AsyncClient() as client: + response = await client.get( + f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", + headers=headers, + timeout=30, + ) + response.raise_for_status() + raw_channels = response.json() + + # Discord channel types: 0=text, 2=voice, 4=category, 5=news, 13=stage, 15=forum + type_map = { + 0: "text", + 2: "voice", + 4: "category", + 5: "news", + 13: "stage", + 15: "forum", + } + + channels = [ + ChannelInfo( + id=ch["id"], + name=ch.get("name", ""), + type=type_map.get(ch.get("type", 0), "other"), + ) + for ch in raw_channels + ] + + return ListChannelsResponse( + success=True, + data=ListChannelsData(channels=channels, count=len(channels)), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord list_channels HTTP error") + return ListChannelsResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord list_channels failed") + return ListChannelsResponse( + success=False, error=f"Failed to list Discord channels: {str(e)}" + ) + + +@tool() +async def discord_create_thread( + channel_id: str, + name: str, + message_id: str | None = None, + auto_archive_duration: int = 1440, +) -> DiscordCreateThreadResponse: + """Create a new thread in a Discord channel. + + When message_id is provided, creates a thread attached to that message + (uses POST /channels/{channel_id}/messages/{message_id}/threads). + Otherwise creates a standalone thread without an associated message + (uses POST /channels/{channel_id}/threads). + + Args: + channel_id: The ID of the channel to create the thread in. + name: The name of the thread (1-100 characters). + message_id: Optional message ID to start the thread from. + When omitted, creates a standalone thread. + auto_archive_duration: Duration in minutes before the thread is + automatically archived. Must be one of: + 60 (1 hour), 1440 (1 day, default), 4320 (3 days), + or 10080 (7 days, requires server boost level 2). + + Returns: + Response indicating success with the new thread details, or an error. + """ + try: + headers = _get_headers() + if headers is None: + return DiscordCreateThreadResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + payload: dict = { + "name": name, + "auto_archive_duration": auto_archive_duration, + } + + if message_id: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads" + else: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/threads" + # Standalone threads require a type; 11 = public thread + payload["type"] = 11 + + logger.info( + "Creating Discord thread name=%s in channel=%s message=%s", + name, + channel_id, + message_id, + ) + async with httpx.AsyncClient() as client: + response = await client.post( + url, + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + thread_type_map = { + 10: "news", + 11: "public", + 12: "private", + } + + return DiscordCreateThreadResponse( + success=True, + data=DiscordThreadCreatedData( + thread_id=data["id"], + name=data.get("name", name), + channel_id=channel_id, + type=thread_type_map.get(data.get("type"), "public"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord create_thread HTTP error") + return DiscordCreateThreadResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord create_thread failed") + return DiscordCreateThreadResponse( + success=False, error=f"Failed to create Discord thread: {str(e)}" + ) diff --git a/src/tools/messaging/schemas.py b/src/tools/messaging/schemas.py new file mode 100644 index 0000000..91147ed --- /dev/null +++ b/src/tools/messaging/schemas.py @@ -0,0 +1,393 @@ +"""Pydantic output schemas for messaging tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Shared Messaging Schemas +# ============================================================================= + + +class MessageSentData(BaseModel): + """Data returned after sending a message.""" + + message_id: str | None = Field(None, description="ID of the sent message") + channel: str | None = Field( + None, description="Channel or recipient the message was sent to" + ) + timestamp: str | None = Field(None, description="Timestamp of the sent message") + + +class SendMessageResponse(ToolResponse[MessageSentData]): + """Response schema for message-sending tools.""" + + pass + + +# ============================================================================= +# Channel / Room Listing Schemas +# ============================================================================= + + +class ChannelInfo(BaseModel): + """Information about a channel or room.""" + + id: str = Field(..., description="Channel or room ID") + name: str = Field(..., description="Channel or room name") + type: str | None = Field( + None, description="Channel type (e.g., public, private, direct)" + ) + is_member: bool | None = Field(None, description="Whether the bot is a member") + + +class ListChannelsData(BaseModel): + """Data returned when listing channels.""" + + channels: list[ChannelInfo] = Field(..., description="List of channels") + count: int = Field(..., description="Number of channels returned") + + +class ListChannelsResponse(ToolResponse[ListChannelsData]): + """Response schema for channel-listing tools.""" + + pass + + +# ============================================================================= +# Message History Schemas +# ============================================================================= + + +class MessageInfo(BaseModel): + """A single message from channel history or search results.""" + + text: str = Field(..., description="Message text content") + user: str | None = Field(None, description="User who sent the message") + timestamp: str | None = Field(None, description="Message timestamp") + channel: str | None = Field(None, description="Channel the message belongs to") + permalink: str | None = Field(None, description="Permalink to the message") + thread_ts: str | None = Field( + None, description="Thread timestamp if message is in a thread" + ) + + +class ChannelHistoryData(BaseModel): + """Data returned when fetching channel history.""" + + messages: list[MessageInfo] = Field(..., description="List of messages") + count: int = Field(..., description="Number of messages returned") + channel: str = Field(..., description="Channel ID the history was fetched from") + + +class ChannelHistoryResponse(ToolResponse[ChannelHistoryData]): + """Response schema for channel history tools.""" + + pass + + +# ============================================================================= +# Search Messages Schemas +# ============================================================================= + + +class SearchMessagesData(BaseModel): + """Data returned when searching messages.""" + + messages: list[MessageInfo] = Field(..., description="List of matching messages") + count: int = Field(..., description="Number of matching messages") + query: str = Field(..., description="The search query that was executed") + + +class SearchMessagesResponse(ToolResponse[SearchMessagesData]): + """Response schema for message search tools.""" + + pass + + +# ============================================================================= +# Telegram Update Schemas +# ============================================================================= + + +class TelegramUpdate(BaseModel): + """A single Telegram update.""" + + update_id: int = Field(..., description="Unique update identifier") + message_text: str | None = Field(None, description="Text content of the message") + chat_id: int | None = Field(None, description="Chat ID the update belongs to") + from_user: str | None = Field(None, description="Username of the sender") + date: int | None = Field(None, description="Unix timestamp of the message") + + +class TelegramUpdatesData(BaseModel): + """Data returned when fetching Telegram updates.""" + + updates: list[TelegramUpdate] = Field(..., description="List of updates") + count: int = Field(..., description="Number of updates returned") + + +class TelegramUpdatesResponse(ToolResponse[TelegramUpdatesData]): + """Response schema for Telegram get_updates tool.""" + + pass + + +# ============================================================================= +# Telegram Photo Schemas +# ============================================================================= + + +class TelegramPhotoSentData(BaseModel): + """Data returned after sending a photo via Telegram.""" + + message_id: str | None = Field(None, description="ID of the sent photo message") + chat_id: str | None = Field(None, description="Chat ID the photo was sent to") + + +class SendTelegramPhotoResponse(ToolResponse[TelegramPhotoSentData]): + """Response schema for sending a Telegram photo.""" + + pass + + +# ============================================================================= +# Telegram Edit Message Schemas +# ============================================================================= + + +class TelegramEditedMessageData(BaseModel): + """Data returned after editing a Telegram message.""" + + message_id: str | None = Field(None, description="ID of the edited message") + chat_id: str | None = Field(None, description="Chat ID of the edited message") + text: str | None = Field(None, description="New text of the edited message") + + +class EditTelegramMessageResponse(ToolResponse[TelegramEditedMessageData]): + """Response schema for editing a Telegram message.""" + + pass + + +# ============================================================================= +# Discord Reaction Schemas +# ============================================================================= + + +class ReactionAddedData(BaseModel): + """Data returned after adding a reaction.""" + + message_id: str = Field( + ..., description="ID of the message the reaction was added to" + ) + emoji: str = Field(..., description="The emoji that was added as a reaction") + channel_id: str = Field(..., description="Channel ID of the message") + + +class AddReactionResponse(ToolResponse[ReactionAddedData]): + """Response schema for adding a reaction.""" + + pass + + +# ============================================================================= +# Discord Get Messages Schemas +# ============================================================================= + + +class DiscordMessageInfo(BaseModel): + """A single Discord message.""" + + id: str = Field(..., description="Message ID") + content: str = Field(..., description="Message text content") + author: str | None = Field(None, description="Author username") + timestamp: str | None = Field(None, description="Message timestamp (ISO8601)") + channel_id: str | None = Field( + None, description="Channel the message was posted in" + ) + + +class GetMessagesData(BaseModel): + """Data returned when fetching Discord messages.""" + + messages: list[DiscordMessageInfo] = Field(..., description="List of messages") + count: int = Field(..., description="Number of messages returned") + channel_id: str = Field( + ..., description="Channel ID the messages were fetched from" + ) + + +class GetMessagesResponse(ToolResponse[GetMessagesData]): + """Response schema for fetching Discord messages.""" + + pass + + +# ============================================================================= +# Slack Reaction Schemas +# ============================================================================= + + +class SlackReactionData(BaseModel): + """Data returned after adding a Slack reaction.""" + + channel: str = Field(..., description="Channel ID") + timestamp: str = Field( + ..., description="Message timestamp the reaction was added to" + ) + reaction: str = Field(..., description="The reaction name that was added") + + +class SlackReactionResponse(ToolResponse[SlackReactionData]): + """Response schema for Slack reaction tools.""" + + pass + + +# ============================================================================= +# Slack Thread Reply Schemas +# ============================================================================= + + +class SlackThreadReplyData(BaseModel): + """Data returned when fetching thread replies.""" + + messages: list[MessageInfo] = Field( + ..., description="List of messages in the thread" + ) + count: int = Field(..., description="Number of messages in the thread") + channel: str = Field(..., description="Channel ID") + thread_ts: str = Field(..., description="Thread parent timestamp") + + +class SlackThreadReplyResponse(ToolResponse[SlackThreadReplyData]): + """Response schema for Slack thread reply tools.""" + + pass + + +# ============================================================================= +# Slack User Info Schemas +# ============================================================================= + + +class SlackUserInfo(BaseModel): + """Information about a Slack user.""" + + id: str = Field(..., description="User ID") + name: str = Field(..., description="Username") + real_name: str | None = Field(None, description="User's display name") + email: str | None = Field(None, description="User's email address") + is_bot: bool | None = Field(None, description="Whether the user is a bot") + is_admin: bool | None = Field(None, description="Whether the user is an admin") + + +class SlackUserInfoData(BaseModel): + """Data returned when fetching Slack user info.""" + + user: SlackUserInfo = Field(..., description="User information") + + +class SlackUserInfoResponse(ToolResponse[SlackUserInfoData]): + """Response schema for Slack user info tools.""" + + pass + + +# ============================================================================= +# WhatsApp Template Message Schemas +# ============================================================================= + + +class WhatsAppTemplateSentData(BaseModel): + """Data returned after sending a WhatsApp template message.""" + + message_id: str | None = Field(None, description="ID of the sent template message") + to: str = Field(..., description="Recipient phone number") + template_name: str = Field(..., description="Name of the template used") + + +class SendWhatsAppTemplateResponse(ToolResponse[WhatsAppTemplateSentData]): + """Response schema for sending a WhatsApp template message.""" + + pass + + +# ============================================================================= +# WhatsApp Media Message Schemas +# ============================================================================= + + +class WhatsAppMediaSentData(BaseModel): + """Data returned after sending a WhatsApp media message.""" + + message_id: str | None = Field(None, description="ID of the sent media message") + to: str = Field(..., description="Recipient phone number") + media_type: str = Field( + ..., description="Type of media sent (image, document, video, audio)" + ) + + +class SendWhatsAppMediaResponse(ToolResponse[WhatsAppMediaSentData]): + """Response schema for sending a WhatsApp media message.""" + + pass + + +# ============================================================================= +# Slack Set Channel Topic Schemas +# ============================================================================= + + +class SlackChannelTopicData(BaseModel): + """Data returned after setting a Slack channel topic.""" + + channel: str = Field(..., description="Channel ID the topic was set on") + topic: str = Field(..., description="The new topic that was set") + + +class SlackSetChannelTopicResponse(ToolResponse[SlackChannelTopicData]): + """Response schema for setting a Slack channel topic.""" + + pass + + +# ============================================================================= +# Discord Create Thread Schemas +# ============================================================================= + + +class DiscordThreadCreatedData(BaseModel): + """Data returned after creating a Discord thread.""" + + thread_id: str = Field(..., description="ID of the newly created thread") + name: str = Field(..., description="Name of the thread") + channel_id: str = Field( + ..., description="Parent channel ID the thread was created in" + ) + type: str | None = Field(None, description="Thread type (e.g., public, private)") + + +class DiscordCreateThreadResponse(ToolResponse[DiscordThreadCreatedData]): + """Response schema for creating a Discord thread.""" + + pass + + +# ============================================================================= +# Telegram Pin Message Schemas +# ============================================================================= + + +class TelegramPinnedMessageData(BaseModel): + """Data returned after pinning a Telegram message.""" + + chat_id: str = Field(..., description="Chat ID where the message was pinned") + message_id: int = Field(..., description="ID of the pinned message") + + +class TelegramPinMessageResponse(ToolResponse[TelegramPinnedMessageData]): + """Response schema for pinning a Telegram message.""" + + pass diff --git a/src/tools/messaging/slack.py b/src/tools/messaging/slack.py new file mode 100644 index 0000000..5f20ca7 --- /dev/null +++ b/src/tools/messaging/slack.py @@ -0,0 +1,588 @@ +"""Slack messaging tools for sending messages, managing reactions, threads, and channels. + +Uses the Slack Web API via the slack_sdk Python package. +See https://docs.slack.dev/reference/methods/ for full API reference. +Requires the SLACK_TOKEN environment variable (Bot User OAuth Token). +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + ChannelHistoryData, + ChannelHistoryResponse, + ChannelInfo, + ListChannelsData, + ListChannelsResponse, + MessageInfo, + MessageSentData, + SearchMessagesData, + SearchMessagesResponse, + SendMessageResponse, + SlackChannelTopicData, + SlackReactionData, + SlackReactionResponse, + SlackSetChannelTopicResponse, + SlackThreadReplyData, + SlackThreadReplyResponse, + SlackUserInfo, + SlackUserInfoData, + SlackUserInfoResponse, +) + +try: + from slack_sdk import WebClient + from slack_sdk.errors import SlackApiError +except ImportError as err: + raise ImportError( + "slack_sdk is required for Slack tools. Install with: pip install slack-sdk" + ) from err + +logger = logging.getLogger("humcp.tools.slack") + + +def _get_client() -> WebClient | None: + """Create a Slack WebClient from the environment token.""" + token = os.getenv("SLACK_TOKEN") + if not token: + return None + return WebClient(token=token) + + +@tool() +async def slack_send_message( + channel: str, text: str, thread_ts: str | None = None +) -> SendMessageResponse: + """Send a message to a Slack channel or thread. + + Uses the chat.postMessage API method. Supports Slack mrkdwn formatting. + To reply in a thread, provide the thread_ts of the parent message. + + Args: + channel: The channel ID or name to send the message to. + text: The text of the message. Supports Slack mrkdwn formatting + (e.g., *bold*, _italic_, ~strike~, `code`, ```code block```). + thread_ts: Optional timestamp of the parent message to reply in a thread. + When set, the message is posted as a threaded reply. + + Returns: + Response indicating success with message details, or an error. + """ + try: + client = _get_client() + if client is None: + return SendMessageResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info( + "Sending Slack message to channel=%s thread_ts=%s", channel, thread_ts + ) + kwargs: dict = {"channel": channel, "text": text, "mrkdwn": True} + if thread_ts: + kwargs["thread_ts"] = thread_ts + + response = client.chat_postMessage(**kwargs) + + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=response.get("ts"), + channel=response.get("channel"), + timestamp=response.get("ts"), + ), + ) + except SlackApiError as e: + logger.exception("Slack send_message failed") + return SendMessageResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send Slack message: {str(e)}" + ) + + +@tool() +async def slack_add_reaction( + channel: str, timestamp: str, name: str +) -> SlackReactionResponse: + """Add an emoji reaction to a Slack message. + + Uses the reactions.add API method. The bot must be in the channel. + + Args: + channel: The channel ID containing the message. + timestamp: The timestamp (ts) of the message to react to. + name: The name of the emoji reaction without colons (e.g., "thumbsup", + "heart", "white_check_mark"). + + Returns: + Response indicating success, or an error. + """ + try: + client = _get_client() + if client is None: + return SlackReactionResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info( + "Adding Slack reaction name=%s to channel=%s ts=%s", + name, + channel, + timestamp, + ) + client.reactions_add(channel=channel, timestamp=timestamp, name=name) + + return SlackReactionResponse( + success=True, + data=SlackReactionData( + channel=channel, + timestamp=timestamp, + reaction=name, + ), + ) + except SlackApiError as e: + logger.exception("Slack add_reaction failed") + return SlackReactionResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack add_reaction failed") + return SlackReactionResponse( + success=False, error=f"Failed to add Slack reaction: {str(e)}" + ) + + +@tool() +async def slack_get_thread_replies( + channel: str, thread_ts: str, limit: int = 50 +) -> SlackThreadReplyResponse: + """Fetch replies in a Slack message thread. + + Uses the conversations.replies API method. Returns all messages in + the thread, including the parent message. + + Args: + channel: The channel ID containing the thread. + thread_ts: The timestamp (ts) of the parent message that started the thread. + limit: Maximum number of replies to retrieve (default 50, max 200). + + Returns: + Response containing the thread messages. + """ + try: + client = _get_client() + if client is None: + return SlackThreadReplyResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if limit < 1: + return SlackThreadReplyResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 200) + logger.info( + "Fetching Slack thread replies channel=%s thread_ts=%s limit=%d", + channel, + thread_ts, + capped_limit, + ) + response = client.conversations_replies( + channel=channel, ts=thread_ts, limit=capped_limit + ) + + messages = [ + MessageInfo( + text=msg.get("text", ""), + user=msg.get("user", "unknown"), + timestamp=msg.get("ts", ""), + channel=channel, + thread_ts=msg.get("thread_ts"), + ) + for msg in response.get("messages", []) + ] + + return SlackThreadReplyResponse( + success=True, + data=SlackThreadReplyData( + messages=messages, + count=len(messages), + channel=channel, + thread_ts=thread_ts, + ), + ) + except SlackApiError as e: + logger.exception("Slack get_thread_replies failed") + return SlackThreadReplyResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack get_thread_replies failed") + return SlackThreadReplyResponse( + success=False, error=f"Failed to get Slack thread replies: {str(e)}" + ) + + +@tool() +async def slack_get_user_info(user_id: str) -> SlackUserInfoResponse: + """Get profile information about a Slack user. + + Uses the users.info API method. + + Args: + user_id: The Slack user ID (e.g., "U0123456789"). + + Returns: + Response containing the user's profile information. + """ + try: + client = _get_client() + if client is None: + return SlackUserInfoResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info("Fetching Slack user info for user_id=%s", user_id) + response = client.users_info(user=user_id) + + user_data = response.get("user", {}) + profile = user_data.get("profile", {}) + + return SlackUserInfoResponse( + success=True, + data=SlackUserInfoData( + user=SlackUserInfo( + id=user_data.get("id", user_id), + name=user_data.get("name", ""), + real_name=profile.get("real_name"), + email=profile.get("email"), + is_bot=user_data.get("is_bot"), + is_admin=user_data.get("is_admin"), + ), + ), + ) + except SlackApiError as e: + logger.exception("Slack get_user_info failed") + return SlackUserInfoResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack get_user_info failed") + return SlackUserInfoResponse( + success=False, error=f"Failed to get Slack user info: {str(e)}" + ) + + +@tool() +async def slack_list_channels() -> ListChannelsResponse: + """List all channels in the Slack workspace. + + Uses the conversations.list API method. Returns both public and private + channels the bot has access to. + + Returns: + Response containing a list of channels with their IDs, names, and types. + """ + try: + client = _get_client() + if client is None: + return ListChannelsResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info("Listing Slack channels") + response = client.conversations_list() + + channels = [ + ChannelInfo( + id=ch["id"], + name=ch["name"], + type="public" if not ch.get("is_private") else "private", + is_member=ch.get("is_member"), + ) + for ch in response.get("channels", []) + ] + + return ListChannelsResponse( + success=True, + data=ListChannelsData(channels=channels, count=len(channels)), + ) + except SlackApiError as e: + logger.exception("Slack list_channels failed") + return ListChannelsResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack list_channels failed") + return ListChannelsResponse( + success=False, error=f"Failed to list Slack channels: {str(e)}" + ) + + +@tool() +async def slack_get_channel_history( + channel: str, limit: int = 100 +) -> ChannelHistoryResponse: + """Get the message history of a Slack channel. + + Uses the conversations.history API method. + + Args: + channel: The channel ID to fetch history from. + limit: The maximum number of messages to fetch (default 100, max 1000). + + Returns: + Response containing the channel's message history. + """ + try: + client = _get_client() + if client is None: + return ChannelHistoryResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if limit < 1: + return ChannelHistoryResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 1000) + logger.info( + "Fetching Slack channel history channel=%s limit=%d", channel, capped_limit + ) + response = client.conversations_history(channel=channel, limit=capped_limit) + + messages = [ + MessageInfo( + text=msg.get("text", ""), + user=msg.get("user", "unknown"), + timestamp=msg.get("ts", ""), + channel=channel, + thread_ts=msg.get("thread_ts"), + ) + for msg in response.get("messages", []) + ] + + return ChannelHistoryResponse( + success=True, + data=ChannelHistoryData( + messages=messages, count=len(messages), channel=channel + ), + ) + except SlackApiError as e: + logger.exception("Slack get_channel_history failed") + return ChannelHistoryResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack get_channel_history failed") + return ChannelHistoryResponse( + success=False, error=f"Failed to get channel history: {str(e)}" + ) + + +@tool() +async def slack_search_messages(query: str, limit: int = 20) -> SearchMessagesResponse: + """Search messages across the Slack workspace. + + Uses the search.messages API method. Requires a user token with + search:read scope (bot tokens cannot use search). + + Args: + query: The search query. Supports modifiers like from:@user, in:#channel, + has:link, has:reaction, before:YYYY-MM-DD, after:YYYY-MM-DD. + limit: The maximum number of results to return (default 20, max 100). + + Returns: + Response containing matching messages. + """ + try: + client = _get_client() + if client is None: + return SearchMessagesResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if limit < 1: + return SearchMessagesResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 100) + logger.info( + "Searching Slack messages query_length=%d limit=%d", + len(query), + capped_limit, + ) + response = client.search_messages(query=query, count=capped_limit) + + matches = response.get("messages", {}).get("matches", []) + messages = [ + MessageInfo( + text=msg.get("text", ""), + user=msg.get("user", "unknown"), + timestamp=msg.get("ts", ""), + channel=msg.get("channel", {}).get("name", ""), + permalink=msg.get("permalink", ""), + ) + for msg in matches + ] + + return SearchMessagesResponse( + success=True, + data=SearchMessagesData( + messages=messages, count=len(messages), query=query + ), + ) + except SlackApiError as e: + logger.exception("Slack search_messages failed") + return SearchMessagesResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack search_messages failed") + return SearchMessagesResponse( + success=False, error=f"Failed to search Slack messages: {str(e)}" + ) + + +@tool() +async def slack_reply_to_thread( + channel: str, thread_ts: str, text: str, broadcast: bool = False +) -> SendMessageResponse: + """Reply to a specific message thread in a Slack channel. + + Uses the chat.postMessage API method with thread_ts to post a threaded + reply. This is a convenience wrapper that ensures the reply goes into an + existing thread rather than starting a new conversation. + + Args: + channel: The channel ID containing the parent message thread. + thread_ts: The timestamp (ts) of the parent message to reply to. + This is the ts value from the original message that started + the thread. + text: The text of the reply. Supports Slack mrkdwn formatting + (e.g., *bold*, _italic_, ~strike~, `code`, ```code block```). + broadcast: If true, the reply will also be posted to the channel as a + regular message, visible outside the thread. Known as + "Also send to channel" in the Slack UI. + + Returns: + Response indicating success with message details, or an error. + """ + try: + client = _get_client() + if client is None: + return SendMessageResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if not thread_ts or not thread_ts.strip(): + return SendMessageResponse( + success=False, + error="thread_ts is required to reply in a thread.", + ) + + logger.info( + "Replying to Slack thread channel=%s thread_ts=%s broadcast=%s", + channel, + thread_ts, + broadcast, + ) + kwargs: dict = { + "channel": channel, + "text": text, + "thread_ts": thread_ts, + "mrkdwn": True, + } + if broadcast: + kwargs["reply_broadcast"] = True + + response = client.chat_postMessage(**kwargs) + + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=response.get("ts"), + channel=response.get("channel"), + timestamp=response.get("ts"), + ), + ) + except SlackApiError as e: + logger.exception("Slack reply_to_thread failed") + return SendMessageResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack reply_to_thread failed") + return SendMessageResponse( + success=False, error=f"Failed to reply to Slack thread: {str(e)}" + ) + + +@tool() +async def slack_set_channel_topic( + channel: str, topic: str +) -> SlackSetChannelTopicResponse: + """Set the topic of a Slack channel. + + Uses the conversations.setTopic API method. The bot must be a member of + the channel and have the necessary permissions to change the topic. + + Args: + channel: The channel ID to set the topic for. + topic: The new topic text for the channel (up to 250 characters). + + Returns: + Response indicating success with the updated topic, or an error. + """ + try: + client = _get_client() + if client is None: + return SlackSetChannelTopicResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if len(topic) > 250: + return SlackSetChannelTopicResponse( + success=False, + error="Topic must be 250 characters or fewer.", + ) + + logger.info("Setting Slack channel topic channel=%s", channel) + response = client.conversations_setTopic(channel=channel, topic=topic) + + return SlackSetChannelTopicResponse( + success=True, + data=SlackChannelTopicData( + channel=response.get("channel", {}).get("id", channel), + topic=response.get("channel", {}).get("topic", {}).get("value", topic), + ), + ) + except SlackApiError as e: + logger.exception("Slack set_channel_topic failed") + return SlackSetChannelTopicResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack set_channel_topic failed") + return SlackSetChannelTopicResponse( + success=False, error=f"Failed to set Slack channel topic: {str(e)}" + ) diff --git a/src/tools/messaging/telegram.py b/src/tools/messaging/telegram.py new file mode 100644 index 0000000..6c919ec --- /dev/null +++ b/src/tools/messaging/telegram.py @@ -0,0 +1,436 @@ +"""Telegram Bot API tools for sending messages, photos, and managing updates. + +Uses the Telegram Bot API (https://core.telegram.org/bots/api). +Requires the TELEGRAM_BOT_TOKEN environment variable. +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + EditTelegramMessageResponse, + MessageSentData, + SendMessageResponse, + SendTelegramPhotoResponse, + TelegramEditedMessageData, + TelegramPhotoSentData, + TelegramPinMessageResponse, + TelegramPinnedMessageData, + TelegramUpdate, + TelegramUpdatesData, + TelegramUpdatesResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Telegram tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.telegram") + +TELEGRAM_API_BASE = "https://api.telegram.org" + + +def _get_bot_url() -> str | None: + """Build the Telegram Bot API base URL from the environment token.""" + token = os.getenv("TELEGRAM_BOT_TOKEN") + if not token: + return None + return f"{TELEGRAM_API_BASE}/bot{token}" + + +@tool() +async def telegram_send_message( + chat_id: str, + text: str, + parse_mode: str | None = None, + disable_notification: bool = False, + reply_to_message_id: int | None = None, +) -> SendMessageResponse: + """Send a text message to a Telegram chat. + + Uses the sendMessage Bot API method. + + Args: + chat_id: The unique identifier for the target chat or username of the + target channel (in the format @channelusername). + text: The text of the message to send (1-4096 characters). + parse_mode: Optional formatting mode. One of "MarkdownV2", "HTML", or + "Markdown" (legacy). When set, special characters must be + escaped according to the chosen mode. + disable_notification: If true, the message is sent silently and users + receive a notification with no sound. + reply_to_message_id: Optional message ID to reply to, making this + message a reply in the conversation. + + Returns: + Response indicating success with message details, or an error. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return SendMessageResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = {"chat_id": chat_id, "text": text} + if parse_mode: + payload["parse_mode"] = parse_mode + if disable_notification: + payload["disable_notification"] = True + if reply_to_message_id is not None: + payload["reply_to_message_id"] = reply_to_message_id + + logger.info("Sending Telegram message to chat_id=%s", chat_id) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/sendMessage", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return SendMessageResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + result = data.get("result", {}) + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=str(result.get("message_id", "")), + channel=str(result.get("chat", {}).get("id", "")), + timestamp=str(result.get("date", "")), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram send_message HTTP error") + return SendMessageResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send Telegram message: {str(e)}" + ) + + +@tool() +async def telegram_send_photo( + chat_id: str, + photo_url: str, + caption: str | None = None, + parse_mode: str | None = None, +) -> SendTelegramPhotoResponse: + """Send a photo to a Telegram chat via URL. + + Uses the sendPhoto Bot API method. The photo must be accessible via a + public URL. For file uploads, use the Telegram file upload endpoint instead. + + Args: + chat_id: The unique identifier for the target chat or username of the + target channel (in the format @channelusername). + photo_url: URL of the photo to send. Telegram will download the image + from this URL. Must be a direct link to an image file + (JPEG, PNG, etc.) and less than 10 MB. + caption: Optional caption for the photo (0-1024 characters). + parse_mode: Optional formatting mode for the caption. One of + "MarkdownV2", "HTML", or "Markdown" (legacy). + + Returns: + Response indicating success with the sent photo message details. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return SendTelegramPhotoResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = {"chat_id": chat_id, "photo": photo_url} + if caption: + payload["caption"] = caption + if parse_mode: + payload["parse_mode"] = parse_mode + + logger.info("Sending Telegram photo to chat_id=%s", chat_id) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/sendPhoto", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return SendTelegramPhotoResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + result = data.get("result", {}) + return SendTelegramPhotoResponse( + success=True, + data=TelegramPhotoSentData( + message_id=str(result.get("message_id", "")), + chat_id=str(result.get("chat", {}).get("id", "")), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram send_photo HTTP error") + return SendTelegramPhotoResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram send_photo failed") + return SendTelegramPhotoResponse( + success=False, error=f"Failed to send Telegram photo: {str(e)}" + ) + + +@tool() +async def telegram_edit_message( + chat_id: str, + message_id: int, + text: str, + parse_mode: str | None = None, +) -> EditTelegramMessageResponse: + """Edit the text of a previously sent Telegram message. + + Uses the editMessageText Bot API method. Can only edit messages sent by the + bot itself (not messages from other users). + + Args: + chat_id: The unique identifier for the chat containing the message. + message_id: The ID of the message to edit. + text: The new text of the message (1-4096 characters). + parse_mode: Optional formatting mode. One of "MarkdownV2", "HTML", + or "Markdown" (legacy). + + Returns: + Response indicating success with the edited message details. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return EditTelegramMessageResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + if parse_mode: + payload["parse_mode"] = parse_mode + + logger.info( + "Editing Telegram message chat_id=%s message_id=%d", chat_id, message_id + ) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/editMessageText", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return EditTelegramMessageResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + result = data.get("result", {}) + return EditTelegramMessageResponse( + success=True, + data=TelegramEditedMessageData( + message_id=str(result.get("message_id", "")), + chat_id=str(result.get("chat", {}).get("id", "")), + text=result.get("text"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram edit_message HTTP error") + return EditTelegramMessageResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram edit_message failed") + return EditTelegramMessageResponse( + success=False, error=f"Failed to edit Telegram message: {str(e)}" + ) + + +@tool() +async def telegram_get_updates( + limit: int = 10, offset: int | None = None +) -> TelegramUpdatesResponse: + """Get recent updates (incoming messages) from the Telegram bot. + + Uses the getUpdates Bot API method. Returns updates in chronological order. + Use the offset parameter to acknowledge processed updates and avoid + receiving them again. + + Args: + limit: Maximum number of updates to retrieve (default 10, max 100). + offset: Identifier of the first update to return. Set to + last_update_id + 1 to acknowledge all previous updates. + + Returns: + Response containing a list of recent updates. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return TelegramUpdatesResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + if limit < 1: + return TelegramUpdatesResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 100) + params: dict = {"limit": capped_limit} + if offset is not None: + params["offset"] = offset + + logger.info( + "Fetching Telegram updates limit=%d offset=%s", capped_limit, offset + ) + async with httpx.AsyncClient() as client: + response = await client.get( + f"{bot_url}/getUpdates", + params=params, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return TelegramUpdatesResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + raw_updates = data.get("result", []) + updates = [ + TelegramUpdate( + update_id=u["update_id"], + message_text=u.get("message", {}).get("text"), + chat_id=u.get("message", {}).get("chat", {}).get("id"), + from_user=u.get("message", {}).get("from", {}).get("username"), + date=u.get("message", {}).get("date"), + ) + for u in raw_updates + ] + + return TelegramUpdatesResponse( + success=True, + data=TelegramUpdatesData(updates=updates, count=len(updates)), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram get_updates HTTP error") + return TelegramUpdatesResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram get_updates failed") + return TelegramUpdatesResponse( + success=False, error=f"Failed to get Telegram updates: {str(e)}" + ) + + +@tool() +async def telegram_pin_message( + chat_id: str, + message_id: int, + disable_notification: bool = False, +) -> TelegramPinMessageResponse: + """Pin a message in a Telegram chat. + + Uses the pinChatMessage Bot API method. The bot must be an administrator + in the group or channel with the can_pin_messages permission. + + Args: + chat_id: The unique identifier for the chat or username of the + target channel (in the format @channelusername). + message_id: The ID of the message to pin. + disable_notification: If true, the notification is sent silently + and users receive a notification with no sound. + + Returns: + Response indicating success, or an error. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return TelegramPinMessageResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = { + "chat_id": chat_id, + "message_id": message_id, + } + if disable_notification: + payload["disable_notification"] = True + + logger.info( + "Pinning Telegram message chat_id=%s message_id=%d", chat_id, message_id + ) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/pinChatMessage", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return TelegramPinMessageResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + return TelegramPinMessageResponse( + success=True, + data=TelegramPinnedMessageData( + chat_id=chat_id, + message_id=message_id, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram pin_message HTTP error") + return TelegramPinMessageResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram pin_message failed") + return TelegramPinMessageResponse( + success=False, error=f"Failed to pin Telegram message: {str(e)}" + ) diff --git a/src/tools/messaging/whatsapp.py b/src/tools/messaging/whatsapp.py new file mode 100644 index 0000000..26302fd --- /dev/null +++ b/src/tools/messaging/whatsapp.py @@ -0,0 +1,311 @@ +"""WhatsApp Cloud API tools for sending text, template, and media messages. + +Uses the Meta WhatsApp Business Cloud API (https://developers.facebook.com/docs/whatsapp/cloud-api). +Requires WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables. +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + MessageSentData, + SendMessageResponse, + SendWhatsAppMediaResponse, + SendWhatsAppTemplateResponse, + WhatsAppMediaSentData, + WhatsAppTemplateSentData, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for WhatsApp tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.whatsapp") + +WHATSAPP_API_BASE = "https://graph.facebook.com" +WHATSAPP_API_VERSION = "v22.0" + + +def _get_config() -> tuple[str, str, dict[str, str]] | None: + """Get WhatsApp API configuration from environment. + + Returns: + Tuple of (url, phone_number_id, headers) or None if not configured. + """ + token = os.getenv("WHATSAPP_TOKEN") + phone_number_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID") + if not token or not phone_number_id: + return None + + url = f"{WHATSAPP_API_BASE}/{WHATSAPP_API_VERSION}/{phone_number_id}/messages" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + return url, phone_number_id, headers + + +@tool() +async def whatsapp_send_message( + to: str, message: str, preview_url: bool = False +) -> SendMessageResponse: + """Send a text message via the WhatsApp Cloud API. + + Uses the POST /{phone_number_id}/messages endpoint. The recipient must + have an active WhatsApp account on the given phone number. + + Args: + to: Recipient's phone number in international format without the leading + '+' sign (e.g., "1234567890" for a US number). Must include country code. + message: The text message to send (up to 4096 characters). + preview_url: If true, WhatsApp will attempt to render a link preview + for the first URL found in the message text. + + Returns: + Response indicating success with message ID, or an error. + """ + try: + config = _get_config() + if config is None: + return SendMessageResponse( + success=False, + error="WhatsApp not configured. Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables.", + ) + + url, _, headers = config + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to, + "type": "text", + "text": {"preview_url": preview_url, "body": message}, + } + + logger.info("Sending WhatsApp message to=%s", to) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + + messages_list = data.get("messages", []) + message_id = messages_list[0].get("id") if messages_list else None + + logger.info("WhatsApp message sent successfully to=%s", to) + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=message_id, + channel=to, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("WhatsApp send_message HTTP error") + return SendMessageResponse( + success=False, + error=f"WhatsApp API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("WhatsApp send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send WhatsApp message: {str(e)}" + ) + + +@tool() +async def whatsapp_send_template( + to: str, + template_name: str, + language_code: str = "en_US", + header_params: str | None = None, + body_params: str | None = None, +) -> SendWhatsAppTemplateResponse: + """Send a pre-approved template message via the WhatsApp Cloud API. + + Uses the POST /{phone_number_id}/messages endpoint with type "template". + Templates must be created and approved in the Meta Business Manager before + they can be used. Template messages are required to initiate conversations + outside the 24-hour customer service window. + + Args: + to: Recipient's phone number in international format without the leading + '+' sign (e.g., "1234567890"). Must include country code. + template_name: The name of the approved message template. + language_code: The language/locale code for the template (default "en_US"). + Must match a language the template was approved for. + header_params: Optional comma-separated parameter values for the template + header (e.g., "John Doe" or "Order #123"). + body_params: Optional comma-separated parameter values for the template + body placeholders (e.g., "John,42,shipped"). + + Returns: + Response indicating success with message ID, or an error. + """ + try: + config = _get_config() + if config is None: + return SendWhatsAppTemplateResponse( + success=False, + error="WhatsApp not configured. Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables.", + ) + + url, _, headers = config + + template: dict = { + "name": template_name, + "language": {"code": language_code}, + } + + components: list[dict] = [] + if header_params: + params = [ + {"type": "text", "text": p.strip()} for p in header_params.split(",") + ] + components.append({"type": "header", "parameters": params}) + + if body_params: + params = [ + {"type": "text", "text": p.strip()} for p in body_params.split(",") + ] + components.append({"type": "body", "parameters": params}) + + if components: + template["components"] = components + + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to, + "type": "template", + "template": template, + } + + logger.info( + "Sending WhatsApp template=%s to=%s lang=%s", + template_name, + to, + language_code, + ) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + + messages_list = data.get("messages", []) + message_id = messages_list[0].get("id") if messages_list else None + + logger.info("WhatsApp template sent successfully to=%s", to) + return SendWhatsAppTemplateResponse( + success=True, + data=WhatsAppTemplateSentData( + message_id=message_id, + to=to, + template_name=template_name, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("WhatsApp send_template HTTP error") + return SendWhatsAppTemplateResponse( + success=False, + error=f"WhatsApp API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("WhatsApp send_template failed") + return SendWhatsAppTemplateResponse( + success=False, error=f"Failed to send WhatsApp template: {str(e)}" + ) + + +@tool() +async def whatsapp_send_media( + to: str, + media_type: str, + media_url: str, + caption: str | None = None, + filename: str | None = None, +) -> SendWhatsAppMediaResponse: + """Send a media message (image, document, video, or audio) via WhatsApp. + + Uses the POST /{phone_number_id}/messages endpoint with the specified + media type. The media must be accessible via a public URL. + + Args: + to: Recipient's phone number in international format without the leading + '+' sign (e.g., "1234567890"). Must include country code. + media_type: Type of media to send. Must be one of: "image", "document", + "video", "audio". Images support JPEG/PNG (max 5 MB), + documents support PDF/DOC/etc. (max 100 MB), videos support + MP4 (max 16 MB), audio supports MP3/OGG (max 16 MB). + media_url: Public URL of the media file to send. + caption: Optional caption for the media (supported for image, video, + and document types; up to 1024 characters). + filename: Optional filename for documents (shown to the recipient). + + Returns: + Response indicating success with message ID, or an error. + """ + try: + config = _get_config() + if config is None: + return SendWhatsAppMediaResponse( + success=False, + error="WhatsApp not configured. Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables.", + ) + + valid_types = {"image", "document", "video", "audio"} + if media_type not in valid_types: + return SendWhatsAppMediaResponse( + success=False, + error=f"Invalid media_type '{media_type}'. Must be one of: {', '.join(sorted(valid_types))}.", + ) + + url, _, headers = config + + media_payload: dict = {"link": media_url} + if caption and media_type in {"image", "video", "document"}: + media_payload["caption"] = caption + if filename and media_type == "document": + media_payload["filename"] = filename + + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to, + "type": media_type, + media_type: media_payload, + } + + logger.info("Sending WhatsApp %s to=%s url=%s", media_type, to, media_url) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload, timeout=60) + response.raise_for_status() + data = response.json() + + messages_list = data.get("messages", []) + message_id = messages_list[0].get("id") if messages_list else None + + logger.info("WhatsApp %s sent successfully to=%s", media_type, to) + return SendWhatsAppMediaResponse( + success=True, + data=WhatsAppMediaSentData( + message_id=message_id, + to=to, + media_type=media_type, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("WhatsApp send_media HTTP error") + return SendWhatsAppMediaResponse( + success=False, + error=f"WhatsApp API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("WhatsApp send_media failed") + return SendWhatsAppMediaResponse( + success=False, error=f"Failed to send WhatsApp media: {str(e)}" + ) diff --git a/src/tools/social/SKILL.md b/src/tools/social/SKILL.md new file mode 100644 index 0000000..0cf8d17 --- /dev/null +++ b/src/tools/social/SKILL.md @@ -0,0 +1,72 @@ +--- +name: social-media +description: Interact with X (Twitter) for searching tweets, posting tweets, and fetching user profiles and timelines. +--- + +# Social Media Tools + +Tools for interacting with X (Twitter). + +## Requirements + +Set environment variables depending on which tools you need: + +- **X / Twitter**: `X_BEARER_TOKEN` (read), `X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET` (write) + +## X / Twitter + +### Post a tweet + +```python +result = await x_post_tweet(text="Hello from HuMCP!") +``` + +### Search tweets + +```python +result = await x_search_tweets( + query="OpenAI", + max_results=10 +) +``` + +### Get user info + +```python +result = await x_get_user(username="elonmusk") +``` + +### Get user tweets + +```python +result = await x_get_user_tweets( + username="elonmusk", + max_results=10 +) +``` + +## Response Format + +All tools return a consistent response: + +```json +{ + "success": true, + "data": { ... } +} +``` + +On failure: + +```json +{ + "success": false, + "error": "Description of what went wrong" +} +``` + +## When to Use + +- Monitoring X/Twitter for mentions or trending topics +- Posting tweets from automated workflows +- Fetching user profiles and recent timelines diff --git a/src/tools/social/__init__.py b/src/tools/social/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/social/schemas.py b/src/tools/social/schemas.py new file mode 100644 index 0000000..d774ddf --- /dev/null +++ b/src/tools/social/schemas.py @@ -0,0 +1,110 @@ +"""Pydantic output schemas for social media tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# X / Twitter Schemas +# ============================================================================= + + +class XTweetPublicMetrics(BaseModel): + """Public engagement metrics for a tweet.""" + + retweet_count: int = Field(0, description="Number of retweets") + reply_count: int = Field(0, description="Number of replies") + like_count: int = Field(0, description="Number of likes") + quote_count: int = Field(0, description="Number of quote tweets") + bookmark_count: int = Field(0, description="Number of bookmarks") + impression_count: int = Field(0, description="Number of impressions") + + +class XTweet(BaseModel): + """A single X/Twitter tweet.""" + + id: str = Field(..., description="Tweet ID") + text: str = Field(..., description="Tweet text content") + author_id: str | None = Field(None, description="Author user ID") + author_username: str | None = Field(None, description="Author username") + created_at: str | None = Field(None, description="Creation timestamp") + url: str | None = Field(None, description="URL to the tweet") + public_metrics: XTweetPublicMetrics | None = Field( + None, description="Public engagement metrics" + ) + + +class XPostTweetData(BaseModel): + """Output data for x_post_tweet tool.""" + + id: str = Field(..., description="Created tweet ID") + text: str = Field(..., description="Tweet text") + url: str = Field(..., description="URL to the created tweet") + + +class XSearchTweetsData(BaseModel): + """Output data for x_search_tweets tool.""" + + query: str = Field(..., description="The search query that was executed") + count: int = Field(0, description="Number of results returned") + tweets: list[XTweet] = Field( + default_factory=list, description="List of matching tweets" + ) + + +class XUserTweetsData(BaseModel): + """Output data for x_get_user_tweets tool.""" + + user_id: str = Field(..., description="User ID whose tweets were fetched") + username: str | None = Field(None, description="Username / handle") + count: int = Field(0, description="Number of tweets returned") + tweets: list[XTweet] = Field( + default_factory=list, description="List of user tweets" + ) + + +class XUserData(BaseModel): + """Output data for x_get_user tool.""" + + id: str = Field(..., description="User ID") + name: str = Field(..., description="Display name") + username: str = Field(..., description="Username / handle") + description: str | None = Field(None, description="User bio") + profile_image_url: str | None = Field(None, description="Profile image URL") + location: str | None = Field(None, description="User location") + url: str | None = Field(None, description="User website URL") + created_at: str | None = Field(None, description="Account creation date (ISO)") + verified: bool = Field(False, description="Whether the user is verified") + followers_count: int = Field(0, description="Number of followers") + following_count: int = Field(0, description="Number of accounts followed") + tweet_count: int = Field(0, description="Total number of tweets") + listed_count: int = Field(0, description="Number of lists the user is a member of") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class XPostTweetResponse(ToolResponse[XPostTweetData]): + """Response schema for x_post_tweet tool.""" + + pass + + +class XSearchTweetsResponse(ToolResponse[XSearchTweetsData]): + """Response schema for x_search_tweets tool.""" + + pass + + +class XUserResponse(ToolResponse[XUserData]): + """Response schema for x_get_user tool.""" + + pass + + +class XUserTweetsResponse(ToolResponse[XUserTweetsData]): + """Response schema for x_get_user_tweets tool.""" + + pass diff --git a/src/tools/social/x.py b/src/tools/social/x.py new file mode 100644 index 0000000..1ee925c --- /dev/null +++ b/src/tools/social/x.py @@ -0,0 +1,400 @@ +"""X (Twitter) tools for posting tweets, searching, and fetching user info via API v2.""" + +from __future__ import annotations + +import logging +import os + +import httpx + +from src.humcp.decorator import tool +from src.tools.social.schemas import ( + XPostTweetData, + XPostTweetResponse, + XSearchTweetsData, + XSearchTweetsResponse, + XTweet, + XTweetPublicMetrics, + XUserData, + XUserResponse, + XUserTweetsData, + XUserTweetsResponse, +) + +logger = logging.getLogger("humcp.tools.x") + +_BASE_URL = "https://api.twitter.com/2" + + +def _get_bearer_headers() -> dict[str, str] | None: + """Return Authorization headers using the bearer token, or None if not configured.""" + token = os.getenv("X_BEARER_TOKEN") + if not token: + return None + return {"Authorization": f"Bearer {token}"} + + +def _get_oauth_headers(method: str, url: str) -> dict[str, str] | None: + """Return OAuth 1.0a headers for write operations. + + Requires X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, and X_ACCESS_TOKEN_SECRET. + Uses the authlib library for OAuth1 signing. + """ + api_key = os.getenv("X_API_KEY") + api_secret = os.getenv("X_API_SECRET") + access_token = os.getenv("X_ACCESS_TOKEN") + access_token_secret = os.getenv("X_ACCESS_TOKEN_SECRET") + + if not all([api_key, api_secret, access_token, access_token_secret]): + return None + + try: + from authlib.integrations.httpx_client import OAuth1Auth # type: ignore + + auth = OAuth1Auth( + client_id=api_key, + client_secret=api_secret, + token=access_token, + token_secret=access_token_secret, + ) + # Build a dummy request to sign + req = httpx.Request(method, url) + signed = auth(req) + return dict(signed.headers) + except ImportError: + logger.warning( + "authlib is required for OAuth1 signing. Install with: pip install authlib" + ) + return None + + +@tool() +async def x_post_tweet(text: str) -> XPostTweetResponse: + """Post a new tweet on X (Twitter). + + Args: + text: The text content of the tweet (max 280 characters). + + Returns: + The created tweet details or an error message. + """ + try: + url = f"{_BASE_URL}/tweets" + headers = _get_oauth_headers("POST", url) + if headers is None: + return XPostTweetResponse( + success=False, + error="X API not configured for posting. Set X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, and X_ACCESS_TOKEN_SECRET.", + ) + + if len(text) > 280: + return XPostTweetResponse( + success=False, + error=f"Tweet text exceeds 280 characters (got {len(text)}).", + ) + + logger.info("X post tweet length=%d", len(text)) + headers["Content-Type"] = "application/json" + + async with httpx.AsyncClient() as client: + response = await client.post( + url, + headers=headers, + json={"text": text}, + ) + response.raise_for_status() + data = response.json() + + tweet_id = data["data"]["id"] + tweet_text = data["data"]["text"] + tweet_url = f"https://x.com/i/status/{tweet_id}" + + logger.info("X tweet posted id=%s", tweet_id) + return XPostTweetResponse( + success=True, + data=XPostTweetData( + id=tweet_id, + text=tweet_text, + url=tweet_url, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X post tweet HTTP error") + return XPostTweetResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X post tweet failed") + return XPostTweetResponse(success=False, error=f"X post tweet failed: {e}") + + +def _parse_tweet(tweet_data: dict, users_map: dict[str, str]) -> XTweet: + """Parse a single tweet response dict into an XTweet model.""" + author_id = tweet_data.get("author_id") + username = users_map.get(author_id, "unknown") if author_id else "unknown" + + metrics = tweet_data.get("public_metrics") + public_metrics = None + if metrics: + public_metrics = XTweetPublicMetrics( + retweet_count=metrics.get("retweet_count", 0), + reply_count=metrics.get("reply_count", 0), + like_count=metrics.get("like_count", 0), + quote_count=metrics.get("quote_count", 0), + bookmark_count=metrics.get("bookmark_count", 0), + impression_count=metrics.get("impression_count", 0), + ) + + return XTweet( + id=tweet_data["id"], + text=tweet_data["text"], + author_id=author_id, + author_username=username, + created_at=tweet_data.get("created_at"), + url=f"https://x.com/{username}/status/{tweet_data['id']}", + public_metrics=public_metrics, + ) + + +@tool() +async def x_search_tweets( + query: str, + max_results: int = 10, +) -> XSearchTweetsResponse: + """Search recent tweets on X (Twitter) matching a query. + + Uses the Twitter API v2 recent search endpoint. Returns tweets from the + last 7 days matching the query, including public engagement metrics. + + Args: + query: The search query string. Supports Twitter search operators (e.g. "from:user", "#hashtag", "lang:en"). + max_results: Maximum number of results to return (10-100, default 10). + + Returns: + Matching tweets with public metrics or an error message. + """ + try: + headers = _get_bearer_headers() + if headers is None: + return XSearchTweetsResponse( + success=False, + error="X API not configured. Set X_BEARER_TOKEN environment variable.", + ) + + max_results = max(10, min(max_results, 100)) + logger.info("X search query=%r max_results=%d", query, max_results) + + params = { + "query": query, + "max_results": max_results, + "tweet.fields": "author_id,created_at,text,public_metrics", + "expansions": "author_id", + "user.fields": "username", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_BASE_URL}/tweets/search/recent", + headers=headers, + params=params, + ) + response.raise_for_status() + data = response.json() + + # Build user lookup + users_map: dict[str, str] = {} + for user in data.get("includes", {}).get("users", []): + users_map[user["id"]] = user.get("username", "unknown") + + tweets: list[XTweet] = [ + _parse_tweet(td, users_map) for td in data.get("data", []) + ] + + logger.info("X search complete results=%d", len(tweets)) + return XSearchTweetsResponse( + success=True, + data=XSearchTweetsData( + query=query, + count=len(tweets), + tweets=tweets, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X search tweets HTTP error") + return XSearchTweetsResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X search tweets failed") + return XSearchTweetsResponse( + success=False, error=f"X search tweets failed: {e}" + ) + + +@tool() +async def x_get_user(username: str) -> XUserResponse: + """Get public profile information for an X (Twitter) user. + + Returns the user's profile details including bio, location, profile image, + account creation date, verification status, and public metrics. + + Args: + username: The X username / handle (without the @ symbol). + + Returns: + User profile details or an error message. + """ + try: + headers = _get_bearer_headers() + if headers is None: + return XUserResponse( + success=False, + error="X API not configured. Set X_BEARER_TOKEN environment variable.", + ) + + logger.info("X get user username=%s", username) + + params = { + "user.fields": "created_at,description,location,profile_image_url,public_metrics,url,verified", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_BASE_URL}/users/by/username/{username}", + headers=headers, + params=params, + ) + response.raise_for_status() + data = response.json() + + user_data = data.get("data") + if not user_data: + return XUserResponse( + success=False, + error=f"User '{username}' not found.", + ) + + metrics = user_data.get("public_metrics", {}) + return XUserResponse( + success=True, + data=XUserData( + id=user_data["id"], + name=user_data["name"], + username=user_data["username"], + description=user_data.get("description"), + profile_image_url=user_data.get("profile_image_url"), + location=user_data.get("location"), + url=user_data.get("url"), + created_at=user_data.get("created_at"), + verified=user_data.get("verified", False), + followers_count=metrics.get("followers_count", 0), + following_count=metrics.get("following_count", 0), + tweet_count=metrics.get("tweet_count", 0), + listed_count=metrics.get("listed_count", 0), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X get user HTTP error") + return XUserResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X get user failed") + return XUserResponse(success=False, error=f"X get user failed: {e}") + + +@tool() +async def x_get_user_tweets( + username: str, + max_results: int = 10, +) -> XUserTweetsResponse: + """Get recent tweets posted by a specific X (Twitter) user. + + First resolves the username to a user ID, then fetches their recent tweets + including public engagement metrics (likes, retweets, replies, etc.). + + Args: + username: The X username / handle (without the @ symbol). + max_results: Maximum number of tweets to return (5-100, default 10). + + Returns: + List of the user's recent tweets or an error message. + """ + try: + headers = _get_bearer_headers() + if headers is None: + return XUserTweetsResponse( + success=False, + error="X API not configured. Set X_BEARER_TOKEN environment variable.", + ) + + max_results = max(5, min(max_results, 100)) + logger.info( + "X get user tweets username=%s max_results=%d", username, max_results + ) + + async with httpx.AsyncClient() as client: + # First resolve username to user ID + user_resp = await client.get( + f"{_BASE_URL}/users/by/username/{username}", + headers=headers, + params={"user.fields": "id"}, + ) + user_resp.raise_for_status() + user_json = user_resp.json() + + user_data = user_json.get("data") + if not user_data: + return XUserTweetsResponse( + success=False, + error=f"User '{username}' not found.", + ) + + user_id = user_data["id"] + + async with httpx.AsyncClient() as client: + tweets_resp = await client.get( + f"{_BASE_URL}/users/{user_id}/tweets", + headers=headers, + params={ + "max_results": max_results, + "tweet.fields": "author_id,created_at,text,public_metrics", + "expansions": "author_id", + "user.fields": "username", + }, + ) + tweets_resp.raise_for_status() + data = tweets_resp.json() + + users_map: dict[str, str] = {} + for user in data.get("includes", {}).get("users", []): + users_map[user["id"]] = user.get("username", "unknown") + + tweets: list[XTweet] = [ + _parse_tweet(td, users_map) for td in data.get("data", []) + ] + + logger.info("X get user tweets complete results=%d", len(tweets)) + return XUserTweetsResponse( + success=True, + data=XUserTweetsData( + user_id=user_id, + username=username, + count=len(tweets), + tweets=tweets, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X get user tweets HTTP error") + return XUserTweetsResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X get user tweets failed") + return XUserTweetsResponse( + success=False, error=f"X get user tweets failed: {e}" + ) diff --git a/toolset/.dockerignore b/toolset/.dockerignore new file mode 100644 index 0000000..7ad1789 --- /dev/null +++ b/toolset/.dockerignore @@ -0,0 +1,76 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +eggs/ +.eggs/ +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore +.gitattributes + +# Environment files +.env +.env.local +.env.*.local + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +test*/ +tests/ +*.test.py + +# Documentation +docs/ +*.rst +# Exclude markdown except README.md (needed for pyproject.toml) +*.md +!README.md + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Misc +*.bak +*.tmp +*.swp +.cache/ +examples/ diff --git a/toolset/.env.example b/toolset/.env.example new file mode 100644 index 0000000..e350a4b --- /dev/null +++ b/toolset/.env.example @@ -0,0 +1,59 @@ +# ============================================================================= +# Toolset - Environment Variables (for local development) +# ============================================================================= +# Copy this file to .env: cp .env.example .env +# +# Note: When running with Docker Compose, use the root .env file instead. +# These are for local development without Docker. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +MCP_SERVER_URL=http://localhost:8003 + +# ----------------------------------------------------------------------------- +# Service Authentication (must match backend) +# ----------------------------------------------------------------------------- +# Generate with: openssl rand -hex 32 +SERVICE_API_KEY=dev-api-key-change-in-production + +# ----------------------------------------------------------------------------- +# Per-User Authentication (must match backend JWT_SECRET_KEY) +# ----------------------------------------------------------------------------- +# When set, toolset verifies user JWTs forwarded by the backend. +# Without this, all permission checks pass through (dev mode). +# JWT_SECRET_KEY=your-generated-jwt-secret-key + +# Set to "true" to enforce authentication even without JWT_SECRET_KEY. +# When true, unauthenticated requests get 401. Use in production. +# TOOLSET_REQUIRE_AUTH=false + +# ----------------------------------------------------------------------------- +# Web Search - Tavily (Optional) +# ----------------------------------------------------------------------------- +# Get your API key from: https://tavily.com +# TAVILY_API_KEY=your-tavily-api-key + +# ----------------------------------------------------------------------------- +# Google Workspace (Optional) +# ----------------------------------------------------------------------------- +# Create credentials at: https://console.cloud.google.com/apis/credentials +# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com +# GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret + +# ----------------------------------------------------------------------------- +# Storage - S3-Compatible (MinIO, AWS S3, GCS S3 interop, etc.) +# ----------------------------------------------------------------------------- +STORAGE_ENDPOINT=localhost:9000 # AWS S3: s3.amazonaws.com | GCS: storage.googleapis.com +STORAGE_ACCESS_KEY=minioadmin # AWS: AWS_ACCESS_KEY_ID | GCS: HMAC access ID +STORAGE_SECRET_KEY=minioadmin # AWS: AWS_SECRET_ACCESS_KEY | GCS: HMAC secret +STORAGE_BUCKET=graphite-uploads +STORAGE_SECURE=false # true for AWS S3 / GCS +# STORAGE_REGION= # e.g. us-east-1 for AWS, auto for GCS +# STORAGE_ALLOWED_BUCKETS=graphite-uploads + +# ----------------------------------------------------------------------------- +# Database (for database tools) +# ----------------------------------------------------------------------------- +# DATABASE_URL=postgresql://user:password@localhost:5432/graphite_builder diff --git a/toolset/.python-version b/toolset/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/toolset/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/toolset/.vscode/settings.json b/toolset/.vscode/settings.json new file mode 100644 index 0000000..fc6ab45 --- /dev/null +++ b/toolset/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.analysis.extraPaths": ["."], + "python.testing.pytestArgs": ["tests/"] +} diff --git a/toolset/CLAUDE.md b/toolset/CLAUDE.md new file mode 100644 index 0000000..a0c9cd4 --- /dev/null +++ b/toolset/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Install dependencies +uv sync + +# Run server locally +uv run uvicorn src.main:app --host 0.0.0.0 --port 8003 + +# Run tests +uv run pytest + +# Run a single test file +uv run pytest tests/tools/local/test_calculator.py + +# Run a specific test +uv run pytest tests/tools/local/test_calculator.py::test_add -v + +# Linting +uv run ruff check + +# Type checking +uv run mypy src/ + +# Pre-commit hooks +uv run pre-commit run --all-files + +# Docker +docker compose up --build +``` + +## Architecture + +HuMCP exposes tools via both MCP (Model Context Protocol) at `/mcp` and REST at `/tools/*`. + +### Core Library (`src/humcp/`) + +- **`server.py`** - `create_app()` creates FastAPI app, loads tool modules, registers with FastMCP +- **`decorator.py`** - `@tool(category="...")` marks functions for discovery. Category auto-detects from parent folder if not specified. Tool name = function name. Also defines `RegisteredTool` NamedTuple that wraps FastMCP's `FunctionTool` with category +- **`routes.py`** - Generates REST endpoints from registered tools using `FunctionTool.parameters` +- **`config.py`** - Tool filtering via `config/tools.yaml` (include/exclude with wildcard support) +- **`skills.py`** - Discovers `SKILL.md` files for category metadata +- **`schemas.py`** - Pydantic response models for API endpoints + +### Tool Discovery Flow + +1. `create_app()` loads Python modules from `src/tools/` recursively +2. Functions with `@tool()` decorator are discovered via `_humcp_tool` attribute +3. Each tool is registered with FastMCP via `mcp.tool()(func)` which creates a `FunctionTool` +4. `RegisteredTool(tool=fn_tool, category=...)` pairs the FunctionTool with its category +5. REST routes are generated from `FunctionTool.parameters` (JSON Schema) + +### Adding New Tools + +Create a `.py` file in `src/tools//`: + +```python +from src.humcp.decorator import tool + +@tool() # category auto-detected from folder name +async def my_tool(param: str) -> dict: + """Tool description (used by FastMCP and Swagger).""" + return {"success": True, "data": {"result": param}} +``` + +The function name becomes the tool name. Tools are auto-discovered on server startup. + +### Response Pattern + +```python +# Success +return {"success": True, "data": {...}} + +# Error +return {"success": False, "error": "Error message"} +``` + +### Skills + +Add `SKILL.md` in tool category folders with YAML frontmatter: + +```markdown +--- +name: skill-name +description: When and how to use these tools +--- +# Content for AI assistants +``` + +### Tool Configuration + +`config/tools.yaml` controls which tools are exposed: + +```yaml +include: + categories: [local, data] + tools: [tavily_web_search] +exclude: + tools: [shell_*] # wildcards supported +``` + +Empty config = load all tools. + +### Authentication & Permissions (`src/humcp/`) + +- **`auth.py`** - `get_current_user_id()` resolves the calling user's UUID. Tries FastMCP's `get_access_token()` first (MCP path via `JWTVerifier`), falls back to ContextVar (REST path via `APIKeyMiddleware` JWT extraction) +- **`permissions.py`** - Permission checking against `iam.relationships` table: + - `require_auth()` — requires a user context, returns `UUID | None`. Raises 401 if `TOOLSET_REQUIRE_AUTH=true` and no user + - `check_permission(object_type, object_id, relation)` — verifies the user has the required relation (e.g. `"viewer"`, `"editor"`, `"owner"`) on a resource. Raises 403 if denied + - `has_permission()` — low-level check: direct actor tuples + org-based tuples, uses `satisfying_relations()` from `shared.auth` for hierarchy resolution +- **`middleware.py`** - `APIKeyMiddleware` validates `X-API-Key` header and extracts user identity from `Authorization: Bearer ` into a ContextVar +- **`server.py`** - Wires `JWTVerifier` into FastMCP when `JWT_SECRET_KEY` is set (enables MCP path auth) + +**Permission tiers in tools:** +- Storage tools: `check_permission("storage_bucket", bucket, "viewer"|"editor"|"owner")` +- Database/filesystem/data tools: `require_auth()` (no per-resource IAM tuples) +- Storage-aware tools (CSV, PDF): `check_permission()` for `minio://` paths, `require_auth()` for local paths + +**Dev mode:** When `JWT_SECRET_KEY` is unset, no JWT verification occurs. `get_current_user_id()` returns `None`, and all permission checks pass through. Set `TOOLSET_REQUIRE_AUTH=true` to enforce auth. + +**Key env vars:** `JWT_SECRET_KEY` (HS256 signing key, must match backend), `TOOLSET_REQUIRE_AUTH` (set `true` for production lockdown), `DATABASE_URL` (required for permission queries). diff --git a/toolset/Dockerfile b/toolset/Dockerfile new file mode 100644 index 0000000..97f09c2 --- /dev/null +++ b/toolset/Dockerfile @@ -0,0 +1,52 @@ +# Toolset (HuMCP) Dockerfile for development with hot reload +FROM python:3.13-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + curl \ + # For pytesseract OCR + tesseract-ocr \ + && rm -rf /var/lib/apt/lists/* + +# Install uv (fast package manager) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app \ + UV_SYSTEM_PYTHON=1 + +# Copy dependency files first for better caching +# Include README.md as it's referenced in pyproject.toml +COPY toolset/pyproject.toml toolset/README.md ./ + +# Install toolset and its dependencies (creates .venv) +# --no-sources ignores workspace references for shared +RUN uv sync --no-cache --no-sources + +# Copy shared package and install into the venv (not system Python) +COPY shared /tmp/shared +RUN uv pip install --no-cache --python /app/.venv/bin/python /tmp/shared && rm -rf /tmp/shared + +# Put the venv on PATH so uvicorn is directly runnable +ENV PATH="/app/.venv/bin:$PATH" + +# Copy application code +COPY toolset/ . + +# Expose port +EXPOSE 8003 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8003/ || exit 1 + +# Run with hot reload for development +# Use uvicorn directly (not uv run) to avoid re-parsing workspace sources +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8003", "--reload"] diff --git a/toolset/Dockerfile.dockerignore b/toolset/Dockerfile.dockerignore new file mode 100644 index 0000000..169eaaf --- /dev/null +++ b/toolset/Dockerfile.dockerignore @@ -0,0 +1,20 @@ +# Only include what the toolset Dockerfile needs: shared/ and toolset/ +* + +# Include shared package +!shared/ +shared/**/__pycache__/ + +# Include toolset source and config +!toolset/ +toolset/.venv/ +toolset/__pycache__/ +toolset/**/__pycache__/ +toolset/.pytest_cache/ +toolset/tests/ +toolset/.env +toolset/.env.* +toolset/.git/ +toolset/dist/ +toolset/build/ +toolset/*.egg-info/ diff --git a/toolset/LICENSE b/toolset/LICENSE new file mode 100644 index 0000000..897ca62 --- /dev/null +++ b/toolset/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Binome + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/toolset/README.md b/toolset/README.md new file mode 100644 index 0000000..0466ca5 --- /dev/null +++ b/toolset/README.md @@ -0,0 +1,482 @@ +# Toolset - HuMCP Server + +HuMCP is a tool execution service that exposes tools via both MCP (Model Context Protocol) and REST endpoints with auto-generated Swagger documentation. It's part of the Graphite Workflow Builder monorepo. + +## Features + +- Simple `@tool` decorator to register tools +- Auto-generated REST endpoints at `/tools/*` +- MCP server at `/mcp` for AI assistant integration +- Auto-generated Swagger/OpenAPI documentation at `/docs` +- MCP Apps: interactive HTML UIs for tool results (auto-discovered from `src/apps/`) +- Tools organized by category with skill metadata +- Configurable tool filtering via YAML config +- Docker Compose setup for easy deployment + +## Quick Start + +### From Monorepo Root (Recommended) + +```bash +# Install all dependencies +make install + +# Start the toolset server +make dev-toolset +``` + +### Standalone + +```bash +cd toolset + +# Install dependencies +uv sync + +# Copy environment file +cp .env.example .env +# Edit .env and add your API keys (Tavily, Google OAuth, etc.) + +# Run server +uv run uvicorn src.main:app --host 0.0.0.0 --port 8003 +``` + +### Access Points + +| Endpoint | URL | +|----------|-----| +| Swagger UI | http://localhost:8003/docs | +| REST API | http://localhost:8003/tools/* | +| MCP Apps | http://localhost:8003/apps/* | +| MCP endpoint | http://localhost:8003/mcp | + +## Prerequisites + +- Python 3.13+ +- [uv](https://docs.astral.sh/uv/) package manager +- Docker & Docker Compose (optional) + +--- + +# Creating Tools + +## Adding a New Tool + +1. Create a `.py` file in `src/tools//` (e.g., `src/tools/local/my_tool.py`) +2. Use the `@tool` decorator: + +```python +from src.humcp.decorator import tool + +@tool() +async def greet(name: str) -> dict: + """Greet a user by name. + + Args: + name: The name of the person to greet. + + Returns: + A greeting message for the specified person. + """ + return {"success": True, "data": {"message": f"Hello, {name}!"}} +``` + +3. Start the server - tools are auto-discovered! + +## Tool Documentation + +**Your docstring becomes the tool's user interface:** + +- **REST API**: Appears in Swagger/OpenAPI docs at `/docs` +- **MCP Protocol**: Sent to AI assistants to help them understand when and how to use your tool +- **Type hints**: Combined with docstrings to generate input schemas + +### Best Practices + +✅ **DO:** +- Write clear, concise docstrings that explain what the tool does +- Document all parameters with their purpose and expected format +- Describe what the tool returns +- Use proper Python type hints + +❌ **DON'T:** +- Leave tools without docstrings +- Use vague descriptions like "does stuff" +- Forget to document parameters or return values + +### Example + +```python +@tool() +async def search_files(pattern: str, directory: str = ".") -> dict: + """Search for files matching a pattern in a directory. + + Recursively searches the specified directory for files whose names + match the given pattern (supports wildcards like *.txt). + + Args: + pattern: File pattern to search for (e.g., "*.py", "config.*") + directory: Directory to search in (default: current directory) + + Returns: + List of matching file paths with their sizes and modification times. + """ + ... +``` + +## Tool Naming + +```python +# Auto-generated name: "{category}_{function_name}" +@tool() +async def greet(name: str) -> dict: + ... + +# Explicit name +@tool("my_custom_tool") +async def greet(name: str) -> dict: + ... + +# Explicit name and category +@tool("my_tool", category="custom") +async def greet(name: str) -> dict: + ... +``` + +## Response Pattern + +Tools should return a dictionary: + +```python +# Success +return {"success": True, "data": {"result": value}} + +# Error +return {"success": False, "error": "Error message"} +``` + +## MCP Apps + +MCP Apps render tool results as interactive HTML UIs inside sandboxed iframes. Each app is a standalone HTML file bound to a specific tool and delivered via both REST and MCP transport. + +### How It Works + +1. HTML files in `src/apps//.html` are auto-discovered at startup +2. Each app is bound to its matching tool by filename (e.g., `add.html` binds to tool `add`) +3. Apps are served via REST (`GET /apps/{tool_name}`) and registered as MCP `ui://` resources +4. The frontend fetches the HTML through the backend proxy (`GET /api/tools/apps/{tool_name}`), renders it in a sandboxed `