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/paths.py ADDED
@@ -0,0 +1,453 @@
1
+ """Path resolution for OneTool global and project directories.
2
+
3
+ OneTool uses a three-tier directory structure:
4
+ - Bundled: package data in ot.config.defaults — read-only defaults
5
+ - Global: ~/.onetool/ — user-wide settings, secrets
6
+ - Project: .onetool/ — project-specific config
7
+
8
+ Each .onetool/ directory uses subdirectories to organise files by purpose:
9
+ - config/ — YAML configuration files
10
+ - logs/ — Application log files
11
+ - stats/ — Statistics data (stats.jsonl)
12
+ - sessions/ — Browser session state
13
+ - tools/ — Reserved for installed tool packs
14
+
15
+ Directories are created lazily on first use, not on install.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from importlib import resources
23
+ from pathlib import Path
24
+
25
+ # Directory names
26
+ GLOBAL_DIR_NAME = ".onetool"
27
+ PROJECT_DIR_NAME = ".onetool"
28
+
29
+ # Subdirectory names within .onetool/
30
+ CONFIG_SUBDIR = "config"
31
+ LOGS_SUBDIR = "logs"
32
+ STATS_SUBDIR = "stats"
33
+ SESSIONS_SUBDIR = "sessions"
34
+ TOOLS_SUBDIR = "tools"
35
+
36
+ # Package containing bundled config defaults
37
+ BUNDLED_CONFIG_PACKAGE = "ot.config.defaults"
38
+
39
+ # Package containing global templates (copied to ~/.onetool/ on first run)
40
+ GLOBAL_TEMPLATES_PACKAGE = "ot.config.global_templates"
41
+
42
+
43
+ def _resolve_package_dir(package_name: str, description: str) -> Path:
44
+ """Resolve a package to a filesystem directory path.
45
+
46
+ Uses importlib.resources to access package data. Works correctly across:
47
+ - Regular pip/uv install (wheel)
48
+ - Editable install (uv tool install -e .)
49
+ - Development mode
50
+
51
+ Args:
52
+ package_name: Dotted package name (e.g., "ot.config.defaults")
53
+ description: Human-readable description for error messages
54
+
55
+ Returns:
56
+ Path to package directory (read-only package data)
57
+
58
+ Raises:
59
+ FileNotFoundError: If package is not found or not on filesystem
60
+ """
61
+ try:
62
+ files = resources.files(package_name)
63
+ except (ModuleNotFoundError, TypeError) as e:
64
+ raise FileNotFoundError(
65
+ f"{description} package not found: {package_name}. "
66
+ "Ensure onetool is properly installed."
67
+ ) from e
68
+
69
+ # Try multiple approaches to get a filesystem path from the Traversable.
70
+ # importlib.resources returns different types depending on install mode:
71
+ # - Regular install: pathlib.Path-like object
72
+ # - Editable install: MultiplexedPath (internal type)
73
+ # - Zipped package: ZipPath (would need extraction)
74
+
75
+ # Approach 1: Direct _path attribute (MultiplexedPath in editable installs)
76
+ if hasattr(files, "_path"):
77
+ path = Path(files._path)
78
+ if path.is_dir():
79
+ return path
80
+
81
+ # Approach 2: String conversion (works for regular Path-like objects)
82
+ path_str = str(files)
83
+
84
+ # Skip if it looks like a repr() output rather than a path
85
+ if not path_str.startswith(("MultiplexedPath(", "<", "{")):
86
+ path = Path(path_str)
87
+ if path.is_dir():
88
+ return path
89
+
90
+ # Approach 3: Extract path from MultiplexedPath repr as last resort
91
+ if path_str.startswith("MultiplexedPath("):
92
+ import re
93
+
94
+ match = re.search(r"'([^']+)'", path_str)
95
+ if match:
96
+ path = Path(match.group(1))
97
+ if path.is_dir():
98
+ return path
99
+
100
+ # If we get here, the package exists but isn't on a real filesystem
101
+ # (e.g., inside a zipfile). This is not supported.
102
+ raise FileNotFoundError(
103
+ f"{description} directory exists but is not on filesystem: {path_str}. "
104
+ "OneTool requires installation from an unpacked wheel, not a zipfile."
105
+ )
106
+
107
+
108
+ def get_bundled_config_dir() -> Path:
109
+ """Get the bundled config defaults directory path.
110
+
111
+ Uses importlib.resources to access package data. Works correctly across:
112
+ - Regular pip/uv install (wheel)
113
+ - Editable install (uv tool install -e .)
114
+ - Development mode
115
+
116
+ Returns:
117
+ Path to bundled defaults directory (read-only package data)
118
+
119
+ Raises:
120
+ FileNotFoundError: If bundled defaults package is not found or not on filesystem
121
+ """
122
+ return _resolve_package_dir(BUNDLED_CONFIG_PACKAGE, "Bundled config")
123
+
124
+
125
+ def get_global_templates_dir() -> Path:
126
+ """Get the global templates directory path.
127
+
128
+ Global templates are user-facing config files with commented examples,
129
+ copied to ~/.onetool/ on first run. Unlike bundled defaults (which are
130
+ minimal working configs), these provide rich documentation and examples.
131
+
132
+ Returns:
133
+ Path to global templates directory (read-only package data)
134
+
135
+ Raises:
136
+ FileNotFoundError: If global templates package is not found or not on filesystem
137
+ """
138
+ return _resolve_package_dir(GLOBAL_TEMPLATES_PACKAGE, "Global templates")
139
+
140
+
141
+ def get_effective_cwd() -> Path:
142
+ """Get the effective working directory.
143
+
144
+ Returns OT_CWD if set, else Path.cwd(). This provides a single point
145
+ of control for working directory resolution across all CLIs.
146
+
147
+ Returns:
148
+ Resolved Path for working directory
149
+ """
150
+ env_cwd = os.getenv("OT_CWD")
151
+ if env_cwd:
152
+ return Path(env_cwd).resolve()
153
+ return Path.cwd()
154
+
155
+
156
+ def get_global_dir() -> Path:
157
+ """Get the global OneTool directory path.
158
+
159
+ Returns:
160
+ Path to ~/.onetool/ (not necessarily existing)
161
+ """
162
+ return Path.home() / GLOBAL_DIR_NAME
163
+
164
+
165
+ def get_project_dir(start: Path | None = None) -> Path | None:
166
+ """Get the project OneTool directory.
167
+
168
+ Returns cwd/.onetool if it exists, else None. No tree-walking.
169
+ Uses get_effective_cwd() if start is not provided.
170
+
171
+ Args:
172
+ start: Starting directory (default: get_effective_cwd())
173
+
174
+ Returns:
175
+ Path to .onetool/ if found, None otherwise
176
+ """
177
+ cwd = start or get_effective_cwd()
178
+ candidate = cwd / PROJECT_DIR_NAME
179
+ if candidate.is_dir():
180
+ return candidate
181
+ return None
182
+
183
+
184
+ def get_template_files() -> list[tuple[Path, str]]:
185
+ """Get list of template files that would be copied to global dir.
186
+
187
+ Returns:
188
+ List of (source_path, dest_name) tuples for each template file.
189
+ dest_name has -template suffix stripped (e.g., secrets-template.yaml -> secrets.yaml)
190
+ """
191
+ try:
192
+ templates_dir = get_global_templates_dir()
193
+ result = []
194
+ for config_file in templates_dir.glob("*.yaml"):
195
+ dest_name = config_file.name.replace("-template.yaml", ".yaml")
196
+ result.append((config_file, dest_name))
197
+ return result
198
+ except FileNotFoundError:
199
+ return []
200
+
201
+
202
+ def create_backup(file_path: Path) -> Path:
203
+ """Create a numbered backup of a file.
204
+
205
+ Creates backups as file.bak, file.bak.1, file.bak.2, etc.
206
+
207
+ Args:
208
+ file_path: Path to the file to backup
209
+
210
+ Returns:
211
+ Path to the created backup file
212
+ """
213
+ backup_base = file_path.with_suffix(file_path.suffix + ".bak")
214
+
215
+ # Find the next available backup number
216
+ if not backup_base.exists():
217
+ backup_path = backup_base
218
+ else:
219
+ n = 1
220
+ while True:
221
+ backup_path = backup_base.with_suffix(f".bak.{n}")
222
+ if not backup_path.exists():
223
+ break
224
+ n += 1
225
+
226
+ import shutil
227
+
228
+ shutil.copy2(file_path, backup_path)
229
+ return backup_path
230
+
231
+
232
+ def ensure_global_dir(quiet: bool = False, force: bool = False) -> Path:
233
+ """Ensure the global OneTool directory exists with subdirectory structure.
234
+
235
+ Creates ~/.onetool/ with subdirectories (config/, logs/, stats/, sessions/, tools/)
236
+ and copies template config files from global_templates to config/.
237
+ Templates are user-facing files with commented examples for customization.
238
+ Subdirectories (like diagram-templates/) are NOT copied - they remain in
239
+ bundled defaults and are accessed via config inheritance.
240
+
241
+ Args:
242
+ quiet: Suppress creation messages
243
+ force: Overwrite existing files (for reset functionality)
244
+
245
+ Returns:
246
+ Path to ~/.onetool/
247
+ """
248
+ import shutil
249
+
250
+ global_dir = get_global_dir()
251
+
252
+ # If directory exists and not forcing, return early
253
+ if global_dir.exists() and not force:
254
+ return global_dir
255
+
256
+ # Create directory structure with subdirectories
257
+ global_dir.mkdir(parents=True, exist_ok=True)
258
+ subdirs = [CONFIG_SUBDIR, LOGS_SUBDIR, STATS_SUBDIR, SESSIONS_SUBDIR, TOOLS_SUBDIR]
259
+ for subdir in subdirs:
260
+ (global_dir / subdir).mkdir(exist_ok=True)
261
+
262
+ # Copy template config files to config/ subdirectory
263
+ # Only YAML files are copied; subdirectories stay in bundled defaults
264
+ # Files named *-template.yaml are copied without the -template suffix
265
+ # (to avoid gitignore patterns on secrets.yaml)
266
+ config_dir = global_dir / CONFIG_SUBDIR
267
+ copied_items: list[str] = []
268
+ try:
269
+ templates_dir = get_global_templates_dir()
270
+ for config_file in templates_dir.glob("*.yaml"):
271
+ # Strip -template suffix if present (e.g., secrets-template.yaml -> secrets.yaml)
272
+ dest_name = config_file.name.replace("-template.yaml", ".yaml")
273
+ dest = config_dir / dest_name
274
+ # Copy if doesn't exist, or if forcing
275
+ if not dest.exists() or force:
276
+ shutil.copy(config_file, dest)
277
+ copied_items.append(f"config/{dest_name}")
278
+ except FileNotFoundError:
279
+ # Global templates not available (dev environment without package install)
280
+ pass
281
+
282
+ if not quiet:
283
+ # Use stderr to avoid interfering with MCP stdout
284
+ action = "Resetting" if force else "Creating"
285
+ print(f"{action} {global_dir}/", file=sys.stderr)
286
+ for subdir in subdirs:
287
+ print(f" ✓ {subdir}/", file=sys.stderr)
288
+ for item_name in copied_items:
289
+ print(f" ✓ {item_name}", file=sys.stderr)
290
+
291
+ return global_dir
292
+
293
+
294
+ def ensure_project_dir(path: Path | None = None, quiet: bool = False) -> Path:
295
+ """Ensure the project OneTool directory exists with subdirectory structure.
296
+
297
+ Creates .onetool/ in the specified directory or effective cwd,
298
+ including subdirectories (config/, logs/, stats/, sessions/, tools/).
299
+
300
+ Args:
301
+ path: Project root (default: get_effective_cwd())
302
+ quiet: Suppress creation messages
303
+
304
+ Returns:
305
+ Path to .onetool/
306
+ """
307
+ project_root = path or get_effective_cwd()
308
+ project_dir = project_root / PROJECT_DIR_NAME
309
+
310
+ if project_dir.exists():
311
+ return project_dir
312
+
313
+ # Create directory structure with subdirectories
314
+ project_dir.mkdir(parents=True, exist_ok=True)
315
+ subdirs = [CONFIG_SUBDIR, LOGS_SUBDIR, STATS_SUBDIR, SESSIONS_SUBDIR, TOOLS_SUBDIR]
316
+ for subdir in subdirs:
317
+ (project_dir / subdir).mkdir(exist_ok=True)
318
+
319
+ if not quiet:
320
+ print(f"Creating {project_dir.relative_to(project_root)}/", file=sys.stderr)
321
+ for subdir in subdirs:
322
+ print(f" ✓ {subdir}/", file=sys.stderr)
323
+
324
+ return project_dir
325
+
326
+
327
+ def get_config_path(cli_name: str, scope: str = "any") -> Path | None:
328
+ """Get the config file path for a CLI.
329
+
330
+ Resolution order for scope="any":
331
+ 1. cwd/.onetool/config/<cli>.yaml (project-specific)
332
+ 2. ~/.onetool/config/<cli>.yaml (global)
333
+
334
+ Args:
335
+ cli_name: CLI name (e.g., "onetool", "bench")
336
+ scope: "global", "project", or "any" (project first, then global)
337
+
338
+ Returns:
339
+ Path to config file if found, None otherwise
340
+ """
341
+ config_name = f"{cli_name}.yaml"
342
+
343
+ if scope == "project" or scope == "any":
344
+ cwd = get_effective_cwd()
345
+ project_config = cwd / PROJECT_DIR_NAME / CONFIG_SUBDIR / config_name
346
+ if project_config.exists():
347
+ return project_config
348
+
349
+ if scope == "global" or scope == "any":
350
+ global_dir = get_global_dir()
351
+ global_config = global_dir / CONFIG_SUBDIR / config_name
352
+ if global_config.exists():
353
+ return global_config
354
+
355
+ return None
356
+
357
+
358
+ def expand_path(path: str) -> Path:
359
+ """Expand ~ in a path.
360
+
361
+ Only expands ~ to home directory. Does NOT expand ${VAR} patterns.
362
+ Use ~/path instead of ${HOME}/path.
363
+
364
+ Args:
365
+ path: Path string potentially containing ~
366
+
367
+ Returns:
368
+ Expanded absolute Path
369
+ """
370
+ return Path(path).expanduser().resolve()
371
+
372
+
373
+ def get_config_dir(base_dir: Path | None = None) -> Path:
374
+ """Get the config directory path within a .onetool directory.
375
+
376
+ Args:
377
+ base_dir: Base .onetool directory (default: global dir)
378
+
379
+ Returns:
380
+ Path to config/ subdirectory
381
+ """
382
+ base = base_dir or get_global_dir()
383
+ return base / CONFIG_SUBDIR
384
+
385
+
386
+ def get_logs_dir(base_dir: Path | None = None) -> Path:
387
+ """Get the logs directory path within a .onetool directory.
388
+
389
+ Args:
390
+ base_dir: Base .onetool directory (default: global dir)
391
+
392
+ Returns:
393
+ Path to logs/ subdirectory
394
+ """
395
+ base = base_dir or get_global_dir()
396
+ return base / LOGS_SUBDIR
397
+
398
+
399
+ def get_stats_dir(base_dir: Path | None = None) -> Path:
400
+ """Get the stats directory path within a .onetool directory.
401
+
402
+ Args:
403
+ base_dir: Base .onetool directory (default: global dir)
404
+
405
+ Returns:
406
+ Path to stats/ subdirectory
407
+ """
408
+ base = base_dir or get_global_dir()
409
+ return base / STATS_SUBDIR
410
+
411
+
412
+ def get_sessions_dir(base_dir: Path | None = None) -> Path:
413
+ """Get the sessions directory path within a .onetool directory.
414
+
415
+ Args:
416
+ base_dir: Base .onetool directory (default: global dir)
417
+
418
+ Returns:
419
+ Path to sessions/ subdirectory
420
+ """
421
+ base = base_dir or get_global_dir()
422
+ return base / SESSIONS_SUBDIR
423
+
424
+
425
+ def resolve_cwd_path(path: str) -> Path:
426
+ """Resolve a path relative to the project working directory (OT_CWD).
427
+
428
+ Use this for reading/writing files in the user's project.
429
+ This is the internal version for in-process tools - uses OT_CWD env var directly.
430
+
431
+ Args:
432
+ path: Path string (relative, absolute, or with ~)
433
+
434
+ Returns:
435
+ Resolved absolute Path
436
+
437
+ Behaviour:
438
+ - ~ paths: expanded to home directory
439
+ - Absolute paths: returned unchanged
440
+ - Relative paths: resolved relative to get_effective_cwd()
441
+
442
+ Example:
443
+ >>> resolve_cwd_path("data/file.txt")
444
+ PosixPath('/project/data/file.txt')
445
+ >>> resolve_cwd_path("/tmp/output.txt")
446
+ PosixPath('/tmp/output.txt')
447
+ >>> resolve_cwd_path("~/output.txt")
448
+ PosixPath('/home/user/output.txt')
449
+ """
450
+ p = Path(path).expanduser()
451
+ if p.is_absolute():
452
+ return p.resolve()
453
+ return (get_effective_cwd() / p).resolve()
ot/prompts.py ADDED
@@ -0,0 +1,218 @@
1
+ """Prompts loader for externalized MCP server instructions.
2
+
3
+ Loads prompts from prompts.yaml. File must exist and contain instructions.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from loguru import logger
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class ToolPrompt(BaseModel):
17
+ """Prompt configuration for a specific tool."""
18
+
19
+ description: str | None = Field(
20
+ default=None, description="Override tool description"
21
+ )
22
+ examples: list[str] = Field(default_factory=list, description="Usage examples")
23
+
24
+
25
+ class PromptsConfig(BaseModel):
26
+ """Configuration for MCP server prompts and tool descriptions."""
27
+
28
+ instructions: str = Field(
29
+ description="Main server instructions shown to the LLM",
30
+ )
31
+ tools: dict[str, ToolPrompt] = Field(
32
+ default_factory=dict,
33
+ description="Per-tool prompt overrides",
34
+ )
35
+ templates: dict[str, str] = Field(
36
+ default_factory=dict,
37
+ description="Reusable prompt templates with {variable} placeholders",
38
+ )
39
+ packs: dict[str, str] = Field(
40
+ default_factory=dict,
41
+ description="Per-pack instructions (e.g., excel, github)",
42
+ )
43
+
44
+
45
+ class PromptsError(Exception):
46
+ """Error loading prompts configuration."""
47
+
48
+
49
+ def _get_bundled_prompts_path() -> Path:
50
+ """Get path to bundled default prompts.yaml."""
51
+ return Path(__file__).parent / "config" / "defaults" / "prompts.yaml"
52
+
53
+
54
+ def load_prompts(prompts_path: Path | str | None = None) -> PromptsConfig:
55
+ """Load prompts configuration from YAML file.
56
+
57
+ Args:
58
+ prompts_path: Path to prompts file. Falls back to bundled default.
59
+
60
+ Returns:
61
+ PromptsConfig with loaded prompts.
62
+
63
+ Raises:
64
+ PromptsError: If file is invalid or has no instructions.
65
+ """
66
+ if prompts_path is not None:
67
+ prompts_path = Path(prompts_path)
68
+ if not prompts_path.exists():
69
+ raise PromptsError(f"Prompts file not found: {prompts_path}")
70
+ else:
71
+ # Try config/prompts.yaml, fall back to bundled default
72
+ prompts_path = Path("config/prompts.yaml")
73
+ if not prompts_path.exists():
74
+ prompts_path = _get_bundled_prompts_path()
75
+
76
+ logger.debug(f"Loading prompts from {prompts_path}")
77
+
78
+ try:
79
+ with prompts_path.open() as f:
80
+ raw_data = yaml.safe_load(f)
81
+ except yaml.YAMLError as e:
82
+ raise PromptsError(f"Invalid YAML in {prompts_path}: {e}") from e
83
+ except OSError as e:
84
+ raise PromptsError(f"Error reading {prompts_path}: {e}") from e
85
+
86
+ if raw_data is None or not isinstance(raw_data, dict):
87
+ raise PromptsError(f"Empty or invalid prompts file: {prompts_path}")
88
+
89
+ # Handle nested 'prompts:' key (used in bundled default)
90
+ if "prompts" in raw_data and isinstance(raw_data["prompts"], dict):
91
+ raw_data = raw_data["prompts"]
92
+
93
+ if "instructions" not in raw_data or not raw_data["instructions"]:
94
+ raise PromptsError(f"Missing 'instructions' in {prompts_path}")
95
+
96
+ try:
97
+ return PromptsConfig.model_validate(raw_data)
98
+ except Exception as e:
99
+ raise PromptsError(f"Invalid prompts configuration: {e}") from e
100
+
101
+
102
+ def render_template(
103
+ config: PromptsConfig, template_name: str, **kwargs: Any
104
+ ) -> str | None:
105
+ """Render a prompt template with variable substitution.
106
+
107
+ Args:
108
+ config: PromptsConfig with templates
109
+ template_name: Name of the template to render
110
+ **kwargs: Variables to substitute in the template
111
+
112
+ Returns:
113
+ Rendered template string, or None if template not found.
114
+ """
115
+ template = config.templates.get(template_name)
116
+ if template is None:
117
+ return None
118
+
119
+ try:
120
+ return template.format(**kwargs)
121
+ except KeyError as e:
122
+ logger.warning(f"Missing template variable: {e}")
123
+ return None
124
+
125
+
126
+ def get_tool_description(
127
+ config: PromptsConfig, tool_name: str, default: str = ""
128
+ ) -> str:
129
+ """Get tool description from prompts config with fallback to docstring.
130
+
131
+ Args:
132
+ config: PromptsConfig with tool prompts
133
+ tool_name: Name of the tool
134
+ default: Default description if not in config (typically from docstring)
135
+
136
+ Returns:
137
+ Tool description string.
138
+ """
139
+ tool_prompt = config.tools.get(tool_name)
140
+ if tool_prompt and tool_prompt.description:
141
+ return tool_prompt.description
142
+ return default
143
+
144
+
145
+ def get_tool_examples(config: PromptsConfig, tool_name: str) -> list[str]:
146
+ """Get usage examples for a tool.
147
+
148
+ Args:
149
+ config: PromptsConfig with tool prompts
150
+ tool_name: Name of the tool
151
+
152
+ Returns:
153
+ List of example strings.
154
+ """
155
+ tool_prompt = config.tools.get(tool_name)
156
+ if tool_prompt:
157
+ return tool_prompt.examples
158
+ return []
159
+
160
+
161
+ def get_pack_instructions(config: PromptsConfig, pack: str) -> str | None:
162
+ """Get instructions for a pack from prompts config.
163
+
164
+ Args:
165
+ config: PromptsConfig with pack instructions
166
+ pack: Name of the pack (e.g., "excel", "github")
167
+
168
+ Returns:
169
+ Pack instructions string, or None if not configured.
170
+ """
171
+ return config.packs.get(pack)
172
+
173
+
174
+ # Global prompts instance
175
+ _prompts: PromptsConfig | None = None
176
+
177
+
178
+ def get_prompts(
179
+ prompts_path: Path | str | None = None,
180
+ inline_prompts: dict[str, Any] | None = None,
181
+ reload: bool = False,
182
+ ) -> PromptsConfig:
183
+ """Get or load the global prompts configuration.
184
+
185
+ Prompts are loaded with the following priority:
186
+ 1. Inline prompts (if provided)
187
+ 2. prompts_file (from config or explicit path)
188
+
189
+ Args:
190
+ prompts_path: Path to prompts file (only used on first load)
191
+ inline_prompts: Inline prompts dict from config (overrides file)
192
+ reload: Force reload configuration
193
+
194
+ Returns:
195
+ PromptsConfig instance
196
+
197
+ Raises:
198
+ PromptsError: If prompts cannot be loaded.
199
+ """
200
+ global _prompts
201
+
202
+ if _prompts is None or reload:
203
+ if inline_prompts is not None:
204
+ # Use inline prompts from config
205
+ if (
206
+ "instructions" not in inline_prompts
207
+ or not inline_prompts["instructions"]
208
+ ):
209
+ raise PromptsError("Missing 'instructions' in inline prompts")
210
+ try:
211
+ _prompts = PromptsConfig.model_validate(inline_prompts)
212
+ logger.debug("Using inline prompts from config")
213
+ except Exception as e:
214
+ raise PromptsError(f"Invalid inline prompts: {e}") from e
215
+ else:
216
+ _prompts = load_prompts(prompts_path)
217
+
218
+ return _prompts
ot/proxy/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """MCP Proxy module for OneTool.
2
+
3
+ Provides connectivity to external MCP servers that are proxied
4
+ through OneTool's single `run` tool interface.
5
+ """
6
+
7
+ from ot.proxy.manager import (
8
+ ProxyManager,
9
+ ProxyToolInfo,
10
+ get_proxy_manager,
11
+ reconnect_proxy_manager,
12
+ reset_proxy_manager,
13
+ )
14
+
15
+ __all__ = [
16
+ "ProxyManager",
17
+ "ProxyToolInfo",
18
+ "get_proxy_manager",
19
+ "reconnect_proxy_manager",
20
+ "reset_proxy_manager",
21
+ ]