onetool-mcp 1.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
ot/utils/factory.py ADDED
@@ -0,0 +1,179 @@
1
+ """Factory utilities for OneTool.
2
+
3
+ Provides thread-safe lazy initialization patterns for API clients.
4
+
5
+ Example:
6
+ from ot.utils import lazy_client
7
+
8
+ # Define a client factory
9
+ def create_my_client():
10
+ from mylib import Client
11
+ api_key = get_secret("MY_API_KEY")
12
+ return Client(api_key=api_key)
13
+
14
+ # Create a lazy-initialized getter
15
+ get_client = lazy_client(create_my_client)
16
+
17
+ # Use it anywhere - initialized once, thread-safe
18
+ client = get_client()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ from typing import TYPE_CHECKING, TypeVar
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+ __all__ = ["LazyClient", "lazy_client"]
30
+
31
+ T = TypeVar("T")
32
+
33
+
34
+ def lazy_client(
35
+ factory: Callable[[], T | None],
36
+ *,
37
+ allow_none: bool = False,
38
+ ) -> Callable[[], T | None]:
39
+ """Create a thread-safe lazy-initialized client getter.
40
+
41
+ Wraps a factory function with double-checked locking to ensure
42
+ the client is created exactly once, even under concurrent access.
43
+
44
+ The factory function should:
45
+ - Return the client instance on success
46
+ - Return None if required credentials are missing
47
+ - Raise exceptions for other errors
48
+
49
+ Args:
50
+ factory: Callable that creates and returns the client instance
51
+ allow_none: If True, cache None results. If False (default), retry
52
+ factory on each call when it returns None.
53
+
54
+ Returns:
55
+ A callable that returns the lazily-initialized client
56
+
57
+ Example:
58
+ from ot.utils import lazy_client
59
+ from ot.config import get_secret, get_tool_config
60
+
61
+ def create_firecrawl():
62
+ from firecrawl import FirecrawlApp
63
+ api_key = get_secret("FIRECRAWL_API_KEY")
64
+ if not api_key:
65
+ return None
66
+ api_url = get_tool_config("firecrawl", Config).api_url
67
+ return FirecrawlApp(api_key=api_key, api_url=api_url or None)
68
+
69
+ get_firecrawl = lazy_client(create_firecrawl)
70
+
71
+ # Later, in tool functions:
72
+ def scrape(url: str) -> str:
73
+ client = get_firecrawl()
74
+ if client is None:
75
+ return "Error: FIRECRAWL_API_KEY not configured"
76
+ return client.scrape_url(url)
77
+ """
78
+ client: T | None = None
79
+ initialized = False
80
+ lock = threading.Lock()
81
+
82
+ def get_client() -> T | None:
83
+ nonlocal client, initialized
84
+
85
+ # Fast path: already initialized
86
+ if initialized:
87
+ return client
88
+
89
+ # Slow path: acquire lock and initialize
90
+ with lock:
91
+ # Double-check after acquiring lock
92
+ if initialized:
93
+ return client
94
+
95
+ result = factory()
96
+
97
+ # Cache result if successful or allow_none is True
98
+ if result is not None or allow_none:
99
+ client = result
100
+ initialized = True
101
+
102
+ return result
103
+
104
+ return get_client
105
+
106
+
107
+ class LazyClient:
108
+ """Class-based lazy client for more complex initialization patterns.
109
+
110
+ Useful when you need to pass the factory as a method or need
111
+ additional client management features.
112
+
113
+ Example:
114
+ class MyPack:
115
+ def __init__(self):
116
+ self._client = LazyClient(self._create_client)
117
+
118
+ def _create_client(self):
119
+ return SomeClient(api_key=get_secret("API_KEY"))
120
+
121
+ def search(self, query: str) -> str:
122
+ client = self._client.get()
123
+ if client is None:
124
+ return "Error: API_KEY not configured"
125
+ return client.search(query)
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ factory: Callable[[], T | None],
131
+ *,
132
+ allow_none: bool = False,
133
+ ) -> None:
134
+ """Initialize the lazy client wrapper.
135
+
136
+ Args:
137
+ factory: Callable that creates the client
138
+ allow_none: If True, cache None results
139
+ """
140
+ self._factory = factory
141
+ self._allow_none = allow_none
142
+ self._client: T | None = None
143
+ self._initialized = False
144
+ self._lock = threading.Lock()
145
+
146
+ def get(self) -> T | None:
147
+ """Get the client, initializing if necessary.
148
+
149
+ Returns:
150
+ The client instance, or None if not available
151
+ """
152
+ if self._initialized:
153
+ return self._client # type: ignore[return-value]
154
+
155
+ with self._lock:
156
+ if self._initialized:
157
+ return self._client
158
+
159
+ result = self._factory()
160
+
161
+ if result is not None or self._allow_none:
162
+ self._client = result
163
+ self._initialized = True
164
+
165
+ return result # type: ignore[return-value]
166
+
167
+ def reset(self) -> None:
168
+ """Reset the client, forcing re-initialization on next access.
169
+
170
+ Useful for testing or when configuration changes.
171
+ """
172
+ with self._lock:
173
+ self._client = None
174
+ self._initialized = False
175
+
176
+ @property
177
+ def is_initialized(self) -> bool:
178
+ """Check if the client has been initialized."""
179
+ return self._initialized
ot/utils/format.py ADDED
@@ -0,0 +1,65 @@
1
+ """Result serialization utilities for MCP responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Literal
7
+
8
+ import yaml
9
+
10
+ __all__ = ["FormatMode", "serialize_result"]
11
+
12
+ FormatMode = Literal["json", "json_h", "yml", "yml_h", "raw"]
13
+
14
+
15
+ def serialize_result(result: Any, fmt: FormatMode = "json") -> str:
16
+ """Serialize tool result to string for MCP response.
17
+
18
+ Tools return native Python types (dict, list, str). This function
19
+ serializes them to a string suitable for MCP text content.
20
+
21
+ Format modes:
22
+ - json: Compact JSON (default, no spaces)
23
+ - json_h: Human-readable JSON (2-space indent)
24
+ - yml: YAML flow style (compact)
25
+ - yml_h: YAML block style (human-readable)
26
+ - raw: str() conversion
27
+
28
+ Args:
29
+ result: Tool result (dict, list, str, or other)
30
+ fmt: Output format mode (default: "json")
31
+
32
+ Returns:
33
+ String representation suitable for MCP response
34
+ """
35
+ # Strings pass through unchanged for all formats except raw
36
+ if isinstance(result, str) and fmt != "raw":
37
+ return result
38
+
39
+ if fmt == "raw":
40
+ return str(result)
41
+
42
+ if fmt == "json":
43
+ if isinstance(result, (dict, list)):
44
+ return json.dumps(result, ensure_ascii=False, separators=(",", ":"))
45
+ return str(result)
46
+
47
+ if fmt == "json_h":
48
+ if isinstance(result, (dict, list)):
49
+ return json.dumps(result, ensure_ascii=False, indent=2)
50
+ return str(result)
51
+
52
+ if fmt == "yml":
53
+ if isinstance(result, (dict, list)):
54
+ return yaml.dump(result, default_flow_style=True, allow_unicode=True, sort_keys=False).rstrip()
55
+ return str(result)
56
+
57
+ if fmt == "yml_h":
58
+ if isinstance(result, (dict, list)):
59
+ return yaml.dump(result, default_flow_style=False, allow_unicode=True, sort_keys=False).rstrip()
60
+ return str(result)
61
+
62
+ # Unknown format, fall back to compact JSON
63
+ if isinstance(result, (dict, list)):
64
+ return json.dumps(result, ensure_ascii=False, separators=(",", ":"))
65
+ return str(result)
ot/utils/http.py ADDED
@@ -0,0 +1,202 @@
1
+ """HTTP request utilities for OneTool.
2
+
3
+ Provides standardized HTTP error handling and header construction
4
+ for API-based tools.
5
+
6
+ Example:
7
+ from ot.utils import safe_request, api_headers
8
+
9
+ # Build API headers with authentication
10
+ headers = api_headers("MY_API_KEY", header_name="Authorization", prefix="Bearer")
11
+
12
+ # Make a request with standardized error handling
13
+ success, result = safe_request(
14
+ lambda: client.get("/endpoint", headers=headers)
15
+ )
16
+ if success:
17
+ data = result # Parsed JSON dict
18
+ else:
19
+ error_msg = result # Error message string
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Any, TypeVar
25
+
26
+ from ot.config.secrets import get_secret
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable
30
+
31
+ __all__ = ["api_headers", "check_api_key", "safe_request"]
32
+
33
+ T = TypeVar("T")
34
+
35
+
36
+ def api_headers(
37
+ secret_name: str,
38
+ *,
39
+ header_name: str = "Authorization",
40
+ prefix: str = "Bearer",
41
+ extra: dict[str, str] | None = None,
42
+ ) -> dict[str, str]:
43
+ """Build API request headers with authentication.
44
+
45
+ Retrieves a secret and constructs the appropriate authorization header.
46
+ Commonly used patterns:
47
+ - Bearer token: prefix="Bearer" (default)
48
+ - API key header: header_name="X-API-Key", prefix=""
49
+
50
+ Args:
51
+ secret_name: Name of secret in secrets.yaml (e.g., "MY_API_KEY")
52
+ header_name: Header name for the auth token (default: "Authorization")
53
+ prefix: Prefix before the token value (default: "Bearer")
54
+ extra: Additional headers to include
55
+
56
+ Returns:
57
+ Dict of headers ready for HTTP requests
58
+
59
+ Raises:
60
+ ValueError: If the secret is not configured
61
+
62
+ Example:
63
+ # Bearer token auth
64
+ headers = api_headers("OPENAI_API_KEY")
65
+ # {"Authorization": "Bearer sk-..."}
66
+
67
+ # Custom API key header
68
+ headers = api_headers(
69
+ "BRAVE_API_KEY", header_name="X-Subscription-Token", prefix=""
70
+ )
71
+ # {"X-Subscription-Token": "BSA..."}
72
+
73
+ # With extra headers
74
+ headers = api_headers("API_KEY", extra={"Accept": "application/json"})
75
+ """
76
+ api_key = get_secret(secret_name)
77
+ if not api_key:
78
+ raise ValueError(f"{secret_name} secret not configured in secrets.yaml")
79
+
80
+ headers: dict[str, str] = {}
81
+
82
+ # Build auth header
83
+ if prefix:
84
+ headers[header_name] = f"{prefix} {api_key}"
85
+ else:
86
+ headers[header_name] = api_key
87
+
88
+ # Add extra headers
89
+ if extra:
90
+ headers.update(extra)
91
+
92
+ return headers
93
+
94
+
95
+ def safe_request(
96
+ request_fn: Callable[[], T],
97
+ *,
98
+ parse_json: bool = True,
99
+ ) -> tuple[bool, T | dict[str, Any] | str]:
100
+ """Execute an HTTP request with standardized error handling.
101
+
102
+ Wraps an HTTP request call and handles common error patterns:
103
+ - Connection errors
104
+ - HTTP status errors
105
+ - JSON parsing errors
106
+ - Timeout errors
107
+
108
+ Args:
109
+ request_fn: Callable that makes the HTTP request and returns response
110
+ parse_json: If True (default), parse response as JSON
111
+
112
+ Returns:
113
+ Tuple of (success: bool, result).
114
+ On success: (True, parsed_json_dict or response)
115
+ On failure: (False, error_message_string)
116
+
117
+ Example:
118
+ # With httpx client
119
+ success, result = safe_request(
120
+ lambda: client.get("/api/data", params={"q": "test"})
121
+ )
122
+
123
+ if success:
124
+ data = result["data"]
125
+ else:
126
+ return f"Error: {result}"
127
+
128
+ # Without JSON parsing
129
+ success, result = safe_request(
130
+ lambda: client.get("/raw/text"),
131
+ parse_json=False,
132
+ )
133
+ """
134
+ try:
135
+ response = request_fn()
136
+
137
+ # Check for raise_for_status method (httpx/requests response)
138
+ if hasattr(response, "raise_for_status"):
139
+ response.raise_for_status()
140
+
141
+ if parse_json and hasattr(response, "json"):
142
+ return True, response.json()
143
+
144
+ if hasattr(response, "text"):
145
+ return True, response.text
146
+
147
+ return True, response
148
+
149
+ except Exception as e:
150
+ return False, _format_http_error(e)
151
+
152
+
153
+ def _format_http_error(error: Exception) -> str:
154
+ """Format an HTTP error into a user-friendly message.
155
+
156
+ Extracts status code and response text when available.
157
+
158
+ Args:
159
+ error: The exception from the HTTP request
160
+
161
+ Returns:
162
+ Formatted error message string
163
+ """
164
+ error_type = type(error).__name__
165
+
166
+ # Check for response attribute (HTTPStatusError, etc.)
167
+ if hasattr(error, "response"):
168
+ response = error.response
169
+ status = getattr(response, "status_code", "unknown")
170
+ text = getattr(response, "text", "")[:200]
171
+ return f"HTTP error ({status}): {text}" if text else f"HTTP error ({status})"
172
+
173
+ # Check for common error types
174
+ error_str = str(error)
175
+ if "timeout" in error_str.lower():
176
+ return f"Request timeout: {error}"
177
+ if "connection" in error_str.lower():
178
+ return f"Connection error: {error}"
179
+
180
+ return f"Request failed ({error_type}): {error}"
181
+
182
+
183
+ def check_api_key(secret_name: str) -> str | None:
184
+ """Check if an API key is configured and return error message if not.
185
+
186
+ Convenience function for early validation in tools.
187
+
188
+ Args:
189
+ secret_name: Name of secret to check
190
+
191
+ Returns:
192
+ None if configured, error message string if not
193
+
194
+ Example:
195
+ if error := check_api_key("BRAVE_API_KEY"):
196
+ return error
197
+ # Proceed with API calls
198
+ """
199
+ api_key = get_secret(secret_name)
200
+ if not api_key:
201
+ return f"Error: {secret_name} secret not configured"
202
+ return None
ot/utils/platform.py ADDED
@@ -0,0 +1,45 @@
1
+ """Platform detection utilities for cross-platform support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ # Platform-specific install commands for external dependencies
8
+ INSTALL_COMMANDS: dict[str, dict[str, str]] = {
9
+ "rg": {
10
+ "darwin": "brew install ripgrep",
11
+ "linux": "apt install ripgrep # or: snap install ripgrep",
12
+ "win32": "winget install BurntSushi.ripgrep.MSVC # or: scoop install ripgrep",
13
+ },
14
+ "playwright": {
15
+ "darwin": "pip install playwright && playwright install",
16
+ "linux": "pip install playwright && playwright install",
17
+ "win32": "pip install playwright && playwright install",
18
+ },
19
+ }
20
+
21
+
22
+ def get_install_hint(tool: str) -> str:
23
+ """Get platform-appropriate install command for a tool.
24
+
25
+ Args:
26
+ tool: Tool name (e.g., "rg", "playwright")
27
+
28
+ Returns:
29
+ Install command for the current platform, or generic message if unknown.
30
+
31
+ Example:
32
+ >>> get_install_hint("rg") # On macOS
33
+ 'brew install ripgrep'
34
+ >>> get_install_hint("rg") # On Linux
35
+ 'apt install ripgrep # or: snap install ripgrep'
36
+ >>> get_install_hint("rg") # On Windows
37
+ 'winget install BurntSushi.ripgrep.MSVC # or: scoop install ripgrep'
38
+ """
39
+ platform = sys.platform
40
+ # Normalize Linux variants (linux2, etc.)
41
+ if platform.startswith("linux"):
42
+ platform = "linux"
43
+
44
+ commands = INSTALL_COMMANDS.get(tool, {})
45
+ return commands.get(platform, f"Install {tool} for your platform")
ot/utils/sanitize.py ADDED
@@ -0,0 +1,130 @@
1
+ """Output sanitization for prompt injection protection.
2
+
3
+ Protects against indirect prompt injection by sanitizing tool outputs
4
+ that may contain malicious payloads designed to trick the LLM.
5
+
6
+ Three-layer defense:
7
+ 1. Trigger sanitization: Replace __ot, mcp__onetool patterns
8
+ 2. Tag sanitization: Remove <external-content-*> patterns
9
+ 3. GUID-tagged boundaries: Wrap content in unpredictable tags
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import uuid
16
+
17
+ __all__ = [
18
+ "sanitize_output",
19
+ "sanitize_tag_closes",
20
+ "sanitize_triggers",
21
+ "wrap_external_content",
22
+ ]
23
+
24
+ # Regex patterns for trigger detection (case-insensitive)
25
+ # Matches: __ot, mcp__onetool, mcp__onetool__run
26
+ TRIGGER_PATTERN = re.compile(
27
+ r"(__ot\b|mcp__onetool\w*)",
28
+ re.IGNORECASE,
29
+ )
30
+
31
+ # Pattern for tag injection attempts (both opening and closing)
32
+ # Matches: <external-content-*> and </external-content-*>
33
+ TAG_PATTERN = re.compile(
34
+ r"</?external-content-[a-f0-9-]*>?",
35
+ re.IGNORECASE,
36
+ )
37
+
38
+
39
+ def sanitize_triggers(content: str) -> str:
40
+ """Replace trigger patterns that could invoke OneTool.
41
+
42
+ Replaces patterns like __ot, mcp__onetool, mcp__onetool__run
43
+ with [REDACTED:trigger] to prevent indirect prompt injection.
44
+
45
+ Args:
46
+ content: String content that may contain trigger patterns
47
+
48
+ Returns:
49
+ Content with triggers replaced by [REDACTED:trigger]
50
+ """
51
+ if not content:
52
+ return content
53
+
54
+ return TRIGGER_PATTERN.sub("[REDACTED:trigger]", content)
55
+
56
+
57
+ def sanitize_tag_closes(content: str) -> str:
58
+ """Remove boundary tag patterns that could escape or confuse boundaries.
59
+
60
+ Attackers may include </external-content-*> to close the boundary early,
61
+ or <external-content-*> to create fake boundaries and confuse parsing.
62
+
63
+ Args:
64
+ content: String content that may contain boundary tag attempts
65
+
66
+ Returns:
67
+ Content with boundary tag patterns replaced by [REDACTED:tag]
68
+ """
69
+ if not content:
70
+ return content
71
+
72
+ return TAG_PATTERN.sub("[REDACTED:tag]", content)
73
+
74
+
75
+ def wrap_external_content(
76
+ content: str,
77
+ source: str | None = None,
78
+ ) -> str:
79
+ """Wrap external content in GUID-tagged boundaries.
80
+
81
+ Applies all three defense layers:
82
+ 1. Sanitize trigger patterns
83
+ 2. Sanitize tag patterns
84
+ 3. Wrap in unpredictable GUID boundary tags
85
+
86
+ Args:
87
+ content: External content to wrap
88
+ source: Optional source identifier (e.g., URL, tool name)
89
+
90
+ Returns:
91
+ Content wrapped in boundary tags with sanitization applied.
92
+ Empty content is still wrapped (boundaries always apply).
93
+ """
94
+ # Generate unique boundary ID (4 hex chars = 16 bits = 65,536 possibilities)
95
+ # Sufficient for unpredictability within a single request context
96
+ boundary_id = uuid.uuid4().hex[:4]
97
+
98
+ # Apply sanitization layers
99
+ content = sanitize_triggers(content)
100
+ content = sanitize_tag_closes(content)
101
+
102
+ # Build source attribute if provided
103
+ source_attr = f' source="{source}"' if source else ""
104
+
105
+ # Wrap in boundary tags
106
+ return f"<external-content-{boundary_id}{source_attr}>\n{content}\n</external-content-{boundary_id}>"
107
+
108
+
109
+ def sanitize_output(
110
+ content: str,
111
+ source: str | None = None,
112
+ enabled: bool = True,
113
+ ) -> str:
114
+ """Sanitize tool output for prompt injection protection.
115
+
116
+ Main entry point for output sanitization. When enabled, wraps content
117
+ in boundary tags and sanitizes trigger patterns.
118
+
119
+ Args:
120
+ content: Tool output content
121
+ source: Optional source identifier (e.g., tool name, URL)
122
+ enabled: Whether sanitization is enabled (default True)
123
+
124
+ Returns:
125
+ Sanitized content wrapped in boundary tags, or original if disabled
126
+ """
127
+ if not enabled:
128
+ return content
129
+
130
+ return wrap_external_content(content, source=source)
ot/utils/truncate.py ADDED
@@ -0,0 +1,69 @@
1
+ """Text truncation and error formatting utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Any
7
+
8
+ __all__ = ["format_error", "run_command", "truncate"]
9
+
10
+
11
+ def truncate(text: str, max_length: int = 4000, indicator: str = "...") -> str:
12
+ """Truncate text to a maximum length with an indicator.
13
+
14
+ Args:
15
+ text: Text to truncate
16
+ max_length: Maximum length including indicator
17
+ indicator: String to append when truncated (default: "...")
18
+
19
+ Returns:
20
+ Truncated text with indicator, or original if within limit
21
+ """
22
+ if len(text) <= max_length:
23
+ return text
24
+ return text[: max_length - len(indicator)] + indicator
25
+
26
+
27
+ def format_error(message: str, details: dict[str, Any] | None = None) -> str:
28
+ """Format an error message consistently.
29
+
30
+ Args:
31
+ message: Main error message
32
+ details: Optional additional details
33
+
34
+ Returns:
35
+ Formatted error string
36
+ """
37
+ if details:
38
+ detail_str = ", ".join(f"{k}={v}" for k, v in details.items())
39
+ return f"Error: {message} ({detail_str})"
40
+ return f"Error: {message}"
41
+
42
+
43
+ def run_command(
44
+ args: list[str],
45
+ *,
46
+ timeout: float = 30.0,
47
+ cwd: str | None = None,
48
+ ) -> tuple[int, str, str]:
49
+ """Run a subprocess command with timeout.
50
+
51
+ Args:
52
+ args: Command and arguments
53
+ timeout: Timeout in seconds (default: 30)
54
+ cwd: Working directory
55
+
56
+ Returns:
57
+ Tuple of (return_code, stdout, stderr)
58
+
59
+ Raises:
60
+ subprocess.TimeoutExpired: If command times out
61
+ """
62
+ result = subprocess.run(
63
+ args,
64
+ timeout=timeout,
65
+ cwd=cwd,
66
+ capture_output=True,
67
+ text=True,
68
+ )
69
+ return result.returncode, result.stdout, result.stderr
ot_tools/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """OneTool built-in tools.
2
+
3
+ Tools are auto-discovered from Python files in this directory.
4
+ """