From 2b8bb6d52be57b61f6949a4458aa79ad3dd34f58 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 12 Mar 2026 22:17:50 +0300 Subject: [PATCH 1/8] feat: add UTCP check provider and documentation Implement full UTCP (Universal Tool Calling Protocol) support as a new check provider type. Provider Implementation: - New utcp-check-provider.ts with full UTCP client lifecycle, three-way manual resolution (URL/file/inline), smart tool name resolution, Liquid template rendering in arguments, and automatic issue extraction - Configuration support: 'utcp' added to ConfigCheckType and validCheckTypes - Registered in CheckProviderRegistry with optional dependencies @utcp/sdk and @utcp/http Documentation: - Created docs/utcp-provider.md with complete reference guide - Updated README.md, docs/configuration.md, glossary.md, pluggable.md, architecture.md, migration.md, and CLAUDE.md to reference UTCP (17 providers total now) Testing: - 31 unit tests for UtcpCheckProvider covering validation, execution, transforms, and issue extraction - Updated check-provider-registry.test.ts (17 providers) - All 3731 tests passing Real Integration: - Verified with actual UTCP SDK calls to httpbin.org (3 successful checks: get_ip, get_uuid, echo_post) - Liquid template rendering and file-based manual discovery with suffix matching confirmed Co-Authored-By: Claude Haiku 4.5 --- CLAUDE.md | 4 +- README.md | 9 +- docs/architecture.md | 3 + docs/configuration.md | 3 +- docs/glossary.md | 10 +- docs/migration.md | 2 + docs/pluggable.md | 17 +- docs/utcp-provider.md | 299 ++++++++ package-lock.json | 53 ++ package.json | 10 + src/config.ts | 1 + src/generated/config-schema.ts | 35 +- src/providers/check-provider-registry.ts | 12 + src/providers/index.ts | 1 + src/providers/utcp-check-provider.ts | 693 ++++++++++++++++++ src/types/config.ts | 12 +- .../providers/check-provider-registry.test.ts | 4 +- .../providers/utcp-check-provider.test.ts | 503 +++++++++++++ 18 files changed, 1654 insertions(+), 17 deletions(-) create mode 100644 docs/utcp-provider.md create mode 100644 src/providers/utcp-check-provider.ts create mode 100644 tests/unit/providers/utcp-check-provider.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4584b9aa3..ef0f8eb06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ Visor is an AI-powered code review tool for GitHub Pull Requests that can run as - `src/providers/` - Pluggable check provider architecture - `ai-check-provider.ts` - AI-powered analysis (Gemini, Claude, OpenAI) - `mcp-check-provider.ts` - Direct MCP tool execution via stdio/SSE/HTTP + - `utcp-check-provider.ts` - UTCP tool execution via native protocols (HTTP/CLI/SSE) - `claude-code-check-provider.ts` - Claude Code SDK integration with MCP tools - `tool-check-provider.ts` - Integration with external tools - `command-check-provider.ts` - Execute shell commands @@ -78,7 +79,8 @@ Visor is an AI-powered code review tool for GitHub Pull Requests that can run as 2. **Pluggable Providers**: Extensible system for different analysis types 3. **AI Integration**: Multi-provider AI support including Claude Code SDK 4. **MCP Provider**: Direct MCP tool execution with stdio, SSE, and HTTP transports -5. **Claude Code Provider**: Advanced AI with MCP tools, subagents, and streaming +5. **UTCP Provider**: Direct UTCP tool execution via native protocols (HTTP, CLI, SSE) +6. **Claude Code Provider**: Advanced AI with MCP tools, subagents, and streaming 6. **Incremental Analysis**: Smart PR updates that analyze only new commits 7. **Comment Management**: Unique comment IDs prevent duplicate reviews 8. **Multiple Output Formats**: table, json, markdown, sarif diff --git a/README.md b/README.md index 07bec0d97..45bb369ac 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ Visor is an open-source workflow engine that lets you define multi-step AI pipel - **YAML-driven pipelines** — define checks, transforms, routing, and AI prompts in a single config file. - **8 runtime modes** — CLI, GitHub Action, Slack bot, Telegram bot, Email, WhatsApp, Teams, HTTP server — same config, any surface. -- **12+ provider types** — `ai`, `command`, `script`, `mcp`, `http`, `claude-code`, `github`, `memory`, `workflow`, and more. -- **AI orchestration** — multi-provider (Gemini, Claude, OpenAI, Bedrock), session reuse, MCP tool calling, retry & fallback. +- **17 provider types** — `ai`, `command`, `script`, `mcp`, `utcp`, `http`, `claude-code`, `a2a`, `github`, `memory`, `workflow`, and more. +- **AI orchestration** — multi-provider (Gemini, Claude, OpenAI, Bedrock), session reuse, MCP/UTCP tool calling, retry & fallback. - **Execution engine** — dependency DAGs, parallel waves, forEach fan-out, conditional routing, failure auto-remediation. - **Built-in testing** — YAML-native integration tests with fixtures, mocks, and assertions. @@ -241,7 +241,7 @@ Learn more: [docs/commands.md](docs/commands.md) | Concept | What it is | |---------|-----------| | **Step** (or Check) | Unit of work — a shell command, AI call, HTTP request, script, etc. | -| **Provider** | How a step runs: `ai`, `command`, `script`, `mcp`, `http`, `claude-code`, `github`, `memory`, `workflow`, … | +| **Provider** | How a step runs: `ai`, `command`, `script`, `mcp`, `utcp`, `http`, `claude-code`, `github`, `memory`, `workflow`, … | | **depends_on** | Execution order — independents run in parallel, dependents wait. | | **forEach** | Fan-out — transform output into an array, run dependents per item. | | **Routing** | `on_fail`, `on_success`, `goto`, `retry` — conditional flow with loop safety. | @@ -260,6 +260,7 @@ Learn more: [docs/commands.md](docs/commands.md) | `command` | Shell commands with Liquid templating | Run tests, build, lint | | `script` | JavaScript in a secure sandbox | Transform data, custom logic | | `mcp` | MCP tool execution (stdio/SSE/HTTP) | External tool integration | +| `utcp` | UTCP tool execution (HTTP/CLI/SSE) | Direct tool calling via manuals | | `claude-code` | Claude Code SDK with MCP tools | Deep code analysis, refactoring | | `http` | HTTP output/webhook sender | Notify Slack, trigger CI | | `http_input` | Webhook receiver | Accept external events | @@ -778,7 +779,7 @@ Learn more: [docs/enterprise-policy.md](docs/enterprise-policy.md) [Tools & Toolkits](docs/tools-and-toolkits.md) · [Assistant workflows](docs/assistant-workflows.md) · [TDD for assistant workflows](docs/guides/tdd-assistant-workflows.md) · [Workflow creation](docs/workflow-creation-guide.md) · [Workflow style guide](docs/guides/workflow-style-guide.md) · [Dependencies](docs/dependencies.md) · [forEach propagation](docs/foreach-dependency-propagation.md) · [Failure routing](docs/failure-routing.md) · [Router patterns](docs/router-patterns.md) · [Lifecycle hooks](docs/lifecycle-hooks.md) · [Liquid templates](docs/liquid-templates.md) · [Schema-template system](docs/schema-templates.md) · [Fail conditions](docs/fail-if.md) · [Failure conditions schema](docs/failure-conditions-schema.md) · [Failure conditions impl](docs/failure-conditions-implementation.md) · [Timeouts](docs/timeouts.md) · [Execution limits](docs/limits.md) · [Event triggers](docs/event-triggers.md) · [Output formats](docs/output-formats.md) · [Output formatting](docs/output-formatting.md) · [Default output schema](docs/default-output-schema.md) · [Output history](docs/output-history.md) · [Reusable workflows](docs/workflows.md) · [Criticality modes](docs/guides/criticality-modes.md) · [Fault management](docs/guides/fault-management-and-contracts.md) **Providers:** -[A2A](docs/a2a-provider.md) · [Command](docs/command-provider.md) · [Script](docs/script.md) · [MCP](docs/mcp-provider.md) · [MCP tools for AI](docs/mcp.md) · [Claude Code](docs/claude-code.md) · [AI custom tools](docs/ai-custom-tools.md) · [AI custom tools usage](docs/ai-custom-tools-usage.md) · [Custom tools](docs/custom-tools.md) · [GitHub ops](docs/github-ops.md) · [Git checkout](docs/providers/git-checkout.md) · [HTTP integration](docs/http.md) · [Memory](docs/memory.md) · [Human input](docs/human-input-provider.md) · [Custom providers](docs/pluggable.md) +[A2A](docs/a2a-provider.md) · [Command](docs/command-provider.md) · [Script](docs/script.md) · [MCP](docs/mcp-provider.md) · [UTCP](docs/utcp-provider.md) · [MCP tools for AI](docs/mcp.md) · [Claude Code](docs/claude-code.md) · [AI custom tools](docs/ai-custom-tools.md) · [AI custom tools usage](docs/ai-custom-tools-usage.md) · [Custom tools](docs/custom-tools.md) · [GitHub ops](docs/github-ops.md) · [Git checkout](docs/providers/git-checkout.md) · [HTTP integration](docs/http.md) · [Memory](docs/memory.md) · [Human input](docs/human-input-provider.md) · [Custom providers](docs/pluggable.md) **Operations:** [Security](docs/security.md) · [Performance](docs/performance.md) · [Observability](docs/observability.md) · [Debugging](docs/debugging.md) · [Debug visualizer](docs/debug-visualizer.md) · [Telemetry setup](docs/telemetry-setup.md) · [Dashboards](docs/dashboards/README.md) · [Troubleshooting](docs/troubleshooting.md) · [Suppressions](docs/suppressions.md) · [GitHub checks](docs/GITHUB_CHECKS.md) · [Bot integrations](docs/bot-integrations.md) · [Slack](docs/slack-integration.md) · [Telegram](docs/telegram-integration.md) · [Email](docs/email-integration.md) · [WhatsApp](docs/whatsapp-integration.md) · [Teams](docs/teams-integration.md) · [Scheduler](docs/scheduler.md) · [Sandbox engines](docs/sandbox-engines.md) diff --git a/docs/architecture.md b/docs/architecture.md index 052c921f5..db2252a97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -444,6 +444,7 @@ class CheckProviderRegistry { | `http_input` | HTTP Input | Receives webhook data | | `http_client` | HTTP Client | Makes HTTP requests | | `mcp` | MCP | Connects to MCP tool servers | +| `utcp` | UTCP | Calls UTCP tools via native protocols | | `claude-code` | Claude Code | Uses Claude Code SDK with MCP tools | | `memory` | Memory | Persistent key-value storage | | `log` | Logger | Debug logging output | @@ -1249,6 +1250,7 @@ src/ http-input-provider.ts # HTTP webhook input provider http-client-provider.ts # HTTP client provider mcp-check-provider.ts # MCP tool provider + utcp-check-provider.ts # UTCP tool provider mcp-tools.ts # MCP server management memory-check-provider.ts # Key-value memory provider log-check-provider.ts # Debug logging provider @@ -1420,3 +1422,4 @@ Events that flow through the state machine: | **Task Store** | SQLite-backed persistence layer for A2A tasks | | **Task Queue** | Async execution queue for processing A2A tasks with concurrency control | | **MCP** | Model Context Protocol - standard for AI tool integration | +| **UTCP** | Universal Tool Calling Protocol - client-side protocol for calling tools directly via native protocols | diff --git a/docs/configuration.md b/docs/configuration.md index 89efcf1f8..f4d9bb23a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,6 +41,7 @@ Visor supports the following check types: | `http_input` | Receive webhooks | [HTTP Integration](./http.md) | | `http_client` | Fetch data from APIs | [HTTP Integration](./http.md) | | `mcp` | MCP tool execution | [MCP Provider](./mcp-provider.md) | +| `utcp` | UTCP tool execution (HTTP/CLI/SSE) | [UTCP Provider](./utcp-provider.md) | | `memory` | Key-value storage operations | [Memory](./memory.md) | | `workflow` | Reusable workflow invocation | [Workflows](./workflows.md) | | `git-checkout` | Git repository checkout | [Git Checkout](./providers/git-checkout.md) | @@ -137,7 +138,7 @@ If there are errors, you'll get detailed messages with hints: ``` ❌ Configuration validation failed! -Error: Invalid check type "webhook". Must be: ai, claude-code, mcp, command, script, http, http_input, http_client, memory, noop, log, github, human-input, workflow, git-checkout +Error: Invalid check type "webhook". Must be: ai, claude-code, mcp, utcp, command, script, http, http_input, http_client, memory, noop, log, github, human-input, workflow, git-checkout, a2a 💡 Hint: The 'webhook' type has been renamed to 'http' for output and 'http_input' for input. ``` diff --git a/docs/glossary.md b/docs/glossary.md index 58735eb70..c9816d175 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -30,7 +30,7 @@ Configuration for retry delays. Supports `fixed` (constant delay) or `exponentia A single unit of work in a Visor workflow. Each check has a type (provider), configuration, and optional dependencies. The terms "check" and "step" are used interchangeably; `steps:` is the recommended configuration key. ### Check Provider -A pluggable component that executes a specific type of check. Visor includes 15 built-in providers: `ai`, `claude-code`, `mcp`, `command`, `script`, `http`, `http_input`, `http_client`, `memory`, `noop`, `log`, `github`, `human-input`, `workflow`, and `git-checkout`. See [Pluggable Architecture](./pluggable.md). +A pluggable component that executes a specific type of check. Visor includes 17 built-in providers: `ai`, `claude-code`, `mcp`, `utcp`, `a2a`, `command`, `script`, `http`, `http_input`, `http_client`, `memory`, `noop`, `log`, `github`, `human-input`, `workflow`, and `git-checkout`. See [Pluggable Architecture](./pluggable.md). ### Claude Code Provider A provider (`type: claude-code`) that integrates the Claude Code SDK with MCP tools and advanced agent capabilities including subagents and streaming. See [Claude Code](./claude-code.md). @@ -281,6 +281,14 @@ The `transform` (Liquid) or `transform_js` (JavaScript) fields that modify step ### Transitions Declarative routing rules in `on_success`, `on_fail`, or `on_finish` blocks. Each rule has `when` (condition), `to` (target step), and optional `goto_event`. Evaluated in order; first match wins. See [Failure Routing](./failure-routing.md). +## U + +### UTCP (Universal Tool Calling Protocol) +A client-side protocol for AI agents to discover and call tools directly via their native protocols (HTTP, CLI, SSE). Tools publish JSON "manuals" describing how to call them; the UTCP client reads the manual and makes direct calls without intermediate servers. Visor supports UTCP via the `utcp` provider. See [UTCP Provider](./utcp-provider.md). + +### UTCP Provider +A provider (`type: utcp`) that calls UTCP tools directly using their native protocols. Supports manual discovery from URLs, local files, or inline call templates. See [UTCP Provider](./utcp-provider.md). + ## V ### Validate Command diff --git a/docs/migration.md b/docs/migration.md index 69a423c4c..8d04e512c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -159,6 +159,8 @@ The following providers have been added: | `script` | `script` | Execute custom JavaScript logic | | `log` | `log` | Debug logging (replaces `logger`) | | `github` | `github` | Native GitHub API operations | +| `a2a` | `a2a` | Call external A2A-compatible agents | +| `utcp` | `utcp` | Call UTCP tools via native protocols (HTTP/CLI/SSE) | ### Provider Type Renames diff --git a/docs/pluggable.md b/docs/pluggable.md index db0f0a391..f01e43769 100644 --- a/docs/pluggable.md +++ b/docs/pluggable.md @@ -2,7 +2,7 @@ Visor supports multiple provider types. You can also add custom providers. -**Built-in Providers:** a2a, ai, mcp, command, script, http, http_input, http_client, log, memory, noop, github, human-input, workflow, git-checkout, claude-code +**Built-in Providers:** a2a, ai, mcp, utcp, command, script, http, http_input, http_client, log, memory, noop, github, human-input, workflow, git-checkout, claude-code ### Custom Provider Skeleton (TypeScript) @@ -63,6 +63,21 @@ steps: [Learn more](./mcp-provider.md) +#### UTCP Provider (`type: utcp`) +Call UTCP (Universal Tool Calling Protocol) tools directly via their native protocols. Unlike MCP which requires a running server, UTCP tools publish JSON "manuals" and the client calls them directly over HTTP, CLI, or SSE. + +```yaml +steps: + api-check: + type: utcp + manual: https://api.example.com/utcp + method: analyze + methodArgs: + files: "{{ files | map: 'filename' | join: ',' }}" +``` + +[Learn more](./utcp-provider.md) + #### Command Provider (`type: command`) Execute shell commands with templating and security controls. diff --git a/docs/utcp-provider.md b/docs/utcp-provider.md new file mode 100644 index 000000000..22b093bd2 --- /dev/null +++ b/docs/utcp-provider.md @@ -0,0 +1,299 @@ +# UTCP Provider + +The UTCP (Universal Tool Calling Protocol) provider lets you call external tools directly via their native protocols (HTTP, CLI, SSE) without intermediate servers. Tools publish JSON "manuals" that describe how to call them, and the UTCP client makes direct calls. + +Unlike MCP which requires a running server process, UTCP is a **client-side protocol** — the client reads a tool's manual and calls the tool's real API directly. + +## Quick Start + +```yaml +steps: + api-check: + type: utcp + manual: https://api.example.com/utcp + method: analyze + methodArgs: + files: "{{ files | map: 'filename' | join: ',' }}" +``` + +## Prerequisites + +Install the UTCP SDK: + +```bash +npm install @utcp/sdk @utcp/http +``` + +The SDK is an optional dependency — the provider gracefully handles its absence. + +## Configuration + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `type` | `"utcp"` | Yes | — | Provider type | +| `manual` | `string \| object` | Yes | — | Manual source: URL, file path, or inline call template | +| `method` | `string` | Yes | — | Tool name to call | +| `methodArgs` | `object` | No | `{}` | Arguments to pass (supports Liquid templates) | +| `argsTransform` | `string` | No | — | Liquid template that produces JSON args (overrides `methodArgs`) | +| `variables` | `object` | No | `{}` | UTCP variables for authentication/config | +| `plugins` | `string[]` | No | `["http"]` | UTCP plugins to load | +| `transform` | `string` | No | — | Liquid template to transform output | +| `transform_js` | `string` | No | — | JavaScript expression to transform output | +| `timeout` | `number` | No | `60` | Timeout in seconds | + +## Manual Sources + +The `manual` field supports three formats: + +### URL Discovery + +Point to a URL that returns a UTCP manual or OpenAPI spec. The provider creates an HTTP call template automatically: + +```yaml +steps: + petstore: + type: utcp + manual: https://petstore3.swagger.io/api/v3/openapi.json + method: findPetsByStatus + methodArgs: + status: available +``` + +### File-Based Manual + +Point to a local JSON file containing a UTCP manual: + +```yaml +steps: + local-tool: + type: utcp + manual: ./tools/my-manual.json + method: analyze +``` + +Example manual file: + +```json +{ + "utcp_version": "1.0.0", + "manual_version": "1.0.0", + "tools": [ + { + "name": "analyze", + "description": "Analyze code for issues", + "inputs": { + "type": "object", + "properties": { + "code": { "type": "string", "description": "Code to analyze" } + } + }, + "tool_call_template": { + "call_template_type": "http", + "url": "https://api.example.com/analyze", + "http_method": "POST" + } + } + ] +} +``` + +### Inline Call Template + +Define the call template directly in the config: + +```yaml +steps: + inline-check: + type: utcp + manual: + call_template_type: http + url: https://api.example.com/utcp + http_method: GET + method: check +``` + +## Tool Name Resolution + +The provider supports flexible tool name matching: + +- **Exact match**: `method: manual_name.tool_name` — matches the fully qualified name +- **Suffix match**: `method: tool_name` — automatically resolves to `manual_name.tool_name` + +This means you can use short names like `get_ip` instead of `httpbin.get_ip`. + +## Liquid Templates in Arguments + +Method arguments support Liquid templates for dynamic values: + +```yaml +steps: + scan: + type: utcp + manual: https://scanner.example.com/utcp + method: scan_code + methodArgs: + files: "{{ files | map: 'filename' | join: ',' }}" + pr_title: "{{ pr.title }}" + branch: "{{ pr.branch }}" +``` + +## Variables + +Pass authentication tokens or configuration via `variables`: + +```yaml +steps: + secure-scan: + type: utcp + manual: https://scanner.example.com/utcp + method: scan + variables: + API_KEY: "${SCANNER_API_KEY}" + WORKSPACE: "${GITHUB_WORKSPACE}" +``` + +Variables are resolved through the environment resolver, supporting `${ENV_VAR}` syntax. + +## Output Transforms + +### Liquid Transform + +```yaml +steps: + check: + type: utcp + manual: ./tools/manual.json + method: analyze + transform: | + {% assign results = output.findings %} + {{ results | json }} +``` + +### JavaScript Transform + +```yaml +steps: + check: + type: utcp + manual: ./tools/manual.json + method: analyze + transform_js: | + return output.results.map(r => ({ + file: r.file, + line: r.line, + message: r.msg, + severity: 'warning', + category: 'logic', + ruleId: 'utcp/' + r.rule + })); +``` + +## Issue Extraction + +The provider automatically extracts issues from structured output. If the tool returns data matching the Visor issue format, issues are detected automatically: + +```json +{ + "issues": [ + { + "file": "src/index.ts", + "line": 42, + "message": "Potential null dereference", + "severity": "warning", + "category": "logic", + "ruleId": "null-check" + } + ] +} +``` + +Supported field aliases: +- **message**: `message`, `text`, `description`, `summary` +- **severity**: `severity`, `level`, `priority` +- **file**: `file`, `path`, `filename` +- **line**: `line`, `startLine`, `lineNumber` + +## UTCP vs MCP + +| Feature | UTCP | MCP | +|---------|------|-----| +| **Architecture** | Client-side, no server needed | Requires running server process | +| **Transport** | Direct HTTP/CLI/SSE calls | stdio, SSE, or HTTP to MCP server | +| **Discovery** | JSON manuals at URLs or files | Server advertises tools | +| **Tool calls** | Client calls tool's real API | Client calls MCP server, server calls tool | +| **Best for** | REST APIs, CLI tools, public APIs | Complex tool servers, stateful tools | + +## Examples + +### Call a REST API + +```yaml +steps: + get-weather: + type: utcp + manual: + name: weather + call_template_type: http + url: https://api.weather.com/v1 + http_method: GET + method: get_forecast + methodArgs: + city: "San Francisco" + variables: + API_KEY: "${WEATHER_API_KEY}" +``` + +### Chain with Other Steps + +```yaml +steps: + fetch-data: + type: utcp + manual: ./tools/data-api.json + method: get_metrics + + analyze: + type: ai + depends_on: [fetch-data] + prompt: | + Analyze these metrics: + {{ outputs["fetch-data"] | json }} +``` + +### With Dependency Results + +```yaml +steps: + lint: + type: command + exec: npm run lint -- --format json + + external-scan: + type: utcp + manual: https://scanner.example.com/utcp + method: deep_scan + depends_on: [lint] + methodArgs: + lint_results: "{{ outputs['lint'] | json }}" +``` + +## Troubleshooting + +### "Tool not found in the repository" +The method name doesn't match any discovered tool. Use `--debug` to see available tools, then check your `method` value matches. + +### "@utcp/sdk not available" +Install the SDK: `npm install @utcp/sdk @utcp/http` + +### "Invalid CallTemplate object" +The manual file format is incorrect. Ensure it's either a valid UTCP manual (with `utcp_version` and `tools`) or a call template (with `call_template_type`). + +### Timeout errors +Increase the `timeout` value or check network connectivity to the tool endpoint. + +## Learn More + +- [UTCP Specification](https://utcp.io) +- [UTCP TypeScript SDK](https://github.com/anthropics/utcp-spec) +- [Pluggable Architecture](./pluggable.md) +- [MCP Provider](./mcp-provider.md) (comparison) diff --git a/package-lock.json b/package-lock.json index ef5db93d1..cc57b9979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "@probelabs/probe": "^0.6.0-rc295", "@types/commander": "^2.12.0", "@types/uuid": "^10.0.0", + "@utcp/file": "^1.1.0", + "@utcp/text": "^1.1.0", "acorn": "^8.16.0", "acorn-walk": "^8.3.5", "ajv": "^8.17.1", @@ -101,6 +103,8 @@ "optionalDependencies": { "@anthropic/claude-code-sdk": "npm:null@*", "@open-policy-agent/opa-wasm": "^1.10.0", + "@utcp/http": "^1.1.0", + "@utcp/sdk": "^1.1.0", "knex": "^3.1.0", "mysql2": "^3.11.0", "pg": "^8.13.0", @@ -109,6 +113,12 @@ "peerDependenciesMeta": { "@anthropic/claude-code-sdk": { "optional": true + }, + "@utcp/http": { + "optional": true + }, + "@utcp/sdk": { + "optional": true } } }, @@ -7692,6 +7702,49 @@ "win32" ] }, + "node_modules/@utcp/file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@utcp/file/-/file-1.1.0.tgz", + "integrity": "sha512-7bx1KoYEWEaXZvwbzOfePByL1NKTGAvO2cuXOYItKB8XCHRUDzcPEZkEPgKJqavBgYNJXVEiNQJlf7WRWJGIeQ==", + "license": "MPL-2.0", + "dependencies": { + "@utcp/http": "^1.1.0", + "@utcp/sdk": "^1.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@utcp/http": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@utcp/http/-/http-1.1.1.tgz", + "integrity": "sha512-wGCXKdaN3ojl7RdpeHoyPC9IC9vJ+oTdrAqUPrvTm6XAwFtOLnyPruoqMBiJMRwd+KMz8K8wOwk0vWTGokYGKg==", + "license": "MPL-2.0", + "dependencies": { + "@utcp/sdk": "^1.1.0", + "axios": "^1.11.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@utcp/sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@utcp/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-JyNG+TdtoaZ19K2v+FSmRzi3YR3Ri0XVI7ntiz8pKDA9ZbNqa5+op2EAmmGIHD9sp0IyTI7pMHeUR+mYqd97PA==", + "license": "MPL-2.0", + "dependencies": { + "dotenv": "^17.2.1", + "zod": "^3.23.8" + } + }, + "node_modules/@utcp/text": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@utcp/text/-/text-1.1.0.tgz", + "integrity": "sha512-hAcnvfxW8HjapWEP1es2XvkuYriV2uJWXd7fQl8UNg/4hEeJbJwBWwbNkst/i9ZmTBkHcCr7vSMAtj1CeTlcjg==", + "license": "MPL-2.0", + "dependencies": { + "@utcp/http": "^1.1.0", + "@utcp/sdk": "^1.1.0", + "js-yaml": "^4.1.0" + } + }, "node_modules/@vercel/ncc": { "version": "0.38.4", "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", diff --git a/package.json b/package.json index 6a4cc9978..0d9ee5a1e 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,8 @@ "@probelabs/probe": "^0.6.0-rc295", "@types/commander": "^2.12.0", "@types/uuid": "^10.0.0", + "@utcp/file": "^1.1.0", + "@utcp/text": "^1.1.0", "acorn": "^8.16.0", "acorn-walk": "^8.3.5", "ajv": "^8.17.1", @@ -153,6 +155,8 @@ "optionalDependencies": { "@anthropic/claude-code-sdk": "npm:null@*", "@open-policy-agent/opa-wasm": "^1.10.0", + "@utcp/http": "^1.1.0", + "@utcp/sdk": "^1.1.0", "knex": "^3.1.0", "mysql2": "^3.11.0", "pg": "^8.13.0", @@ -193,6 +197,12 @@ "peerDependenciesMeta": { "@anthropic/claude-code-sdk": { "optional": true + }, + "@utcp/sdk": { + "optional": true + }, + "@utcp/http": { + "optional": true } }, "directories": { diff --git a/src/config.ts b/src/config.ts index 5872a516f..82d383eb7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,6 +63,7 @@ export class ConfigManager { 'workflow', 'git-checkout', 'a2a', + 'utcp', ]; private validEventTriggers: EventTrigger[] = [...VALID_EVENT_TRIGGERS]; private validOutputFormats: ConfigOutputFormat[] = ['table', 'json', 'markdown', 'sarif']; diff --git a/src/generated/config-schema.ts b/src/generated/config-schema.ts index 996351b1f..112aca212 100644 --- a/src/generated/config-schema.ts +++ b/src/generated/config-schema.ts @@ -1046,6 +1046,28 @@ export const configSchema = { type: 'string', description: 'Working directory (for stdio transport in MCP checks)', }, + manual: { + anyOf: [ + { + type: 'string', + }, + { + $ref: '#/definitions/Record%3Cstring%2Cunknown%3E', + }, + ], + description: 'UTCP manual source: URL string, file path, or inline call template object', + }, + variables: { + $ref: '#/definitions/Record%3Cstring%2Cstring%3E', + description: 'UTCP variables for manual authentication/configuration', + }, + plugins: { + type: 'array', + items: { + type: 'string', + }, + description: "UTCP plugins to load (default: ['http'])", + }, placeholder: { type: 'string', description: 'Placeholder text to show in input field', @@ -1071,7 +1093,7 @@ export const configSchema = { description: 'Arguments/inputs for the workflow', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E%3E', description: 'Override specific step configurations in the workflow', }, output_mapping: { @@ -1088,7 +1110,7 @@ export const configSchema = { 'Config file path - alternative to workflow ID (loads a Visor config file as workflow)', }, workflow_overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E%3E', description: 'Alias for overrides - workflow step overrides (backward compatibility)', }, ref: { @@ -1192,6 +1214,7 @@ export const configSchema = { 'workflow', 'git-checkout', 'a2a', + 'utcp', ], description: 'Valid check types in configuration', }, @@ -1809,7 +1832,7 @@ export const configSchema = { description: 'Custom output name (defaults to workflow name)', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E%3E', description: 'Step overrides', }, output_mapping: { @@ -1824,14 +1847,14 @@ export const configSchema = { '^x-': {}, }, }, - 'Record>': + 'Record>': { type: 'object', additionalProperties: { - $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E', + $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E', }, }, - 'Partial': { + 'Partial': { type: 'object', additionalProperties: false, }, diff --git a/src/providers/check-provider-registry.ts b/src/providers/check-provider-registry.ts index 2841bbd81..64ba742b7 100644 --- a/src/providers/check-provider-registry.ts +++ b/src/providers/check-provider-registry.ts @@ -15,6 +15,7 @@ import { ScriptCheckProvider } from './script-check-provider'; import { WorkflowCheckProvider } from './workflow-check-provider'; import { GitCheckoutProvider } from './git-checkout-provider'; import { A2ACheckProvider } from './a2a-check-provider'; +import { UtcpCheckProvider } from './utcp-check-provider'; import { CustomToolDefinition } from '../types/config'; /** @@ -60,6 +61,17 @@ export class CheckProviderRegistry { this.register(new GitCheckoutProvider()); this.register(new A2ACheckProvider()); + // Try to register UtcpCheckProvider - it may fail if dependencies are missing + try { + this.register(new UtcpCheckProvider()); + } catch (error) { + console.error( + `Warning: Failed to register UtcpCheckProvider: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } + // Try to register ClaudeCodeCheckProvider - it may fail if dependencies are missing try { this.register(new ClaudeCodeCheckProvider()); diff --git a/src/providers/index.ts b/src/providers/index.ts index 822053812..9a041390a 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -15,4 +15,5 @@ export { HumanInputCheckProvider } from './human-input-check-provider'; export { ClaudeCodeCheckProvider } from './claude-code-check-provider'; export { McpCheckProvider } from './mcp-check-provider'; export { ScriptCheckProvider } from './script-check-provider'; +export { UtcpCheckProvider } from './utcp-check-provider'; export { VisorMcpTools, McpServerManager, DEFAULT_MCP_TOOLS_CONFIG } from './mcp-tools'; diff --git a/src/providers/utcp-check-provider.ts b/src/providers/utcp-check-provider.ts new file mode 100644 index 000000000..d793aef06 --- /dev/null +++ b/src/providers/utcp-check-provider.ts @@ -0,0 +1,693 @@ +import { CheckProvider, CheckProviderConfig } from './check-provider.interface'; +import { PRInfo } from '../pr-analyzer'; +import { ReviewSummary, ReviewIssue } from '../reviewer'; +import { logger } from '../logger'; +import { Liquid } from 'liquidjs'; +import { createExtendedLiquid } from '../liquid-extensions'; +import Sandbox from '@nyariv/sandboxjs'; +import { createSecureSandbox, compileAndRun } from '../utils/sandbox'; +import { EnvironmentResolver } from '../utils/env-resolver'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * UTCP Check Provider Configuration + */ +export interface UtcpCheckConfig extends CheckProviderConfig { + /** UTCP manual source: URL string, file path string, or inline call template object */ + manual: string | Record; + /** Tool method name to call (format: manual_name.tool_name or just tool_name) */ + method: string; + /** Arguments to pass to the UTCP tool (supports Liquid templates) */ + methodArgs?: Record; + /** Transform template for method arguments (Liquid) - overrides methodArgs */ + argsTransform?: string; + /** UTCP variables for manual authentication/configuration */ + variables?: Record; + /** UTCP plugins to load (default: ['http']) */ + plugins?: string[]; + /** Transform template for output (Liquid) */ + transform?: string; + /** Transform using JavaScript expressions */ + transform_js?: string; + /** Timeout in seconds (default: 60) */ + timeout?: number; +} + +/** + * Check provider that calls UTCP (Universal Tool Calling Protocol) tools directly. + * UTCP is a client-side protocol where tools publish JSON "manuals" describing + * how to call them via their native protocols (HTTP, CLI, SSE, etc.). + * + * Supports manual discovery from: + * - HTTP/HTTPS URLs (GET endpoint returning UTCP manual) + * - Local JSON files + * - Inline call template objects + */ +export class UtcpCheckProvider extends CheckProvider { + private liquid: Liquid; + private sandbox?: Sandbox; + private sdkAvailable: boolean | null = null; + + constructor() { + super(); + this.liquid = createExtendedLiquid({ + cache: false, + strictFilters: false, + strictVariables: false, + }); + } + + getName(): string { + return 'utcp'; + } + + getDescription(): string { + return 'Call UTCP tools directly using their native protocols (HTTP, CLI, SSE)'; + } + + async validateConfig(config: unknown): Promise { + if (!config || typeof config !== 'object') { + return false; + } + + const cfg = config as UtcpCheckConfig; + + // Type must be utcp + if (cfg.type !== 'utcp') { + return false; + } + + // Manual is required + if (!cfg.manual) { + logger.error('UTCP check requires a manual (URL, file path, or inline call template)'); + return false; + } + + // Method is required + if (!cfg.method || typeof cfg.method !== 'string') { + logger.error('UTCP check requires a method name'); + return false; + } + + // Validate manual format + if (typeof cfg.manual === 'string') { + // URL validation + if (cfg.manual.startsWith('http://') || cfg.manual.startsWith('https://')) { + try { + const parsedUrl = new URL(cfg.manual); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + logger.error(`Invalid URL protocol for UTCP manual: ${parsedUrl.protocol}`); + return false; + } + } catch { + logger.error(`Invalid URL format for UTCP manual: ${cfg.manual}`); + return false; + } + } + // File paths are validated at execution time + } else if (typeof cfg.manual === 'object') { + // Inline call template must have call_template_type + if (!cfg.manual.call_template_type) { + logger.error('Inline UTCP manual must have call_template_type'); + return false; + } + } else { + logger.error('UTCP manual must be a URL string, file path, or inline call template object'); + return false; + } + + return true; + } + + async execute( + prInfo: PRInfo, + config: CheckProviderConfig, + dependencyResults?: Map, + sessionInfo?: any + ): Promise { + const cfg = config as UtcpCheckConfig; + + // Test hook: mock output for this step + try { + const stepName = (config as any).checkName || 'unknown'; + const mock = sessionInfo?.hooks?.mockForStep?.(String(stepName)); + if (mock !== undefined) { + const ms = mock as any; + const issuesArr = Array.isArray(ms?.issues) ? (ms.issues as any[]) : []; + const out = ms && typeof ms === 'object' && 'output' in ms ? ms.output : ms; + return { + issues: issuesArr, + ...(out !== undefined ? { output: out } : {}), + } as ReviewSummary; + } + } catch {} + + try { + // Build template context + const templateContext = { + pr: { + number: prInfo.number, + title: prInfo.title, + author: prInfo.author, + branch: prInfo.head, + base: prInfo.base, + }, + files: prInfo.files, + fileCount: prInfo.files.length, + outputs: this.buildOutputContext(dependencyResults), + args: sessionInfo?.args || {}, + env: this.getSafeEnvironmentVariables(), + inputs: (config as any).workflowInputs || sessionInfo?.workflowInputs || {}, + }; + + // Render method arguments + let methodArgs = cfg.methodArgs || {}; + if (cfg.argsTransform) { + const rendered = await this.liquid.parseAndRender(cfg.argsTransform, templateContext); + try { + methodArgs = JSON.parse(rendered); + } catch (error) { + logger.error(`Failed to parse argsTransform as JSON: ${error}`); + return { + issues: [ + { + file: 'utcp', + line: 0, + ruleId: 'utcp/args_transform_error', + message: `Failed to parse argsTransform: ${error instanceof Error ? error.message : 'Unknown error'}`, + severity: 'error', + category: 'logic', + }, + ], + }; + } + } else if (methodArgs && typeof methodArgs === 'object') { + // Recursively render Liquid templates in methodArgs + const renderValue = async (val: unknown): Promise => { + if (typeof val === 'string' && (val.includes('{{') || val.includes('{%'))) { + return await this.liquid.parseAndRender(val, templateContext); + } else if (val && typeof val === 'object' && !Array.isArray(val)) { + const rendered: Record = {}; + for (const [k, v] of Object.entries(val)) { + rendered[k] = await renderValue(v); + } + return rendered; + } else if (Array.isArray(val)) { + return Promise.all(val.map(item => renderValue(item))); + } + return val; + }; + methodArgs = (await renderValue(methodArgs)) as Record; + } + + // Resolve manual to a call template + const callTemplate = await this.resolveManualCallTemplate(cfg.manual); + + // Resolve variables through environment resolver + const resolvedVariables: Record = {}; + if (cfg.variables) { + for (const [key, value] of Object.entries(cfg.variables)) { + resolvedVariables[key] = String(EnvironmentResolver.resolveValue(value)); + } + } + + // Dynamic import UTCP SDK + const { UtcpClient } = await import('@utcp/sdk'); + + // Load plugins + const plugins = cfg.plugins || ['http']; + for (const plugin of plugins) { + try { + await import(`@utcp/${plugin}`); + } catch (err) { + logger.debug(`UTCP plugin @utcp/${plugin} not available: ${err}`); + } + } + + // Create UTCP client + const timeout = (cfg.timeout || 60) * 1000; + const client = await UtcpClient.create(process.cwd(), { + manual_call_templates: [callTemplate], + variables: resolvedVariables, + } as any); + + try { + // Resolve tool name - try exact match first, then suffix match + let toolName = cfg.method; + try { + const tools = await client.getTools(); + const toolNames = tools.map((t: any) => t.name as string); + logger.debug(`UTCP tools available: ${JSON.stringify(toolNames)}`); + + if (!toolNames.includes(toolName)) { + // Try suffix match: user may specify "get_ip" but tool is "manual_name.get_ip" + const suffixMatch = toolNames.find((name: string) => name.endsWith(`.${toolName}`)); + if (suffixMatch) { + logger.debug( + `UTCP method '${toolName}' resolved to '${suffixMatch}' via suffix match` + ); + toolName = suffixMatch; + } + } + } catch (err) { + logger.debug(`Failed to list UTCP tools for name resolution: ${err}`); + } + + // Call tool with timeout + const result = await Promise.race([ + client.callTool(toolName, methodArgs as Record), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`UTCP tool call timed out after ${cfg.timeout || 60}s`)), + timeout + ) + ), + ]); + + // Apply transforms + let finalOutput = result; + + // Apply Liquid transform + if (cfg.transform) { + try { + const transformContext = { + ...templateContext, + output: result, + }; + const rendered = await this.liquid.parseAndRender(cfg.transform, transformContext); + try { + finalOutput = JSON.parse(rendered.trim()); + } catch { + finalOutput = rendered.trim(); + } + } catch (error) { + logger.error(`Failed to apply Liquid transform: ${error}`); + return { + issues: [ + { + file: 'utcp', + line: 0, + ruleId: 'utcp/transform_error', + message: `Failed to apply transform: ${error instanceof Error ? error.message : 'Unknown error'}`, + severity: 'error', + category: 'logic', + }, + ], + }; + } + } + + // Apply JavaScript transform + if (cfg.transform_js) { + try { + this.sandbox = createSecureSandbox(); + const scope = { + output: finalOutput, + pr: templateContext.pr, + files: templateContext.files, + outputs: templateContext.outputs, + env: templateContext.env, + }; + finalOutput = compileAndRun( + this.sandbox, + `return (${cfg.transform_js});`, + scope, + { injectLog: true, wrapFunction: false, logPrefix: '[utcp:transform_js]' } + ); + } catch (error) { + logger.error(`Failed to apply JavaScript transform: ${error}`); + return { + issues: [ + { + file: 'utcp', + line: 0, + ruleId: 'utcp/transform_js_error', + message: `Failed to apply JavaScript transform: ${error instanceof Error ? error.message : 'Unknown error'}`, + severity: 'error', + category: 'logic', + }, + ], + }; + } + } + + // Extract issues from output + const extracted = this.extractIssuesFromOutput(finalOutput); + if (extracted) { + return { + issues: extracted.issues, + ...(extracted.remainingOutput ? { output: extracted.remainingOutput } : {}), + } as ReviewSummary; + } + + // Return output directly + return { + issues: [], + ...(finalOutput ? { output: finalOutput } : {}), + } as ReviewSummary; + } finally { + try { + await client.close(); + } catch (err) { + logger.debug(`Failed to close UTCP client: ${err}`); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const isTimeout = this.isTimeoutError(error); + const severity: ReviewIssue['severity'] = isTimeout ? 'warning' : 'error'; + const ruleId = isTimeout ? 'utcp/timeout' : 'utcp/execution_error'; + + if (isTimeout) { + logger.warn(`UTCP check timed out: ${errorMessage}`); + } else { + logger.error(`UTCP check failed: ${errorMessage}`); + } + + return { + issues: [ + { + file: 'utcp', + line: 0, + ruleId, + message: isTimeout + ? `UTCP check timed out: ${errorMessage}` + : `UTCP check failed: ${errorMessage}`, + severity, + category: 'logic', + }, + ], + }; + } + } + + /** + * Resolve manual config to a UTCP call template object + */ + private async resolveManualCallTemplate( + manual: string | Record + ): Promise> { + if (typeof manual === 'object') { + if (!manual.call_template_type) { + throw new Error('Inline manual must have call_template_type'); + } + // Ensure it has a name + if (!manual.name) { + manual.name = 'inline'; + } + return manual; + } + + // URL-based discovery + if (manual.startsWith('http://') || manual.startsWith('https://')) { + return { + name: this.deriveManualName(manual), + call_template_type: 'http', + url: manual, + http_method: 'GET', + }; + } + + // File-based discovery + const resolvedPath = path.resolve(manual); + + // First, read and check if the file is already a call template + const content = fs.readFileSync(resolvedPath, 'utf-8'); + const parsed = JSON.parse(content); + + if (parsed.call_template_type) { + // File contains a call template directly - use as-is + if (!parsed.name) { + parsed.name = path.basename(resolvedPath, path.extname(resolvedPath)); + } + return parsed; + } + + // File contains a UTCP manual - use file call template to let SDK handle it + // Load the file plugin for file-based manuals + try { + await import('@utcp/file'); + } catch { + logger.debug('UTCP @utcp/file plugin not available, attempting direct parse'); + } + + return { + name: parsed.name || path.basename(resolvedPath, path.extname(resolvedPath)), + call_template_type: 'file', + file_path: resolvedPath, + allowed_communication_protocols: ['file', 'http', 'https'], + }; + } + + /** + * Derive a manual name from a URL + */ + private deriveManualName(url: string): string { + try { + const parsed = new URL(url); + // Use hostname with dots replaced by underscores + return parsed.hostname.replace(/\./g, '_').replace(/-/g, '_'); + } catch { + return 'utcp_manual'; + } + } + + /** + * Check if an error is a timeout error + */ + private isTimeoutError(error: unknown): boolean { + const err = error as { message?: unknown; code?: unknown; cause?: unknown }; + const message = typeof err?.message === 'string' ? err.message.toLowerCase() : ''; + const code = typeof err?.code === 'string' ? err.code.toLowerCase() : ''; + return message.includes('timeout') || message.includes('timed out') || code.includes('timeout'); + } + + /** + * Build output context from dependency results + */ + private buildOutputContext( + dependencyResults?: Map + ): Record { + if (!dependencyResults) { + return {}; + } + const outputs: Record = {}; + for (const [checkName, result] of dependencyResults) { + const summary = result as ReviewSummary & { output?: unknown }; + outputs[checkName] = summary.output !== undefined ? summary.output : summary; + } + return outputs; + } + + /** + * Get safe environment variables + */ + private getSafeEnvironmentVariables(): Record { + const safeVars: Record = {}; + const { buildSandboxEnv } = require('../utils/env-exposure'); + const merged = buildSandboxEnv(process.env); + for (const [key, value] of Object.entries(merged)) { + safeVars[key] = String(value); + } + safeVars['PWD'] = process.cwd(); + return safeVars; + } + + /** + * Extract issues from UTCP output + */ + private extractIssuesFromOutput( + output: unknown + ): { issues: ReviewIssue[]; remainingOutput: unknown } | null { + if (output === null || output === undefined) { + return null; + } + + // If output is a string, try to parse as JSON + if (typeof output === 'string') { + try { + const parsed = JSON.parse(output); + return this.extractIssuesFromOutput(parsed); + } catch { + return null; + } + } + + // If output is an array of issues + if (Array.isArray(output)) { + const issues = this.normalizeIssueArray(output); + if (issues) { + return { issues, remainingOutput: undefined }; + } + return null; + } + + // If output is an object with issues property + if (typeof output === 'object') { + const record = output as Record; + + if (Array.isArray(record.issues)) { + const issues = this.normalizeIssueArray(record.issues); + if (!issues) { + return null; + } + + const remaining = { ...record }; + delete (remaining as { issues?: unknown }).issues; + + return { + issues, + remainingOutput: Object.keys(remaining).length > 0 ? remaining : undefined, + }; + } + + // Check if output itself is a single issue + const singleIssue = this.normalizeIssue(record); + if (singleIssue) { + return { issues: [singleIssue], remainingOutput: undefined }; + } + } + + return null; + } + + /** + * Normalize an array of issues + */ + private normalizeIssueArray(values: unknown[]): ReviewIssue[] | null { + const normalized: ReviewIssue[] = []; + for (const value of values) { + const issue = this.normalizeIssue(value); + if (!issue) { + return null; + } + normalized.push(issue); + } + return normalized; + } + + /** + * Normalize a single issue + */ + private normalizeIssue(raw: unknown): ReviewIssue | null { + if (!raw || typeof raw !== 'object') { + return null; + } + + const data = raw as Record; + + const message = this.toTrimmedString( + data.message || data.text || data.description || data.summary + ); + if (!message) { + return null; + } + + const allowedSeverities = new Set(['info', 'warning', 'error', 'critical']); + const severityRaw = this.toTrimmedString(data.severity || data.level || data.priority); + let severity: ReviewIssue['severity'] = 'warning'; + if (severityRaw) { + const lower = severityRaw.toLowerCase(); + if (allowedSeverities.has(lower)) { + severity = lower as ReviewIssue['severity']; + } + } + + const allowedCategories = new Set([ + 'security', + 'performance', + 'style', + 'logic', + 'documentation', + ]); + const categoryRaw = this.toTrimmedString(data.category || data.type || data.group); + let category: ReviewIssue['category'] = 'logic'; + if (categoryRaw && allowedCategories.has(categoryRaw.toLowerCase())) { + category = categoryRaw.toLowerCase() as ReviewIssue['category']; + } + + const file = this.toTrimmedString(data.file || data.path || data.filename) || 'system'; + const line = this.toNumber(data.line || data.startLine || data.lineNumber) ?? 0; + const endLine = this.toNumber(data.endLine || data.end_line || data.stopLine); + const suggestion = this.toTrimmedString(data.suggestion); + const replacement = this.toTrimmedString(data.replacement); + const ruleId = + this.toTrimmedString(data.ruleId || data.rule || data.id || data.check) || 'utcp'; + + return { + file, + line, + endLine: endLine ?? undefined, + ruleId, + message, + severity, + category, + suggestion: suggestion || undefined, + replacement: replacement || undefined, + }; + } + + private toTrimmedString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (value !== null && value !== undefined && typeof value.toString === 'function') { + const converted = String(value).trim(); + return converted.length > 0 ? converted : null; + } + return null; + } + + private toNumber(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + const num = Number(value); + if (Number.isFinite(num)) { + return Math.trunc(num); + } + return null; + } + + getSupportedConfigKeys(): string[] { + return [ + 'type', + 'manual', + 'method', + 'methodArgs', + 'argsTransform', + 'variables', + 'plugins', + 'transform', + 'transform_js', + 'timeout', + 'depends_on', + 'on', + 'if', + 'group', + ]; + } + + async isAvailable(): Promise { + if (this.sdkAvailable !== null) { + return this.sdkAvailable; + } + try { + await import('@utcp/sdk'); + this.sdkAvailable = true; + } catch { + this.sdkAvailable = false; + } + return this.sdkAvailable; + } + + getRequirements(): string[] { + return [ + '@utcp/sdk package installed', + 'UTCP manual source (URL, file path, or inline)', + 'Tool method name', + ]; + } +} diff --git a/src/types/config.ts b/src/types/config.ts index e4352c86d..63aee72cc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -218,7 +218,8 @@ export type ConfigCheckType = | 'human-input' | 'workflow' | 'git-checkout' - | 'a2a'; + | 'a2a' + | 'utcp'; /** * Valid event triggers for checks @@ -753,6 +754,15 @@ export interface CheckConfig { command_args?: string[]; /** Working directory (for stdio transport in MCP checks) */ workingDirectory?: string; + /** + * UTCP provider specific options (optional, only used when type === 'utcp'). + */ + /** UTCP manual source: URL string, file path, or inline call template object */ + manual?: string | Record; + /** UTCP variables for manual authentication/configuration */ + variables?: Record; + /** UTCP plugins to load (default: ['http']) */ + plugins?: string[]; /** * Human input provider specific options (optional, only used when type === 'human-input'). */ diff --git a/tests/unit/providers/check-provider-registry.test.ts b/tests/unit/providers/check-provider-registry.test.ts index 87f916f99..4724f2f7f 100644 --- a/tests/unit/providers/check-provider-registry.test.ts +++ b/tests/unit/providers/check-provider-registry.test.ts @@ -173,8 +173,8 @@ describe('CheckProviderRegistry', () => { const providers = registry.getAllProviders(); expect(providers).toContain(provider1); expect(providers).toContain(provider2); - // Reset adds 16 default providers (ai, command, script, http, http_input, http_client, noop, log, memory, github, claude-code, mcp, human-input, workflow, git-checkout, a2a) + 2 custom = 18 total - expect(providers.length).toBe(18); + // Reset adds 17 default providers (ai, command, script, http, http_input, http_client, noop, log, memory, github, claude-code, mcp, human-input, workflow, git-checkout, a2a, utcp) + 2 custom = 19 total + expect(providers.length).toBe(19); }); }); diff --git a/tests/unit/providers/utcp-check-provider.test.ts b/tests/unit/providers/utcp-check-provider.test.ts new file mode 100644 index 000000000..3cb0b8c11 --- /dev/null +++ b/tests/unit/providers/utcp-check-provider.test.ts @@ -0,0 +1,503 @@ +import { UtcpCheckProvider } from '../../../src/providers/utcp-check-provider'; + +// Mock the UTCP SDK +const mockClose = jest.fn().mockResolvedValue(undefined); +const mockGetTools = jest + .fn() + .mockResolvedValue([{ name: 'test_manual.analyze', description: 'Analyze code' }]); +const mockCallTool = jest.fn().mockResolvedValue({ status: 'ok', data: 'result' }); +const mockCreate = jest.fn().mockResolvedValue({ + close: mockClose, + getTools: mockGetTools, + callTool: mockCallTool, +}); + +jest.mock('@utcp/sdk', () => ({ + UtcpClient: { + create: (...args: any[]) => mockCreate(...args), + }, +})); + +jest.mock('@utcp/http', () => ({})); + +// Mock fs for file-based manual tests +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn(), +})); + +describe('UTCP Check Provider', () => { + let provider: UtcpCheckProvider; + const mockPRInfo = { + number: 42, + title: 'Test PR', + body: 'Test body', + author: 'testuser', + base: 'main', + head: 'feature-branch', + files: [ + { + filename: 'src/index.ts', + status: 'modified' as const, + additions: 10, + deletions: 5, + changes: 15, + patch: '@@ -1,5 +1,10 @@\n+new code', + }, + ], + totalAdditions: 10, + totalDeletions: 5, + eventType: 'manual' as const, + }; + + beforeEach(() => { + jest.clearAllMocks(); + provider = new UtcpCheckProvider(); + mockCreate.mockResolvedValue({ + close: mockClose, + getTools: mockGetTools, + callTool: mockCallTool, + }); + }); + + describe('basic properties', () => { + it('should have correct name', () => { + expect(provider.getName()).toBe('utcp'); + }); + + it('should have correct description', () => { + const desc = provider.getDescription(); + expect(desc).toContain('UTCP'); + }); + + it('should be available when SDK is importable', async () => { + const available = await provider.isAvailable(); + expect(available).toBe(true); + }); + + it('should list requirements', () => { + const reqs = provider.getRequirements(); + expect(reqs.some(r => r.includes('@utcp/sdk'))).toBe(true); + expect(reqs.some(r => r.includes('manual'))).toBe(true); + expect(reqs.some(r => r.includes('method'))).toBe(true); + }); + + it('should list supported config keys', () => { + const keys = provider.getSupportedConfigKeys(); + expect(keys).toContain('type'); + expect(keys).toContain('manual'); + expect(keys).toContain('method'); + expect(keys).toContain('methodArgs'); + expect(keys).toContain('variables'); + expect(keys).toContain('plugins'); + expect(keys).toContain('transform'); + expect(keys).toContain('transform_js'); + expect(keys).toContain('timeout'); + }); + }); + + describe('validateConfig', () => { + it('should accept valid URL manual config', async () => { + const result = await provider.validateConfig({ + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'analyze', + }); + expect(result).toBe(true); + }); + + it('should accept valid file path manual config', async () => { + const result = await provider.validateConfig({ + type: 'utcp', + manual: './tools/manual.json', + method: 'lint', + }); + expect(result).toBe(true); + }); + + it('should accept valid inline manual config', async () => { + const result = await provider.validateConfig({ + type: 'utcp', + manual: { + call_template_type: 'http', + url: 'https://api.example.com/utcp', + http_method: 'GET', + }, + method: 'analyze', + }); + expect(result).toBe(true); + }); + + it('should reject missing manual', async () => { + const result = await provider.validateConfig({ + type: 'utcp', + method: 'analyze', + }); + expect(result).toBe(false); + }); + + it('should reject missing method', async () => { + const result = await provider.validateConfig({ + type: 'utcp', + manual: 'https://api.example.com/utcp', + }); + expect(result).toBe(false); + }); + + it('should reject wrong type', async () => { + const result = await provider.validateConfig({ + type: 'mcp', + manual: 'https://api.example.com/utcp', + method: 'analyze', + }); + expect(result).toBe(false); + }); + + it('should reject inline manual without call_template_type', async () => { + const result = await provider.validateConfig({ + type: 'utcp', + manual: { url: 'https://api.example.com/utcp' }, + method: 'analyze', + }); + expect(result).toBe(false); + }); + + it('should reject invalid URL format', async () => { + const result = await provider.validateConfig({ + type: 'utcp', + manual: 'http://[invalid', + method: 'analyze', + }); + expect(result).toBe(false); + }); + + it('should reject null config', async () => { + const result = await provider.validateConfig(null); + expect(result).toBe(false); + }); + + it('should reject non-object config', async () => { + const result = await provider.validateConfig('not an object'); + expect(result).toBe(false); + }); + }); + + describe('execute', () => { + it('should create client, call tool, and return output', async () => { + mockCallTool.mockResolvedValue({ status: 'ok', data: 'test result' }); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'analyze', + methodArgs: { input: 'test' }, + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + manual_call_templates: [ + expect.objectContaining({ + call_template_type: 'http', + url: 'https://api.example.com/utcp', + http_method: 'GET', + }), + ], + }) + ); + // Tool name resolved via suffix match: 'analyze' -> 'test_manual.analyze' + expect(mockCallTool).toHaveBeenCalledWith('test_manual.analyze', { input: 'test' }); + expect(result.issues).toEqual([]); + expect((result as any).output).toEqual({ status: 'ok', data: 'test result' }); + expect(mockClose).toHaveBeenCalled(); + }); + + it('should handle inline manual', async () => { + mockCallTool.mockResolvedValue('inline result'); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: { + name: 'my_api', + call_template_type: 'http', + url: 'https://api.example.com/utcp', + http_method: 'GET', + }, + method: 'check', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + manual_call_templates: [ + expect.objectContaining({ + name: 'my_api', + call_template_type: 'http', + }), + ], + }) + ); + expect(result.issues).toEqual([]); + expect((result as any).output).toBe('inline result'); + }); + + it('should resolve variables through EnvironmentResolver', async () => { + process.env.TEST_UTCP_KEY = 'resolved-key-123'; + + await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'analyze', + variables: { + API_KEY: '${TEST_UTCP_KEY}', + }, + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + variables: expect.objectContaining({ + API_KEY: 'resolved-key-123', + }), + }) + ); + + delete process.env.TEST_UTCP_KEY; + }); + + it('should handle mock hook', async () => { + const mockResult = { issues: [{ file: 'test', message: 'mock issue' }], output: 'mocked' }; + const sessionInfo = { + hooks: { + mockForStep: jest.fn().mockReturnValue(mockResult), + }, + }; + + const result = await provider.execute( + mockPRInfo, + { type: 'utcp', manual: 'https://example.com/utcp', method: 'test', checkName: 'my-step' }, + undefined, + sessionInfo + ); + + expect(sessionInfo.hooks.mockForStep).toHaveBeenCalledWith('my-step'); + expect(result.issues).toEqual([{ file: 'test', message: 'mock issue' }]); + expect((result as any).output).toBe('mocked'); + // Should NOT create a client + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('should extract issues from structured output', async () => { + mockCallTool.mockResolvedValue({ + issues: [ + { + file: 'src/index.ts', + line: 10, + message: 'Potential issue found', + severity: 'warning', + category: 'security', + ruleId: 'sec-001', + }, + ], + summary: 'Analysis complete', + }); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'scan', + }); + + expect(result.issues).toHaveLength(1); + expect(result.issues![0]).toMatchObject({ + file: 'src/index.ts', + line: 10, + message: 'Potential issue found', + severity: 'warning', + category: 'security', + ruleId: 'sec-001', + }); + expect((result as any).output).toEqual({ summary: 'Analysis complete' }); + }); + + it('should handle client creation errors', async () => { + mockCreate.mockRejectedValue(new Error('SDK initialization failed')); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'analyze', + }); + + expect(result.issues).toHaveLength(1); + expect(result.issues![0]).toMatchObject({ + ruleId: 'utcp/execution_error', + severity: 'error', + message: expect.stringContaining('SDK initialization failed'), + }); + }); + + it('should handle tool call errors', async () => { + mockCallTool.mockRejectedValue(new Error('Tool not found')); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'nonexistent_tool', + }); + + expect(result.issues).toHaveLength(1); + expect(result.issues![0]).toMatchObject({ + ruleId: 'utcp/execution_error', + severity: 'error', + message: expect.stringContaining('Tool not found'), + }); + // Client should still be closed + expect(mockClose).toHaveBeenCalled(); + }); + + it('should handle timeout errors', async () => { + mockCallTool.mockRejectedValue(new Error('UTCP tool call timed out after 60s')); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'slow_tool', + timeout: 60, + }); + + expect(result.issues).toHaveLength(1); + expect(result.issues![0]).toMatchObject({ + ruleId: 'utcp/timeout', + severity: 'warning', + message: expect.stringContaining('timed out'), + }); + }); + + it('should close client even on error', async () => { + mockCallTool.mockRejectedValue(new Error('Some error')); + + await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'analyze', + }); + + expect(mockClose).toHaveBeenCalled(); + }); + + it('should return empty issues for non-issue output', async () => { + mockCallTool.mockResolvedValue('plain text result'); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'analyze', + }); + + expect(result.issues).toEqual([]); + expect((result as any).output).toBe('plain text result'); + }); + + it('should pass empty methodArgs when not provided', async () => { + await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'simple_tool', + }); + + expect(mockCallTool).toHaveBeenCalledWith('simple_tool', {}); + }); + + it('should derive manual name from URL', async () => { + await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.weather.com/utcp', + method: 'get_weather', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + manual_call_templates: [ + expect.objectContaining({ + name: 'api_weather_com', + }), + ], + }) + ); + }); + }); + + describe('issue extraction', () => { + it('should extract issues from array output', async () => { + mockCallTool.mockResolvedValue([ + { + file: 'test.ts', + line: 1, + message: 'Issue 1', + severity: 'error', + category: 'logic', + }, + { + file: 'test.ts', + line: 5, + message: 'Issue 2', + severity: 'warning', + category: 'style', + }, + ]); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'scan', + }); + + expect(result.issues).toHaveLength(2); + expect(result.issues![0].message).toBe('Issue 1'); + expect(result.issues![1].message).toBe('Issue 2'); + }); + + it('should use default ruleId of utcp', async () => { + mockCallTool.mockResolvedValue([{ file: 'test.ts', line: 1, message: 'No rule specified' }]); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'scan', + }); + + expect(result.issues![0].ruleId).toBe('utcp'); + }); + + it('should normalize severity aliases', async () => { + mockCallTool.mockResolvedValue({ + issues: [{ message: 'Test', level: 'error', file: 'test.ts', line: 1 }], + }); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'scan', + }); + + expect(result.issues![0].severity).toBe('error'); + }); + + it('should normalize file aliases', async () => { + mockCallTool.mockResolvedValue({ + issues: [{ message: 'Test', path: 'src/app.ts', lineNumber: 42 }], + }); + + const result = await provider.execute(mockPRInfo, { + type: 'utcp', + manual: 'https://api.example.com/utcp', + method: 'scan', + }); + + expect(result.issues![0].file).toBe('src/app.ts'); + expect(result.issues![0].line).toBe(42); + }); + }); +}); From cd418f19e0f22988c7624bfec6691ee617f56206 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 12 Mar 2026 22:19:56 +0300 Subject: [PATCH 2/8] docs: add UTCP provider example configurations Add examples/utcp-provider-example.yaml with 10 examples covering: - URL-based manual discovery (OpenAPI/UTCP endpoints) - Inline call templates - File-based UTCP manuals - Liquid templates in arguments - Variable-based authentication - JavaScript transforms for issue extraction - Chaining UTCP with AI analysis - Dynamic args via argsTransform - Conditional execution with if expressions - Multi-step pipeline with dependencies Also link the example from README.md and docs/utcp-provider.md. Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + docs/utcp-provider.md | 1 + examples/utcp-provider-example.yaml | 243 ++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 examples/utcp-provider-example.yaml diff --git a/README.md b/README.md index 45bb369ac..4bc6aa4b5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Visor is an open-source workflow engine that lets you define multi-step AI pipel | **Chat assistant / Bot** | [Bot Integrations](docs/bot-integrations.md) | [teams-assistant.yaml](examples/teams-assistant.yaml) | | **Run shell commands + AI** | [Command Provider](docs/command-provider.md) | [ai-with-bash.yaml](examples/ai-with-bash.yaml) | | **Connect MCP tools** | [MCP Provider](docs/mcp-provider.md) | [mcp-provider-example.yaml](examples/mcp-provider-example.yaml) | +| **Call tools via UTCP** | [UTCP Provider](docs/utcp-provider.md) | [utcp-provider-example.yaml](examples/utcp-provider-example.yaml) | | **Add API integrations (TDD)** | [Guide: TDD Assistant Workflows](docs/guides/tdd-assistant-workflows.md) | [workable.tests.yaml](https://github.com/TykTechnologies/REFINE/blob/main/Oel/tests/workable.tests.yaml) | > **First time?** Run `npx visor init` to scaffold a working config, then `npx visor` to run it. diff --git a/docs/utcp-provider.md b/docs/utcp-provider.md index 22b093bd2..93a4cacfc 100644 --- a/docs/utcp-provider.md +++ b/docs/utcp-provider.md @@ -295,5 +295,6 @@ Increase the `timeout` value or check network connectivity to the tool endpoint. - [UTCP Specification](https://utcp.io) - [UTCP TypeScript SDK](https://github.com/anthropics/utcp-spec) +- [Example Configurations](../examples/utcp-provider-example.yaml) - [Pluggable Architecture](./pluggable.md) - [MCP Provider](./mcp-provider.md) (comparison) diff --git a/examples/utcp-provider-example.yaml b/examples/utcp-provider-example.yaml new file mode 100644 index 000000000..72f01d200 --- /dev/null +++ b/examples/utcp-provider-example.yaml @@ -0,0 +1,243 @@ +version: "1.0" + +# ============================================================================= +# UTCP (Universal Tool Calling Protocol) Provider Examples +# +# UTCP is a client-side protocol for calling tools directly via their native +# protocols (HTTP, CLI, SSE) without intermediate servers. Tools publish JSON +# "manuals" describing how to call them; the UTCP SDK reads the manual and +# makes direct calls. +# +# Prerequisites: +# npm install @utcp/sdk @utcp/http +# +# Run: +# visor --config examples/utcp-provider-example.yaml +# ============================================================================= + +steps: + # =========================================================================== + # Example 1: URL-Based Manual Discovery + # Point at an OpenAPI or UTCP endpoint and call tools directly + # =========================================================================== + petstore-lookup: + type: utcp + manual: https://petstore3.swagger.io/api/v3/openapi.json + method: findPetsByStatus + methodArgs: + status: available + timeout: 30 + tags: ["demo"] + transform_js: | + // Extract first 5 pets from the response + const pets = Array.isArray(output) ? output.slice(0, 5) : []; + return pets.map(p => ({ name: p.name, status: p.status, id: p.id })); + + # =========================================================================== + # Example 2: Inline Call Template + # Define the tool's HTTP endpoint directly in the config + # =========================================================================== + check-ip: + type: utcp + manual: + name: httpbin + call_template_type: http + url: https://httpbin.org + http_method: GET + method: get_ip + timeout: 15 + tags: ["demo", "fast"] + + # =========================================================================== + # Example 3: File-Based UTCP Manual + # Load tool definitions from a local JSON file + # + # Example manual file (tools/api-manual.json): + # { + # "utcp_version": "1.0.0", + # "manual_version": "1.0.0", + # "tools": [{ + # "name": "health_check", + # "description": "Check service health", + # "inputs": { "type": "object", "properties": {} }, + # "tool_call_template": { + # "call_template_type": "http", + # "url": "https://api.example.com/health", + # "http_method": "GET" + # } + # }] + # } + # =========================================================================== + health-check: + type: utcp + manual: ./tools/api-manual.json + method: health_check + timeout: 10 + tags: ["monitoring"] + + # =========================================================================== + # Example 4: UTCP with Liquid Templates in Arguments + # Pass PR context to tool calls dynamically + # =========================================================================== + external-scan: + type: utcp + manual: https://scanner.example.com/utcp + method: scan_code + methodArgs: + files: "{{ files | map: 'filename' | join: ',' }}" + pr_number: "{{ pr.number }}" + branch: "{{ pr.branch }}" + author: "{{ pr.author }}" + timeout: 120 + tags: ["security"] + + # =========================================================================== + # Example 5: UTCP with Variables for Authentication + # Pass API keys and tokens securely via environment variables + # =========================================================================== + authenticated-api: + type: utcp + manual: https://api.example.com/utcp + method: analyze + methodArgs: + input: "{{ files | map: 'filename' | json }}" + variables: + API_KEY: "${SCANNER_API_KEY}" + WORKSPACE_ID: "${GITHUB_REPOSITORY}" + timeout: 60 + tags: ["security", "slow"] + + # =========================================================================== + # Example 6: UTCP with JavaScript Transform for Issue Extraction + # Convert tool output into Visor issues for PR comments + # =========================================================================== + lint-with-utcp: + type: utcp + manual: https://linter.example.com/utcp + method: lint + methodArgs: + language: "typescript" + files: "{{ files | map: 'filename' | json }}" + transform_js: | + // Convert linter output to Visor issues + const findings = output.findings || output.results || []; + return findings.map(f => ({ + file: f.file || f.path || 'unknown', + line: f.line || f.startLine || 0, + message: f.message || f.description, + severity: f.severity === 'error' ? 'error' : 'warning', + category: f.category || 'style', + ruleId: 'utcp/' + (f.rule || f.code || 'lint') + })); + + # =========================================================================== + # Example 7: Chaining UTCP with AI Analysis + # Fetch data via UTCP, then analyze with AI + # =========================================================================== + fetch-metrics: + type: utcp + manual: + name: metrics_api + call_template_type: http + url: https://metrics.example.com/api/v1 + http_method: GET + method: get_pr_metrics + methodArgs: + repo: "{{ env.GITHUB_REPOSITORY }}" + pr: "{{ pr.number }}" + tags: ["metrics"] + + analyze-metrics: + type: ai + depends_on: [fetch-metrics] + prompt: | + Analyze these code metrics for PR #{{ pr.number }}: + {{ outputs["fetch-metrics"] | json }} + + Identify any concerning trends in complexity, test coverage, + or code churn. Provide actionable recommendations. + schema: code-review + tags: ["metrics"] + + # =========================================================================== + # Example 8: UTCP with argsTransform for Dynamic Arguments + # Use a Liquid template to construct the entire args object + # =========================================================================== + dynamic-args-check: + type: utcp + manual: https://api.example.com/utcp + method: deep_analysis + argsTransform: | + { + "files": {{ files | map: 'filename' | json }}, + "config": { + "severity_threshold": "warning", + "max_issues": 50, + "language": "{{ files | map: 'filename' | first | split: '.' | last }}" + } + } + timeout: 90 + + # =========================================================================== + # Example 9: Conditional UTCP Execution + # Only run UTCP checks when specific files change + # =========================================================================== + api-contract-check: + type: utcp + manual: https://contract-validator.example.com/utcp + method: validate_openapi + if: "files.some(f => f.filename.includes('openapi') || f.filename.includes('swagger'))" + methodArgs: + spec_files: "{{ files | where: 'filename', 'openapi' | map: 'filename' | json }}" + tags: ["api", "contracts"] + transform_js: | + if (output.valid === false) { + return output.errors.map(e => ({ + file: e.file || 'openapi.yaml', + line: e.line || 0, + message: e.message, + severity: 'error', + category: 'logic', + ruleId: 'utcp/openapi-violation' + })); + } + return []; + + # =========================================================================== + # Example 10: UTCP in a Pipeline with Dependencies + # Multi-step workflow: fetch data -> process -> notify + # =========================================================================== + fetch-dependencies: + type: utcp + manual: + name: deps_api + call_template_type: http + url: https://deps.example.com/api + http_method: GET + method: list_outdated + methodArgs: + manifest: "package.json" + tags: ["deps"] + + check-vulnerabilities: + type: utcp + depends_on: [fetch-dependencies] + manual: + name: vuln_api + call_template_type: http + url: https://vuln-db.example.com/api + http_method: POST + method: check_packages + methodArgs: + packages: "{{ outputs['fetch-dependencies'] | json }}" + tags: ["deps", "security"] + transform_js: | + const vulns = output.vulnerabilities || []; + return vulns.map(v => ({ + file: 'package.json', + line: 0, + message: `${v.package}@${v.version}: ${v.advisory} (${v.severity})`, + severity: v.severity === 'critical' ? 'error' : 'warning', + category: 'security', + ruleId: 'utcp/vuln-' + v.id + })); From 41f1fe2132b6cd5459b43eb78687c88cbe307290 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 12 Mar 2026 23:11:00 +0300 Subject: [PATCH 3/8] feat: add UTCP-to-MCP bridge for AI agent tool access UTCP tools can now be exposed as MCP tools to AI agents via ai_mcp_servers with type: utcp entries. Tools are discovered from the UTCP manual at setup time and routed through the UTCP SDK at call time via the CustomToolsSSEServer. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- docs/ai-configuration.md | 1 + docs/ai-custom-tools.md | 25 ++ docs/glossary.md | 2 +- docs/guides/build-ai-agent.md | 4 + docs/mcp.md | 20 ++ docs/migration.md | 2 +- docs/pluggable.md | 12 + docs/utcp-provider.md | 43 +++ examples/utcp-provider-example.yaml | 47 ++++ src/generated/config-schema.ts | 40 ++- src/providers/ai-check-provider.ts | 111 +++++++- src/providers/mcp-custom-sse-server.ts | 93 ++++++- src/providers/utcp-check-provider.ts | 24 +- src/types/config.ts | 12 +- tests/unit/providers/utcp-mcp-bridge.test.ts | 275 +++++++++++++++++++ 16 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 tests/unit/providers/utcp-mcp-bridge.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ef0f8eb06..ecef062d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Visor is an AI-powered code review tool for GitHub Pull Requests that can run as 2. **Pluggable Providers**: Extensible system for different analysis types 3. **AI Integration**: Multi-provider AI support including Claude Code SDK 4. **MCP Provider**: Direct MCP tool execution with stdio, SSE, and HTTP transports -5. **UTCP Provider**: Direct UTCP tool execution via native protocols (HTTP, CLI, SSE) +5. **UTCP Provider**: Direct UTCP tool execution via native protocols (HTTP, CLI, SSE); also bridges to MCP for AI agent tool access via `ai_mcp_servers` 6. **Claude Code Provider**: Advanced AI with MCP tools, subagents, and streaming 6. **Incremental Analysis**: Smart PR updates that analyze only new commits 7. **Comment Management**: Unique comment IDs prevent duplicate reviews diff --git a/docs/ai-configuration.md b/docs/ai-configuration.md index 36b64ea21..e22a1b2a0 100644 --- a/docs/ai-configuration.md +++ b/docs/ai-configuration.md @@ -726,6 +726,7 @@ Each server config can be: - **SSE/HTTP MCP server**: `{ url, transport }` - **Workflow tool**: `{ workflow, inputs }` - **Built-in tool**: `{ tool: 'schedule' }` +- **UTCP tools**: `{ type: 'utcp', manual: '...', variables: {...} }` — discovers and exposes all tools from a UTCP manual Dynamic servers are merged with any static `ai_mcp_servers` configuration. diff --git a/docs/ai-custom-tools.md b/docs/ai-custom-tools.md index 05959a267..8e347b653 100644 --- a/docs/ai-custom-tools.md +++ b/docs/ai-custom-tools.md @@ -127,6 +127,31 @@ steps: - You want to give a meaningful name to your tool server - You prefer all MCP configuration in one place +### Method 3: Using UTCP tools in `ai_mcp_servers` + +UTCP tools can be exposed as MCP tools to AI agents. All tools discovered from the UTCP manual are automatically available: + +```yaml +steps: + security-review: + type: ai + prompt: | + Use the scanner tools to check for security issues. + ai_mcp_servers: + scanner: + type: utcp + manual: https://scanner.example.com/utcp + variables: + API_KEY: "${SCANNER_API_KEY}" +``` + +**Choose UTCP in `ai_mcp_servers` when:** +- You have UTCP-compatible tools (tools that publish JSON manuals) +- You want direct tool calling without MCP server processes +- You need to integrate HTTP/CLI tools with AI agents + +See [UTCP Provider — AI Integration](./utcp-provider.md#using-utcp-tools-with-ai-checks) for details. + ### Basic Example ### API Bundle Example (`type: api`) diff --git a/docs/glossary.md b/docs/glossary.md index c9816d175..868070b22 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -287,7 +287,7 @@ Declarative routing rules in `on_success`, `on_fail`, or `on_finish` blocks. Eac A client-side protocol for AI agents to discover and call tools directly via their native protocols (HTTP, CLI, SSE). Tools publish JSON "manuals" describing how to call them; the UTCP client reads the manual and makes direct calls without intermediate servers. Visor supports UTCP via the `utcp` provider. See [UTCP Provider](./utcp-provider.md). ### UTCP Provider -A provider (`type: utcp`) that calls UTCP tools directly using their native protocols. Supports manual discovery from URLs, local files, or inline call templates. See [UTCP Provider](./utcp-provider.md). +A provider (`type: utcp`) that calls UTCP tools directly using their native protocols. Supports manual discovery from URLs, local files, or inline call templates. UTCP tools can also be exposed as MCP tools to AI agents via `ai_mcp_servers` with `type: utcp` entries. See [UTCP Provider](./utcp-provider.md). ## V diff --git a/docs/guides/build-ai-agent.md b/docs/guides/build-ai-agent.md index f44207223..fcd6bc971 100644 --- a/docs/guides/build-ai-agent.md +++ b/docs/guides/build-ai-agent.md @@ -202,6 +202,9 @@ steps: server-name: command: "..." args: [...] + utcp-tools: # or UTCP tools + type: utcp + manual: https://tools.example.com/utcp ``` ## Complete examples @@ -210,6 +213,7 @@ steps: - [ai-with-bash.yaml](../../examples/ai-with-bash.yaml) — AI with bash access - [claude-code-config.yaml](../../examples/claude-code-config.yaml) — Claude Code with MCP tools - [mcp-provider-example.yaml](../../examples/mcp-provider-example.yaml) — Direct MCP tool calls +- [utcp-provider-example.yaml](../../examples/utcp-provider-example.yaml) — UTCP tools (standalone and with AI) ## Common mistakes diff --git a/docs/mcp.md b/docs/mcp.md index dbf383760..54fb8c0ef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -144,7 +144,27 @@ ai_mcp_servers: - [Jira Workflow Automation](../examples/jira-workflow-mcp.yaml) - Complete Jira integration examples - [Simple Jira Analysis](../examples/jira-simple-example.yaml) - Basic JQL → analyze → label workflow +#### UTCP Tools in AI Checks + +You can also expose UTCP tools to AI agents via `ai_mcp_servers`. UTCP tools are discovered from a manual and bridged to MCP automatically: + +```yaml +steps: + review: + type: ai + prompt: "Use the scanner to check for security issues." + ai_mcp_servers: + scanner: + type: utcp + manual: https://scanner.example.com/utcp + variables: + API_KEY: "${SCANNER_API_KEY}" +``` + +All tools discovered from the UTCP manual are exposed as MCP tools to the AI agent. See [UTCP Provider — AI Integration](./utcp-provider.md#using-utcp-tools-with-ai-checks). + #### Related Documentation - [MCP Provider](./mcp-provider.md) - Standalone MCP tool execution (direct tool calls without AI) +- [UTCP Provider](./utcp-provider.md) - Standalone UTCP tool execution and AI integration - [Custom Tools](./custom-tools.md) - Define custom tools for use with MCP diff --git a/docs/migration.md b/docs/migration.md index 8d04e512c..0c80d5087 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -160,7 +160,7 @@ The following providers have been added: | `log` | `log` | Debug logging (replaces `logger`) | | `github` | `github` | Native GitHub API operations | | `a2a` | `a2a` | Call external A2A-compatible agents | -| `utcp` | `utcp` | Call UTCP tools via native protocols (HTTP/CLI/SSE) | +| `utcp` | `utcp` | Call UTCP tools via native protocols (HTTP/CLI/SSE); also usable as `ai_mcp_servers` entry with `type: utcp` | ### Provider Type Renames diff --git a/docs/pluggable.md b/docs/pluggable.md index f01e43769..714151165 100644 --- a/docs/pluggable.md +++ b/docs/pluggable.md @@ -76,6 +76,18 @@ steps: files: "{{ files | map: 'filename' | join: ',' }}" ``` +UTCP tools can also be exposed to AI agents via `ai_mcp_servers`: + +```yaml +steps: + ai-review: + type: ai + ai_mcp_servers: + scanner: + type: utcp + manual: https://scanner.example.com/utcp +``` + [Learn more](./utcp-provider.md) #### Command Provider (`type: command`) diff --git a/docs/utcp-provider.md b/docs/utcp-provider.md index 93a4cacfc..9e1a94bf3 100644 --- a/docs/utcp-provider.md +++ b/docs/utcp-provider.md @@ -277,6 +277,49 @@ steps: lint_results: "{{ outputs['lint'] | json }}" ``` +## Using UTCP Tools with AI Checks + +UTCP tools can be exposed as MCP tools to AI agents via `ai_mcp_servers`. The AI agent discovers all tools from the UTCP manual and can call any of them during analysis. + +```yaml +steps: + ai-review: + type: ai + prompt: "Review this PR for security and performance issues. Use the scanner tools to analyze the code." + ai_mcp_servers: + scanner: + type: utcp + manual: https://scanner.example.com/utcp + variables: + API_KEY: "${SCANNER_API_KEY}" + plugins: ["http"] +``` + +When the AI check starts, Visor: +1. Resolves the UTCP manual (URL, file, or inline) +2. Discovers all tools via `client.getTools()` +3. Exposes each discovered tool as an MCP tool to the AI agent +4. Routes AI tool calls through the UTCP SDK + +This works alongside other `ai_mcp_servers` entry types (workflows, http_client, regular MCP servers): + +```yaml +steps: + smart-review: + type: ai + prompt: "Review and analyze using all available tools." + ai_mcp_servers: + scanner: + type: utcp + manual: https://scanner.example.com/utcp + jira: + command: npx + args: ["-y", "@aashari/mcp-server-atlassian-jira"] + internal-api: + type: http_client + base_url: https://internal.example.com/api +``` + ## Troubleshooting ### "Tool not found in the repository" diff --git a/examples/utcp-provider-example.yaml b/examples/utcp-provider-example.yaml index 72f01d200..18889b2eb 100644 --- a/examples/utcp-provider-example.yaml +++ b/examples/utcp-provider-example.yaml @@ -241,3 +241,50 @@ steps: category: 'security', ruleId: 'utcp/vuln-' + v.id })); + +# ------------------------------------------------------------------- +# Example 11: UTCP Tools Exposed to AI Agent +# UTCP tools registered in ai_mcp_servers are discovered and exposed +# as MCP tools to the AI agent. The AI can call any discovered tool. +# ------------------------------------------------------------------- + ai-with-utcp-tools: + type: ai + prompt: | + Review this PR for security issues. Use the scanner tools + to perform deep analysis of the code changes. + ai_mcp_servers: + scanner: + type: utcp + manual: https://scanner.example.com/utcp + variables: + API_KEY: "${SCANNER_API_KEY}" + plugins: ["http"] + +# ------------------------------------------------------------------- +# Example 12: AI with Mixed Tool Sources (UTCP + MCP + HTTP) +# Combine UTCP tools, regular MCP servers, and HTTP client tools +# in a single AI check. The AI sees all tools together. +# ------------------------------------------------------------------- + ai-mixed-tools: + type: ai + prompt: | + Analyze this PR using all available tools. + Use the code scanner for security, JIRA for ticket context, + and the internal API for deployment status. + ai_mcp_servers: + code-scanner: + type: utcp + manual: https://scanner.example.com/utcp + variables: + TOKEN: "${SCANNER_TOKEN}" + jira: + command: npx + args: ["-y", "@aashari/mcp-server-atlassian-jira"] + env: + ATLASSIAN_SITE_NAME: mysite + deploy-api: + type: http_client + base_url: https://deploy.internal.example.com/api + auth: + type: bearer + token: "${DEPLOY_TOKEN}" diff --git a/src/generated/config-schema.ts b/src/generated/config-schema.ts index 112aca212..f75c6f1ca 100644 --- a/src/generated/config-schema.ts +++ b/src/generated/config-schema.ts @@ -233,7 +233,7 @@ export const configSchema = { properties: { type: { type: 'string', - enum: ['command', 'api', 'workflow', 'http_client'], + enum: ['command', 'api', 'workflow', 'http_client', 'utcp'], description: "Tool implementation type (defaults to 'command')", }, name: { @@ -458,6 +458,32 @@ export const configSchema = { $ref: '#/definitions/RateLimitConfig', description: 'Rate limiting configuration for HTTP/API tools', }, + __utcpManual: { + anyOf: [ + { + type: 'string', + }, + { + $ref: '#/definitions/Record%3Cstring%2Cunknown%3E', + }, + ], + description: 'UTCP manual source (URL, file path, or inline call template)', + }, + __utcpToolName: { + type: 'string', + description: 'Resolved UTCP tool name from discovery', + }, + __utcpVariables: { + $ref: '#/definitions/Record%3Cstring%2Cstring%3E', + description: 'UTCP variables for authentication', + }, + __utcpPlugins: { + type: 'array', + items: { + type: 'string', + }, + description: "UTCP plugins to load (default: ['http'])", + }, workflow: { type: 'string', description: "Workflow ID (registry lookup) or file path (for type: 'workflow')", @@ -1093,7 +1119,7 @@ export const configSchema = { description: 'Arguments/inputs for the workflow', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58960%3E%3E', description: 'Override specific step configurations in the workflow', }, output_mapping: { @@ -1110,7 +1136,7 @@ export const configSchema = { 'Config file path - alternative to workflow ID (loads a Visor config file as workflow)', }, workflow_overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58960%3E%3E', description: 'Alias for overrides - workflow step overrides (backward compatibility)', }, ref: { @@ -1832,7 +1858,7 @@ export const configSchema = { description: 'Custom output name (defaults to workflow name)', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58960%3E%3E', description: 'Step overrides', }, output_mapping: { @@ -1847,14 +1873,14 @@ export const configSchema = { '^x-': {}, }, }, - 'Record>': + 'Record>': { type: 'object', additionalProperties: { - $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58544%3E', + $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-14897-29977-src_types_config.ts-0-58960%3E', }, }, - 'Partial': { + 'Partial': { type: 'object', additionalProperties: false, }, diff --git a/src/providers/ai-check-provider.ts b/src/providers/ai-check-provider.ts index 8767f1306..83f87b1fd 100644 --- a/src/providers/ai-check-provider.ts +++ b/src/providers/ai-check-provider.ts @@ -1261,14 +1261,16 @@ export class AICheckProvider extends CheckProvider { } } - // Option 4: Extract workflow, tool, and http_client entries from ai_mcp_servers/ai_mcp_servers_js + // Option 4: Extract workflow, tool, http_client, and utcp entries from ai_mcp_servers/ai_mcp_servers_js // Unified format: // { workflow: 'name', inputs: {...} } → workflow tool // { tool: 'name' } → custom tool from tools: section or built-in (e.g., 'schedule') // { type: 'http_client', base_url: '...', ... } → HTTP client tool (REST API proxy) + // { type: 'utcp', manual: '...', ... } → UTCP tools (discovered from manual) const workflowEntriesFromMcp: WorkflowToolReference[] = []; const toolEntriesFromMcp: string[] = []; const httpClientEntriesFromMcp: Array<{ name: string; config: Record }> = []; + const utcpEntriesFromMcp: Array<{ name: string; config: Record }> = []; const mcpEntriesToRemove: string[] = []; for (const [serverName, serverConfig] of Object.entries(mcpServers)) { @@ -1293,6 +1295,13 @@ export class AICheckProvider extends CheckProvider { logger.debug( `[AICheckProvider] Extracted http_client tool '${serverName}' (base_url=${cfg.base_url || cfg.url}) from ai_mcp_servers` ); + } else if (cfg.type === 'utcp' && cfg.manual) { + // UTCP tool entry — tools discovered from manual and exposed via SSE server + utcpEntriesFromMcp.push({ name: serverName, config: cfg }); + mcpEntriesToRemove.push(serverName); + logger.debug( + `[AICheckProvider] Extracted utcp entry '${serverName}' (manual=${typeof cfg.manual === 'string' ? cfg.manual : 'inline'}) from ai_mcp_servers` + ); } else if (cfg.tool && typeof cfg.tool === 'string') { // Custom tool or built-in tool entry toolEntriesFromMcp.push(cfg.tool as string); @@ -1466,6 +1475,106 @@ export class AICheckProvider extends CheckProvider { customToolsServerName = '__tools__'; } + // Add UTCP tools discovered from ai_mcp_servers entries + if (utcpEntriesFromMcp.length > 0) { + for (const entry of utcpEntriesFromMcp) { + try { + const { UtcpCheckProvider } = await import('./utcp-check-provider'); + const manual = entry.config.manual as string | Record; + const variables = entry.config.variables as Record | undefined; + const plugins = (entry.config.plugins as string[]) || ['http']; + + // Resolve environment variable placeholders in variables + const resolvedVariables: Record = {}; + if (variables) { + for (const [key, value] of Object.entries(variables)) { + resolvedVariables[key] = String(EnvironmentResolver.resolveValue(value)); + } + } + + // Resolve manual to call template + const callTemplate = await UtcpCheckProvider.resolveManualCallTemplate(manual); + + // Load UTCP SDK and plugins + const { UtcpClient } = await import('@utcp/sdk'); + for (const plugin of plugins) { + try { + await import(`@utcp/${plugin}`); + } catch { + logger.debug(`[AICheckProvider] UTCP plugin @utcp/${plugin} not available`); + } + } + + // Create client and discover tools + const client = await UtcpClient.create(process.cwd(), { + manual_call_templates: [callTemplate], + variables: resolvedVariables, + } as any); + + try { + const tools = await client.getTools(); + logger.debug( + `[AICheckProvider] UTCP entry '${entry.name}' discovered ${tools.length} tools: ${tools.map((t: any) => t.name).join(', ')}` + ); + + for (const tool of tools) { + const toolName = (tool as any).name as string; + const toolDesc = (tool as any).description as string | undefined; + const toolInputs = (tool as any).inputs || (tool as any).inputSchema; + + // Convert UTCP input schema to MCP-compatible inputSchema + let inputSchema: CustomToolDefinition['inputSchema'] = { + type: 'object', + properties: {}, + required: [], + }; + if (toolInputs && typeof toolInputs === 'object') { + // UTCP inputs may be a JSON Schema object directly + if (toolInputs.type === 'object') { + inputSchema = toolInputs; + } else if (toolInputs.properties) { + inputSchema = { + type: 'object', + properties: toolInputs.properties, + required: toolInputs.required || [], + }; + } + } + + const utcpTool: CustomToolDefinition = { + name: toolName, + type: 'utcp', + description: toolDesc || `UTCP tool ${toolName} from ${entry.name}`, + inputSchema, + __utcpManual: manual, + __utcpToolName: toolName, + __utcpVariables: resolvedVariables, + __utcpPlugins: plugins, + }; + customTools.set(toolName, utcpTool); + logger.debug( + `[AICheckProvider] Added UTCP tool '${toolName}' from entry '${entry.name}'` + ); + } + } finally { + try { + if (typeof (client as any).close === 'function') { + await (client as any).close(); + } + } catch {} + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[AICheckProvider] Failed to discover UTCP tools from entry '${entry.name}': ${errMsg}` + ); + } + } + if (!customToolsServerName) { + customToolsServerName = '__tools__'; + } + } + if (customTools.size > 0) { const sessionId = (config as any).checkName || `ai-check-${Date.now()}`; const debug = aiConfig.debug || process.env.VISOR_DEBUG === 'true'; diff --git a/src/providers/mcp-custom-sse-server.ts b/src/providers/mcp-custom-sse-server.ts index 63b2fa82d..f54651ca1 100644 --- a/src/providers/mcp-custom-sse-server.ts +++ b/src/providers/mcp-custom-sse-server.ts @@ -31,6 +31,13 @@ function isHttpClientTool(tool: CustomToolDefinition | undefined): boolean { return Boolean(tool && tool.type === 'http_client' && (tool.base_url || (tool as any).url)); } +/** + * Check if a tool definition is a UTCP tool + */ +function isUtcpTool(tool: CustomToolDefinition | undefined): boolean { + return Boolean(tool && tool.type === 'utcp' && tool.__utcpManual); +} + /** * MCP Protocol message types */ @@ -170,10 +177,10 @@ export class CustomToolsSSEServer implements CustomMCPServer { } } - // Second pass: separate workflow and http_client tools from regular tools + // Second pass: separate workflow, http_client, and utcp tools from regular tools for (const [name, tool] of this.tools.entries()) { - // Skip workflow and http_client tools - they're handled separately - if (isWorkflowTool(tool) || isHttpClientTool(tool)) { + // Skip workflow, http_client, and utcp tools - they're handled separately + if (isWorkflowTool(tool) || isHttpClientTool(tool) || isUtcpTool(tool)) { if (isWorkflowTool(tool)) { workflowToolNames.push(name); } @@ -782,7 +789,14 @@ export class CustomToolsSSEServer implements CustomMCPServer { description: tool.description || `Call ${tool.name} HTTP API`, inputSchema: normalizeInputSchema(tool.inputSchema as Record | undefined), })); - const allTools = [...regularTools, ...workflowTools, ...httpClientTools]; + const utcpTools = Array.from(this.tools.values()) + .filter(isUtcpTool) + .map(tool => ({ + name: tool.name, + description: tool.description || `Call ${tool.name} via UTCP`, + inputSchema: normalizeInputSchema(tool.inputSchema as Record | undefined), + })); + const allTools = [...regularTools, ...workflowTools, ...httpClientTools, ...utcpTools]; if (this.debug) { logger.debug( @@ -950,6 +964,9 @@ export class CustomToolsSSEServer implements CustomMCPServer { } else if (tool && isHttpClientTool(tool)) { // Execute HTTP client tool — proxy REST API calls result = await this.executeHttpClientTool(tool, args); + } else if (tool && isUtcpTool(tool)) { + // Execute UTCP tool — call via UTCP SDK + result = await this.executeUtcpTool(tool, toolName, args); } else { // Execute regular custom tool result = await this.toolExecutor.execute(toolName, args); @@ -1128,6 +1145,74 @@ export class CustomToolsSSEServer implements CustomMCPServer { } } + /** + * Execute a UTCP tool — call via UTCP SDK using stored manual/variables. + */ + private async executeUtcpTool( + tool: CustomToolDefinition, + toolName: string, + args: Record + ): Promise { + const manual = tool.__utcpManual; + const utcpToolName = tool.__utcpToolName || toolName; + const variables = tool.__utcpVariables || {}; + const plugins = tool.__utcpPlugins || ['http']; + const timeout = tool.timeout || 60000; + + if (!manual) { + throw new Error(`UTCP tool '${toolName}' missing manual configuration`); + } + + if (this.debug) { + logger.debug( + `[CustomToolsSSEServer:${this.sessionId}] Executing UTCP tool '${utcpToolName}' with args: ${JSON.stringify(args)}` + ); + } + + // Dynamic import UTCP SDK and plugins + const { UtcpClient } = await import('@utcp/sdk'); + for (const plugin of plugins) { + try { + await import(`@utcp/${plugin}`); + } catch { + logger.debug( + `[CustomToolsSSEServer:${this.sessionId}] UTCP plugin @utcp/${plugin} not available` + ); + } + } + + // Resolve call template + const { UtcpCheckProvider } = await import('./utcp-check-provider'); + const callTemplate = await UtcpCheckProvider.resolveManualCallTemplate(manual); + + // Create client + const client = await UtcpClient.create(process.cwd(), { + manual_call_templates: [callTemplate], + variables, + } as any); + + try { + // Call tool with timeout + const result = await Promise.race([ + client.callTool(utcpToolName, args as Record), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`UTCP tool '${utcpToolName}' timed out after ${timeout}ms`)), + timeout + ) + ), + ]); + + return result; + } finally { + try { + if (typeof (client as any).close === 'function') { + await (client as any).close(); + } + } catch {} + } + } + /** * Convert a type: 'workflow' tool definition into a WorkflowToolDefinition marker. * diff --git a/src/providers/utcp-check-provider.ts b/src/providers/utcp-check-provider.ts index d793aef06..fe7ee9dcc 100644 --- a/src/providers/utcp-check-provider.ts +++ b/src/providers/utcp-check-provider.ts @@ -383,10 +383,20 @@ export class UtcpCheckProvider extends CheckProvider { } /** - * Resolve manual config to a UTCP call template object + * Resolve manual config to a UTCP call template object (instance method, delegates to static) */ private async resolveManualCallTemplate( manual: string | Record + ): Promise> { + return UtcpCheckProvider.resolveManualCallTemplate(manual); + } + + /** + * Resolve manual config to a UTCP call template object. + * Shared utility used by both the standalone UTCP provider and the AI check provider's UTCP-to-MCP bridge. + */ + static async resolveManualCallTemplate( + manual: string | Record ): Promise> { if (typeof manual === 'object') { if (!manual.call_template_type) { @@ -402,7 +412,7 @@ export class UtcpCheckProvider extends CheckProvider { // URL-based discovery if (manual.startsWith('http://') || manual.startsWith('https://')) { return { - name: this.deriveManualName(manual), + name: UtcpCheckProvider.deriveManualName(manual), call_template_type: 'http', url: manual, http_method: 'GET', @@ -441,9 +451,17 @@ export class UtcpCheckProvider extends CheckProvider { } /** - * Derive a manual name from a URL + * Derive a manual name from a URL (instance method, delegates to static) */ private deriveManualName(url: string): string { + return UtcpCheckProvider.deriveManualName(url); + } + + /** + * Derive a manual name from a URL. + * Shared utility for UTCP manual name derivation. + */ + static deriveManualName(url: string): string { try { const parsed = new URL(url); // Use hostname with dots replaced by underscores diff --git a/src/types/config.ts b/src/types/config.ts index 63aee72cc..0a02f1070 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1223,7 +1223,7 @@ export interface VisorHooks { */ export interface CustomToolDefinition { /** Tool implementation type (defaults to 'command') */ - type?: 'command' | 'api' | 'workflow' | 'http_client'; + type?: 'command' | 'api' | 'workflow' | 'http_client' | 'utcp'; /** Tool name - used to reference the tool in MCP blocks */ name: string; /** Description of what the tool does */ @@ -1302,6 +1302,16 @@ export interface CustomToolDefinition { /** Rate limiting configuration for HTTP/API tools */ rate_limit?: RateLimitConfig; + // === UTCP tool fields (type: 'utcp') === + /** UTCP manual source (URL, file path, or inline call template) */ + __utcpManual?: string | Record; + /** Resolved UTCP tool name from discovery */ + __utcpToolName?: string; + /** UTCP variables for authentication */ + __utcpVariables?: Record; + /** UTCP plugins to load (default: ['http']) */ + __utcpPlugins?: string[]; + // === Workflow tool fields (type: 'workflow') === /** Workflow ID (registry lookup) or file path (for type: 'workflow') */ workflow?: string; diff --git a/tests/unit/providers/utcp-mcp-bridge.test.ts b/tests/unit/providers/utcp-mcp-bridge.test.ts new file mode 100644 index 000000000..8d2be4104 --- /dev/null +++ b/tests/unit/providers/utcp-mcp-bridge.test.ts @@ -0,0 +1,275 @@ +/** + * Tests for UTCP-to-MCP bridge integration: + * - UTCP tool detection (isUtcpTool pattern) + * - UTCP tool listing via CustomToolsSSEServer + * - UTCP tool execution routing + * - UTCP entry extraction in AI check provider + */ + +import { CustomToolDefinition } from '../../../src/types/config'; + +// Mock the UTCP SDK +const mockClose = jest.fn().mockResolvedValue(undefined); +const mockGetTools = jest.fn().mockResolvedValue([ + { + name: 'scanner.check_security', + description: 'Check code for security issues', + inputs: { + type: 'object', + properties: { + code: { type: 'string', description: 'Code to check' }, + language: { type: 'string', description: 'Programming language' }, + }, + required: ['code'], + }, + }, + { + name: 'scanner.check_performance', + description: 'Check code for performance issues', + inputs: { + type: 'object', + properties: { + code: { type: 'string', description: 'Code to check' }, + }, + required: ['code'], + }, + }, +]); +const mockCallTool = jest.fn().mockResolvedValue({ status: 'ok', findings: [] }); +const mockCreate = jest.fn().mockResolvedValue({ + close: mockClose, + getTools: mockGetTools, + callTool: mockCallTool, +}); + +jest.mock('@utcp/sdk', () => ({ + UtcpClient: { + create: (...args: any[]) => mockCreate(...args), + }, +})); + +jest.mock('@utcp/http', () => ({})); + +describe('UTCP-MCP Bridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isUtcpTool detection', () => { + // Replicate the isUtcpTool logic for testing + function isUtcpTool(tool: CustomToolDefinition | undefined): boolean { + return Boolean(tool && tool.type === 'utcp' && tool.__utcpManual); + } + + it('should detect UTCP tools', () => { + const tool: CustomToolDefinition = { + name: 'scanner.check_security', + type: 'utcp', + description: 'Check security', + __utcpManual: 'https://scanner.example.com/utcp', + __utcpToolName: 'scanner.check_security', + __utcpVariables: { API_KEY: 'test-key' }, + __utcpPlugins: ['http'], + }; + expect(isUtcpTool(tool)).toBe(true); + }); + + it('should not detect non-UTCP tools', () => { + const httpTool: CustomToolDefinition = { + name: 'my-api', + type: 'http_client', + base_url: 'https://api.example.com', + }; + expect(isUtcpTool(httpTool)).toBe(false); + }); + + it('should not detect UTCP tool without manual', () => { + const tool: CustomToolDefinition = { + name: 'broken', + type: 'utcp', + }; + expect(isUtcpTool(tool)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isUtcpTool(undefined)).toBe(false); + }); + }); + + describe('UTCP tool discovery via UtcpCheckProvider static methods', () => { + it('should resolve URL-based manual to HTTP call template', async () => { + const { UtcpCheckProvider } = await import('../../../src/providers/utcp-check-provider'); + const result = await UtcpCheckProvider.resolveManualCallTemplate( + 'https://scanner.example.com/utcp' + ); + expect(result.call_template_type).toBe('http'); + expect(result.url).toBe('https://scanner.example.com/utcp'); + expect(result.http_method).toBe('GET'); + expect(result.name).toBe('scanner_example_com'); + }); + + it('should resolve inline manual with call_template_type', async () => { + const { UtcpCheckProvider } = await import('../../../src/providers/utcp-check-provider'); + const inline = { + call_template_type: 'http', + url: 'https://api.example.com/utcp', + http_method: 'GET', + }; + const result = await UtcpCheckProvider.resolveManualCallTemplate(inline); + expect(result.call_template_type).toBe('http'); + expect(result.url).toBe('https://api.example.com/utcp'); + expect(result.name).toBe('inline'); + }); + + it('should derive manual name from URL', () => { + const { UtcpCheckProvider } = require('../../../src/providers/utcp-check-provider'); + expect(UtcpCheckProvider.deriveManualName('https://scanner.example.com/utcp')).toBe( + 'scanner_example_com' + ); + expect(UtcpCheckProvider.deriveManualName('https://api-server.test.io/manual')).toBe( + 'api_server_test_io' + ); + }); + }); + + describe('UTCP tool creation for CustomToolDefinition', () => { + it('should create CustomToolDefinition with UTCP fields', () => { + const utcpTool: CustomToolDefinition = { + name: 'scanner.check_security', + type: 'utcp', + description: 'Check code for security issues', + inputSchema: { + type: 'object', + properties: { + code: { type: 'string', description: 'Code to check' }, + language: { type: 'string', description: 'Programming language' }, + }, + required: ['code'], + }, + __utcpManual: 'https://scanner.example.com/utcp', + __utcpToolName: 'scanner.check_security', + __utcpVariables: { API_KEY: 'test-key' }, + __utcpPlugins: ['http'], + }; + + expect(utcpTool.type).toBe('utcp'); + expect(utcpTool.__utcpManual).toBe('https://scanner.example.com/utcp'); + expect(utcpTool.__utcpToolName).toBe('scanner.check_security'); + expect(utcpTool.__utcpVariables).toEqual({ API_KEY: 'test-key' }); + expect(utcpTool.__utcpPlugins).toEqual(['http']); + expect(utcpTool.inputSchema?.properties).toHaveProperty('code'); + }); + + it('should discover multiple tools from a single UTCP manual', async () => { + const { UtcpClient } = await import('@utcp/sdk'); + + const client = await UtcpClient.create(process.cwd(), { + manual_call_templates: [ + { + name: 'scanner', + call_template_type: 'http', + url: 'https://scanner.example.com/utcp', + http_method: 'GET', + }, + ], + }); + + const tools = await client.getTools(); + expect(tools).toHaveLength(2); + expect(tools[0].name).toBe('scanner.check_security'); + expect(tools[1].name).toBe('scanner.check_performance'); + + // Create a CustomToolDefinition for each discovered tool + const customTools = new Map(); + for (const tool of tools) { + const toolDef: CustomToolDefinition = { + name: tool.name, + type: 'utcp', + description: tool.description, + inputSchema: + tool.inputs?.type === 'object' + ? tool.inputs + : { type: 'object', properties: {}, required: [] }, + __utcpManual: 'https://scanner.example.com/utcp', + __utcpToolName: tool.name, + __utcpVariables: {}, + __utcpPlugins: ['http'], + }; + customTools.set(tool.name, toolDef); + } + + expect(customTools.size).toBe(2); + expect(customTools.has('scanner.check_security')).toBe(true); + expect(customTools.has('scanner.check_performance')).toBe(true); + }); + }); + + describe('UTCP tool execution', () => { + it('should call UTCP tool via client.callTool', async () => { + const { UtcpClient } = await import('@utcp/sdk'); + + const client = await UtcpClient.create(process.cwd(), { + manual_call_templates: [ + { + name: 'scanner', + call_template_type: 'http', + url: 'https://scanner.example.com/utcp', + http_method: 'GET', + }, + ], + variables: { API_KEY: 'test-key' }, + }); + + const result = await client.callTool('scanner.check_security', { + code: 'console.log("test")', + language: 'javascript', + }); + + expect(mockCallTool).toHaveBeenCalledWith('scanner.check_security', { + code: 'console.log("test")', + language: 'javascript', + }); + expect(result).toEqual({ status: 'ok', findings: [] }); + }); + }); + + describe('UTCP entry extraction from ai_mcp_servers', () => { + it('should identify UTCP entries by type and manual fields', () => { + const mcpServers: Record> = { + 'real-mcp': { + command: 'npx', + args: ['-y', 'some-mcp-server'], + }, + 'my-scanner': { + type: 'utcp', + manual: 'https://scanner.example.com/utcp', + variables: { API_KEY: '${SCANNER_KEY}' }, + plugins: ['http'], + }, + 'my-api': { + type: 'http_client', + base_url: 'https://api.example.com', + }, + }; + + const utcpEntries: Array<{ name: string; config: Record }> = []; + const mcpEntriesToRemove: string[] = []; + + for (const [serverName, serverConfig] of Object.entries(mcpServers)) { + const cfg = serverConfig; + if (cfg.type === 'utcp' && cfg.manual) { + utcpEntries.push({ name: serverName, config: cfg }); + mcpEntriesToRemove.push(serverName); + } + } + + expect(utcpEntries).toHaveLength(1); + expect(utcpEntries[0].name).toBe('my-scanner'); + expect(utcpEntries[0].config.manual).toBe('https://scanner.example.com/utcp'); + expect(mcpEntriesToRemove).toContain('my-scanner'); + // Non-UTCP entries should remain + expect(mcpEntriesToRemove).not.toContain('real-mcp'); + expect(mcpEntriesToRemove).not.toContain('my-api'); + }); + }); +}); From e6b182395d0b823f18e3d822464a894650e0a156 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 13 Mar 2026 08:57:42 +0300 Subject: [PATCH 4/8] refactor: deduplicate issue normalization and UTCP client lifecycle - Extract shared extractIssuesFromOutput/normalizeIssue to src/utils/issue-normalizer.ts, used by MCP and UTCP providers - Add UtcpCheckProvider.callTool() static method for shared UTCP client lifecycle (import, create, call, close) - Make executeUtcpTool in SSE server delegate to callTool() - Add file existence check and JSON parse error handling in resolveManualCallTemplate Co-Authored-By: Claude Opus 4.6 --- src/providers/mcp-check-provider.ts | 152 +-------------- src/providers/mcp-custom-sse-server.ts | 51 +---- src/providers/utcp-check-provider.ts | 245 +++++++++---------------- src/utils/issue-normalizer.ts | 162 ++++++++++++++++ 4 files changed, 260 insertions(+), 350 deletions(-) create mode 100644 src/utils/issue-normalizer.ts diff --git a/src/providers/mcp-check-provider.ts b/src/providers/mcp-check-provider.ts index d1247c5a7..8ce8c428a 100644 --- a/src/providers/mcp-check-provider.ts +++ b/src/providers/mcp-check-provider.ts @@ -13,6 +13,11 @@ import { createSecureSandbox, compileAndRun } from '../utils/sandbox'; import { EnvironmentResolver } from '../utils/env-resolver'; import { CustomToolExecutor } from './custom-tool-executor'; import { CustomToolDefinition } from '../types/config'; +import { + extractIssuesFromOutput as sharedExtractIssues, + normalizeIssue as sharedNormalizeIssue, + normalizeIssueArray as sharedNormalizeIssueArray, +} from '../utils/issue-normalizer'; import { Agent } from 'undici'; const parseTimeoutMs = (value: string | undefined, fallback: number): number => { @@ -804,156 +809,15 @@ export class McpCheckProvider extends CheckProvider { private extractIssuesFromOutput( output: unknown ): { issues: ReviewIssue[]; remainingOutput: unknown } | null { - if (output === null || output === undefined) { - return null; - } - - // If output is a string, try to parse as JSON - if (typeof output === 'string') { - try { - const parsed = JSON.parse(output); - return this.extractIssuesFromOutput(parsed); - } catch { - return null; - } - } - - // If output is an array of issues - if (Array.isArray(output)) { - const issues = this.normalizeIssueArray(output); - if (issues) { - return { issues, remainingOutput: undefined }; - } - return null; - } - - // If output is an object with issues property - if (typeof output === 'object') { - const record = output as Record; - - if (Array.isArray(record.issues)) { - const issues = this.normalizeIssueArray(record.issues); - if (!issues) { - return null; - } - - const remaining = { ...record }; - delete (remaining as { issues?: unknown }).issues; - - return { - issues, - remainingOutput: Object.keys(remaining).length > 0 ? remaining : undefined, - }; - } - - // Check if output itself is a single issue - const singleIssue = this.normalizeIssue(record); - if (singleIssue) { - return { issues: [singleIssue], remainingOutput: undefined }; - } - } - - return null; + return sharedExtractIssues(output); } - /** - * Normalize an array of issues - */ private normalizeIssueArray(values: unknown[]): ReviewIssue[] | null { - const normalized: ReviewIssue[] = []; - - for (const value of values) { - const issue = this.normalizeIssue(value); - if (!issue) { - return null; - } - normalized.push(issue); - } - - return normalized; + return sharedNormalizeIssueArray(values, 'mcp'); } - /** - * Normalize a single issue - */ private normalizeIssue(raw: unknown): ReviewIssue | null { - if (!raw || typeof raw !== 'object') { - return null; - } - - const data = raw as Record; - - const message = this.toTrimmedString( - data.message || data.text || data.description || data.summary - ); - if (!message) { - return null; - } - - const allowedSeverities = new Set(['info', 'warning', 'error', 'critical']); - const severityRaw = this.toTrimmedString(data.severity || data.level || data.priority); - let severity: ReviewIssue['severity'] = 'warning'; - if (severityRaw) { - const lower = severityRaw.toLowerCase(); - if (allowedSeverities.has(lower)) { - severity = lower as ReviewIssue['severity']; - } - } - - const allowedCategories = new Set([ - 'security', - 'performance', - 'style', - 'logic', - 'documentation', - ]); - const categoryRaw = this.toTrimmedString(data.category || data.type || data.group); - let category: ReviewIssue['category'] = 'logic'; - if (categoryRaw && allowedCategories.has(categoryRaw.toLowerCase())) { - category = categoryRaw.toLowerCase() as ReviewIssue['category']; - } - - const file = this.toTrimmedString(data.file || data.path || data.filename) || 'system'; - const line = this.toNumber(data.line || data.startLine || data.lineNumber) ?? 0; - const endLine = this.toNumber(data.endLine || data.end_line || data.stopLine); - const suggestion = this.toTrimmedString(data.suggestion); - const replacement = this.toTrimmedString(data.replacement); - const ruleId = this.toTrimmedString(data.ruleId || data.rule || data.id || data.check) || 'mcp'; - - return { - file, - line, - endLine: endLine ?? undefined, - ruleId, - message, - severity, - category, - suggestion: suggestion || undefined, - replacement: replacement || undefined, - }; - } - - private toTrimmedString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - if (value !== null && value !== undefined && typeof value.toString === 'function') { - const converted = String(value).trim(); - return converted.length > 0 ? converted : null; - } - return null; - } - - private toNumber(value: unknown): number | null { - if (value === null || value === undefined) { - return null; - } - const num = Number(value); - if (Number.isFinite(num)) { - return Math.trunc(num); - } - return null; + return sharedNormalizeIssue(raw, 'mcp'); } getSupportedConfigKeys(): string[] { diff --git a/src/providers/mcp-custom-sse-server.ts b/src/providers/mcp-custom-sse-server.ts index f54651ca1..9207138c6 100644 --- a/src/providers/mcp-custom-sse-server.ts +++ b/src/providers/mcp-custom-sse-server.ts @@ -1146,7 +1146,7 @@ export class CustomToolsSSEServer implements CustomMCPServer { } /** - * Execute a UTCP tool — call via UTCP SDK using stored manual/variables. + * Execute a UTCP tool — delegates to UtcpCheckProvider.callTool() for shared lifecycle. */ private async executeUtcpTool( tool: CustomToolDefinition, @@ -1155,9 +1155,6 @@ export class CustomToolsSSEServer implements CustomMCPServer { ): Promise { const manual = tool.__utcpManual; const utcpToolName = tool.__utcpToolName || toolName; - const variables = tool.__utcpVariables || {}; - const plugins = tool.__utcpPlugins || ['http']; - const timeout = tool.timeout || 60000; if (!manual) { throw new Error(`UTCP tool '${toolName}' missing manual configuration`); @@ -1169,48 +1166,12 @@ export class CustomToolsSSEServer implements CustomMCPServer { ); } - // Dynamic import UTCP SDK and plugins - const { UtcpClient } = await import('@utcp/sdk'); - for (const plugin of plugins) { - try { - await import(`@utcp/${plugin}`); - } catch { - logger.debug( - `[CustomToolsSSEServer:${this.sessionId}] UTCP plugin @utcp/${plugin} not available` - ); - } - } - - // Resolve call template const { UtcpCheckProvider } = await import('./utcp-check-provider'); - const callTemplate = await UtcpCheckProvider.resolveManualCallTemplate(manual); - - // Create client - const client = await UtcpClient.create(process.cwd(), { - manual_call_templates: [callTemplate], - variables, - } as any); - - try { - // Call tool with timeout - const result = await Promise.race([ - client.callTool(utcpToolName, args as Record), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`UTCP tool '${utcpToolName}' timed out after ${timeout}ms`)), - timeout - ) - ), - ]); - - return result; - } finally { - try { - if (typeof (client as any).close === 'function') { - await (client as any).close(); - } - } catch {} - } + return UtcpCheckProvider.callTool(manual, utcpToolName, args, { + variables: tool.__utcpVariables || {}, + plugins: tool.__utcpPlugins || ['http'], + timeoutMs: tool.timeout || 60000, + }); } /** diff --git a/src/providers/utcp-check-provider.ts b/src/providers/utcp-check-provider.ts index fe7ee9dcc..07de846d6 100644 --- a/src/providers/utcp-check-provider.ts +++ b/src/providers/utcp-check-provider.ts @@ -7,6 +7,7 @@ import { createExtendedLiquid } from '../liquid-extensions'; import Sandbox from '@nyariv/sandboxjs'; import { createSecureSandbox, compileAndRun } from '../utils/sandbox'; import { EnvironmentResolver } from '../utils/env-resolver'; +import { extractIssuesFromOutput } from '../utils/issue-normalizer'; import * as fs from 'fs'; import * as path from 'path'; @@ -333,7 +334,7 @@ export class UtcpCheckProvider extends CheckProvider { } // Extract issues from output - const extracted = this.extractIssuesFromOutput(finalOutput); + const extracted = extractIssuesFromOutput(finalOutput, 'utcp'); if (extracted) { return { issues: extracted.issues, @@ -422,9 +423,29 @@ export class UtcpCheckProvider extends CheckProvider { // File-based discovery const resolvedPath = path.resolve(manual); - // First, read and check if the file is already a call template - const content = fs.readFileSync(resolvedPath, 'utf-8'); - const parsed = JSON.parse(content); + // Validate file exists and is readable + if (!fs.existsSync(resolvedPath)) { + throw new Error(`UTCP manual file not found: ${resolvedPath}`); + } + + // Read and parse the file + let content: string; + try { + content = fs.readFileSync(resolvedPath, 'utf-8'); + } catch (err) { + throw new Error( + `Failed to read UTCP manual file: ${resolvedPath}: ${err instanceof Error ? err.message : 'Unknown error'}` + ); + } + + let parsed: Record; + try { + parsed = JSON.parse(content); + } catch (err) { + throw new Error( + `Failed to parse UTCP manual file as JSON: ${resolvedPath}: ${err instanceof Error ? err.message : 'Unknown error'}` + ); + } if (parsed.call_template_type) { // File contains a call template directly - use as-is @@ -471,6 +492,65 @@ export class UtcpCheckProvider extends CheckProvider { } } + /** + * Call a UTCP tool directly. Shared by both the standalone provider and the MCP-bridge SSE server. + * Handles SDK import, plugin loading, client creation, tool calling, and cleanup. + */ + static async callTool( + manual: string | Record, + toolName: string, + args: Record, + options?: { + variables?: Record; + plugins?: string[]; + timeoutMs?: number; + } + ): Promise { + const variables = options?.variables || {}; + const plugins = options?.plugins || ['http']; + const timeoutMs = options?.timeoutMs || 60000; + + // Dynamic import UTCP SDK and plugins + const { UtcpClient } = await import('@utcp/sdk'); + for (const plugin of plugins) { + try { + await import(`@utcp/${plugin}`); + } catch { + logger.debug(`UTCP plugin @utcp/${plugin} not available`); + } + } + + // Resolve manual to call template + const callTemplate = await UtcpCheckProvider.resolveManualCallTemplate(manual); + + // Create client + const client = await UtcpClient.create(process.cwd(), { + manual_call_templates: [callTemplate], + variables, + } as any); + + try { + // Call tool with timeout + const result = await Promise.race([ + client.callTool(toolName, args as Record), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`UTCP tool '${toolName}' timed out after ${timeoutMs}ms`)), + timeoutMs + ) + ), + ]); + + return result; + } finally { + try { + if (typeof (client as any).close === 'function') { + await (client as any).close(); + } + } catch {} + } + } + /** * Check if an error is a timeout error */ @@ -512,163 +592,6 @@ export class UtcpCheckProvider extends CheckProvider { return safeVars; } - /** - * Extract issues from UTCP output - */ - private extractIssuesFromOutput( - output: unknown - ): { issues: ReviewIssue[]; remainingOutput: unknown } | null { - if (output === null || output === undefined) { - return null; - } - - // If output is a string, try to parse as JSON - if (typeof output === 'string') { - try { - const parsed = JSON.parse(output); - return this.extractIssuesFromOutput(parsed); - } catch { - return null; - } - } - - // If output is an array of issues - if (Array.isArray(output)) { - const issues = this.normalizeIssueArray(output); - if (issues) { - return { issues, remainingOutput: undefined }; - } - return null; - } - - // If output is an object with issues property - if (typeof output === 'object') { - const record = output as Record; - - if (Array.isArray(record.issues)) { - const issues = this.normalizeIssueArray(record.issues); - if (!issues) { - return null; - } - - const remaining = { ...record }; - delete (remaining as { issues?: unknown }).issues; - - return { - issues, - remainingOutput: Object.keys(remaining).length > 0 ? remaining : undefined, - }; - } - - // Check if output itself is a single issue - const singleIssue = this.normalizeIssue(record); - if (singleIssue) { - return { issues: [singleIssue], remainingOutput: undefined }; - } - } - - return null; - } - - /** - * Normalize an array of issues - */ - private normalizeIssueArray(values: unknown[]): ReviewIssue[] | null { - const normalized: ReviewIssue[] = []; - for (const value of values) { - const issue = this.normalizeIssue(value); - if (!issue) { - return null; - } - normalized.push(issue); - } - return normalized; - } - - /** - * Normalize a single issue - */ - private normalizeIssue(raw: unknown): ReviewIssue | null { - if (!raw || typeof raw !== 'object') { - return null; - } - - const data = raw as Record; - - const message = this.toTrimmedString( - data.message || data.text || data.description || data.summary - ); - if (!message) { - return null; - } - - const allowedSeverities = new Set(['info', 'warning', 'error', 'critical']); - const severityRaw = this.toTrimmedString(data.severity || data.level || data.priority); - let severity: ReviewIssue['severity'] = 'warning'; - if (severityRaw) { - const lower = severityRaw.toLowerCase(); - if (allowedSeverities.has(lower)) { - severity = lower as ReviewIssue['severity']; - } - } - - const allowedCategories = new Set([ - 'security', - 'performance', - 'style', - 'logic', - 'documentation', - ]); - const categoryRaw = this.toTrimmedString(data.category || data.type || data.group); - let category: ReviewIssue['category'] = 'logic'; - if (categoryRaw && allowedCategories.has(categoryRaw.toLowerCase())) { - category = categoryRaw.toLowerCase() as ReviewIssue['category']; - } - - const file = this.toTrimmedString(data.file || data.path || data.filename) || 'system'; - const line = this.toNumber(data.line || data.startLine || data.lineNumber) ?? 0; - const endLine = this.toNumber(data.endLine || data.end_line || data.stopLine); - const suggestion = this.toTrimmedString(data.suggestion); - const replacement = this.toTrimmedString(data.replacement); - const ruleId = - this.toTrimmedString(data.ruleId || data.rule || data.id || data.check) || 'utcp'; - - return { - file, - line, - endLine: endLine ?? undefined, - ruleId, - message, - severity, - category, - suggestion: suggestion || undefined, - replacement: replacement || undefined, - }; - } - - private toTrimmedString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - if (value !== null && value !== undefined && typeof value.toString === 'function') { - const converted = String(value).trim(); - return converted.length > 0 ? converted : null; - } - return null; - } - - private toNumber(value: unknown): number | null { - if (value === null || value === undefined) { - return null; - } - const num = Number(value); - if (Number.isFinite(num)) { - return Math.trunc(num); - } - return null; - } - getSupportedConfigKeys(): string[] { return [ 'type', diff --git a/src/utils/issue-normalizer.ts b/src/utils/issue-normalizer.ts new file mode 100644 index 000000000..2f50d4abc --- /dev/null +++ b/src/utils/issue-normalizer.ts @@ -0,0 +1,162 @@ +/** + * Shared issue normalization utilities. + * + * Used by MCP, UTCP, and command providers to extract and normalize + * ReviewIssue objects from tool/command output. + */ +import { ReviewIssue } from '../reviewer'; + +/** + * Extract issues from tool output. + * Handles: JSON strings, arrays of issues, objects with `issues` property, single issue objects. + */ +export function extractIssuesFromOutput( + output: unknown, + defaultRuleId?: string +): { issues: ReviewIssue[]; remainingOutput: unknown } | null { + if (output === null || output === undefined) { + return null; + } + + // If output is a string, try to parse as JSON + if (typeof output === 'string') { + try { + const parsed = JSON.parse(output); + return extractIssuesFromOutput(parsed, defaultRuleId); + } catch { + return null; + } + } + + // If output is an array of issues + if (Array.isArray(output)) { + const issues = normalizeIssueArray(output, defaultRuleId); + if (issues) { + return { issues, remainingOutput: undefined }; + } + return null; + } + + // If output is an object with issues property + if (typeof output === 'object') { + const record = output as Record; + + if (Array.isArray(record.issues)) { + const issues = normalizeIssueArray(record.issues, defaultRuleId); + if (!issues) { + return null; + } + + const remaining = { ...record }; + delete (remaining as { issues?: unknown }).issues; + + return { + issues, + remainingOutput: Object.keys(remaining).length > 0 ? remaining : undefined, + }; + } + + // Check if output itself is a single issue + const singleIssue = normalizeIssue(record, defaultRuleId); + if (singleIssue) { + return { issues: [singleIssue], remainingOutput: undefined }; + } + } + + return null; +} + +/** + * Normalize an array of issues. Returns null if any element cannot be normalized. + */ +export function normalizeIssueArray( + values: unknown[], + defaultRuleId?: string +): ReviewIssue[] | null { + const normalized: ReviewIssue[] = []; + for (const value of values) { + const issue = normalizeIssue(value, defaultRuleId); + if (!issue) { + return null; + } + normalized.push(issue); + } + return normalized; +} + +/** + * Normalize a single issue from raw data. + * Accepts various field aliases (message/text/description, severity/level/priority, etc.) + */ +export function normalizeIssue(raw: unknown, defaultRuleId = 'tool'): ReviewIssue | null { + if (!raw || typeof raw !== 'object') { + return null; + } + + const data = raw as Record; + + const message = toTrimmedString(data.message || data.text || data.description || data.summary); + if (!message) { + return null; + } + + const allowedSeverities = new Set(['info', 'warning', 'error', 'critical']); + const severityRaw = toTrimmedString(data.severity || data.level || data.priority); + let severity: ReviewIssue['severity'] = 'warning'; + if (severityRaw) { + const lower = severityRaw.toLowerCase(); + if (allowedSeverities.has(lower)) { + severity = lower as ReviewIssue['severity']; + } + } + + const allowedCategories = new Set(['security', 'performance', 'style', 'logic', 'documentation']); + const categoryRaw = toTrimmedString(data.category || data.type || data.group); + let category: ReviewIssue['category'] = 'logic'; + if (categoryRaw && allowedCategories.has(categoryRaw.toLowerCase())) { + category = categoryRaw.toLowerCase() as ReviewIssue['category']; + } + + const file = toTrimmedString(data.file || data.path || data.filename) || 'system'; + const line = toNumber(data.line || data.startLine || data.lineNumber) ?? 0; + const endLine = toNumber(data.endLine || data.end_line || data.stopLine); + const suggestion = toTrimmedString(data.suggestion); + const replacement = toTrimmedString(data.replacement); + const ruleId = + toTrimmedString(data.ruleId || data.rule || data.id || data.check) || defaultRuleId; + + return { + file, + line, + endLine: endLine ?? undefined, + ruleId, + message, + severity, + category, + suggestion: suggestion || undefined, + replacement: replacement || undefined, + }; +} + +export function toTrimmedString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (value !== null && value !== undefined && typeof value.toString === 'function') { + const converted = String(value).trim(); + return converted.length > 0 ? converted : null; + } + return null; +} + +export function toNumber(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + const num = Number(value); + if (Number.isFinite(num)) { + return Math.trunc(num); + } + return null; +} From 36dc0a4d600127b41c98e0b808ccc8bb492d4b98 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 13 Mar 2026 09:04:18 +0300 Subject: [PATCH 5/8] fix: normalize whitespace in CLI help test to handle Commander.js line wrapping The --fail-fast description wraps across lines at 80 columns, causing toContain to fail. Normalize whitespace before asserting. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/failure-conditions-workflow.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/failure-conditions-workflow.test.ts b/tests/e2e/failure-conditions-workflow.test.ts index 0e4ccf15e..53de49ade 100644 --- a/tests/e2e/failure-conditions-workflow.test.ts +++ b/tests/e2e/failure-conditions-workflow.test.ts @@ -93,7 +93,9 @@ function anotherFunction(param) { expect(mockConsoleLog).toHaveBeenCalled(); expect(helpOutput).toContain('Visor - AI-powered code review tool'); expect(helpOutput).toContain('--fail-fast'); - expect(helpOutput).toContain('Stop execution on first failure condition'); + // Normalize whitespace — Commander.js wraps long descriptions across lines + const normalized = helpOutput.replace(/\s+/g, ' '); + expect(normalized).toContain('Stop execution on first failure condition'); expect(mockProcessExit).toHaveBeenCalledWith(0); } finally { process.argv = originalArgv; From d911b46071d154ed389f6035e65ae51591d2f2ec Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 13 Mar 2026 13:00:15 +0300 Subject: [PATCH 6/8] fix: clean up Promise.race timeouts and transform error resource leaks - Clear timeout timer in Promise.race .finally() to prevent resource leaks when the tool call succeeds before the timeout fires - Throw from transform error handlers instead of returning early, so the outer finally block properly closes the UTCP client Co-Authored-By: Claude Opus 4.6 --- src/providers/utcp-check-provider.ts | 58 +++++++++++----------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/src/providers/utcp-check-provider.ts b/src/providers/utcp-check-provider.ts index 07de846d6..aec1040d0 100644 --- a/src/providers/utcp-check-provider.ts +++ b/src/providers/utcp-check-provider.ts @@ -255,16 +255,17 @@ export class UtcpCheckProvider extends CheckProvider { logger.debug(`Failed to list UTCP tools for name resolution: ${err}`); } - // Call tool with timeout + // Call tool with timeout (clear timer on success to avoid resource leak) + let timer: ReturnType | undefined; const result = await Promise.race([ client.callTool(toolName, methodArgs as Record), - new Promise((_, reject) => - setTimeout( + new Promise((_, reject) => { + timer = setTimeout( () => reject(new Error(`UTCP tool call timed out after ${cfg.timeout || 60}s`)), timeout - ) - ), - ]); + ); + }), + ]).finally(() => clearTimeout(timer)); // Apply transforms let finalOutput = result; @@ -284,18 +285,10 @@ export class UtcpCheckProvider extends CheckProvider { } } catch (error) { logger.error(`Failed to apply Liquid transform: ${error}`); - return { - issues: [ - { - file: 'utcp', - line: 0, - ruleId: 'utcp/transform_error', - message: `Failed to apply transform: ${error instanceof Error ? error.message : 'Unknown error'}`, - severity: 'error', - category: 'logic', - }, - ], - }; + // Throw to let the outer finally close the client before returning + throw new Error( + `Failed to apply transform: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } @@ -318,18 +311,10 @@ export class UtcpCheckProvider extends CheckProvider { ); } catch (error) { logger.error(`Failed to apply JavaScript transform: ${error}`); - return { - issues: [ - { - file: 'utcp', - line: 0, - ruleId: 'utcp/transform_js_error', - message: `Failed to apply JavaScript transform: ${error instanceof Error ? error.message : 'Unknown error'}`, - severity: 'error', - category: 'logic', - }, - ], - }; + // Throw to let the outer finally close the client before returning + throw new Error( + `Failed to apply JavaScript transform: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } @@ -530,16 +515,17 @@ export class UtcpCheckProvider extends CheckProvider { } as any); try { - // Call tool with timeout + // Call tool with timeout (clear timer on success to avoid resource leak) + let timer: ReturnType | undefined; const result = await Promise.race([ client.callTool(toolName, args as Record), - new Promise((_, reject) => - setTimeout( + new Promise((_, reject) => { + timer = setTimeout( () => reject(new Error(`UTCP tool '${toolName}' timed out after ${timeoutMs}ms`)), timeoutMs - ) - ), - ]); + ); + }), + ]).finally(() => clearTimeout(timer)); return result; } finally { From 03d2af2e721e868964ced1a0d99d0b9beedd5054 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 13 Mar 2026 21:02:25 +0300 Subject: [PATCH 7/8] fix: add path traversal protection to UTCP manual file resolution - Reject null bytes in file paths - Validate resolved path stays within cwd using normalized prefix check - Resolve symlinks and re-validate to prevent symlink-based traversal - URL and inline manuals are unaffected (handled by SDK, not read locally) Co-Authored-By: Claude Opus 4.6 --- src/providers/utcp-check-provider.ts | 27 +++++++++++++++++ tests/unit/providers/utcp-mcp-bridge.test.ts | 32 ++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/providers/utcp-check-provider.ts b/src/providers/utcp-check-provider.ts index aec1040d0..3977a55a4 100644 --- a/src/providers/utcp-check-provider.ts +++ b/src/providers/utcp-check-provider.ts @@ -406,8 +406,35 @@ export class UtcpCheckProvider extends CheckProvider { } // File-based discovery + + // Security: reject null bytes that could bypass path validation + if (manual.includes('\0')) { + throw new Error('Invalid UTCP manual path: null bytes are not allowed'); + } + const resolvedPath = path.resolve(manual); + // Security: ensure resolved path stays within cwd (prevent path traversal) + const cwd = path.resolve(process.cwd()); + const normalizedResolved = path.normalize(resolvedPath); + const cwdPrefix = cwd.endsWith(path.sep) ? cwd : cwd + path.sep; + if (normalizedResolved !== cwd && !normalizedResolved.startsWith(cwdPrefix)) { + throw new Error( + `Path traversal detected: "${manual}" resolves outside the project directory. ` + + `UTCP manual paths must be within the project directory.` + ); + } + + // Security: resolve symlinks and re-validate to prevent symlink attacks + if (fs.existsSync(resolvedPath)) { + const realPath = fs.realpathSync(resolvedPath); + if (realPath !== cwd && !realPath.startsWith(cwdPrefix)) { + throw new Error( + `Symlink traversal detected: "${manual}" points outside the project directory via symlink.` + ); + } + } + // Validate file exists and is readable if (!fs.existsSync(resolvedPath)) { throw new Error(`UTCP manual file not found: ${resolvedPath}`); diff --git a/tests/unit/providers/utcp-mcp-bridge.test.ts b/tests/unit/providers/utcp-mcp-bridge.test.ts index 8d2be4104..9e6278134 100644 --- a/tests/unit/providers/utcp-mcp-bridge.test.ts +++ b/tests/unit/providers/utcp-mcp-bridge.test.ts @@ -132,6 +132,38 @@ describe('UTCP-MCP Bridge', () => { }); }); + describe('resolveManualCallTemplate path traversal protection', () => { + it('should reject paths with null bytes', async () => { + const { UtcpCheckProvider } = await import('../../../src/providers/utcp-check-provider'); + await expect(UtcpCheckProvider.resolveManualCallTemplate('manual\x00.json')).rejects.toThrow( + 'null bytes are not allowed' + ); + }); + + it('should reject paths that traverse outside cwd', async () => { + const { UtcpCheckProvider } = await import('../../../src/providers/utcp-check-provider'); + await expect( + UtcpCheckProvider.resolveManualCallTemplate('../../../etc/passwd') + ).rejects.toThrow('Path traversal detected'); + }); + + it('should reject absolute paths outside cwd', async () => { + const { UtcpCheckProvider } = await import('../../../src/providers/utcp-check-provider'); + await expect(UtcpCheckProvider.resolveManualCallTemplate('/etc/passwd')).rejects.toThrow( + 'Path traversal detected' + ); + }); + + it('should allow URL-based manuals without path checks', async () => { + const { UtcpCheckProvider } = await import('../../../src/providers/utcp-check-provider'); + const result = await UtcpCheckProvider.resolveManualCallTemplate( + 'https://example.com/../../../etc/passwd' + ); + // URL-based manuals are handled by the SDK, not read locally + expect(result.call_template_type).toBe('http'); + }); + }); + describe('UTCP tool creation for CustomToolDefinition', () => { it('should create CustomToolDefinition with UTCP fields', () => { const utcpTool: CustomToolDefinition = { From 9e685fdc6546c9c1508a1980c4b32ad64b38ad9f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 13 Mar 2026 21:05:27 +0300 Subject: [PATCH 8/8] refactor: deduplicate UTCP client lifecycle into static callTool method The instance execute() method now delegates to the static callTool() for SDK import, plugin loading, client creation, tool calling with timeout, and cleanup. This eliminates the duplicated lifecycle logic and makes callTool the single entry point for both the standalone provider and the MCP-bridge SSE server. Also moved tool name suffix matching into callTool so it works for both code paths, and removed dead instance wrapper methods. Co-Authored-By: Claude Opus 4.6 --- src/providers/utcp-check-provider.ts | 107 ++++++++------------------- 1 file changed, 29 insertions(+), 78 deletions(-) diff --git a/src/providers/utcp-check-provider.ts b/src/providers/utcp-check-provider.ts index 3977a55a4..dbaccd6c2 100644 --- a/src/providers/utcp-check-provider.ts +++ b/src/providers/utcp-check-provider.ts @@ -202,9 +202,6 @@ export class UtcpCheckProvider extends CheckProvider { methodArgs = (await renderValue(methodArgs)) as Record; } - // Resolve manual to a call template - const callTemplate = await this.resolveManualCallTemplate(cfg.manual); - // Resolve variables through environment resolver const resolvedVariables: Record = {}; if (cfg.variables) { @@ -213,60 +210,14 @@ export class UtcpCheckProvider extends CheckProvider { } } - // Dynamic import UTCP SDK - const { UtcpClient } = await import('@utcp/sdk'); - - // Load plugins - const plugins = cfg.plugins || ['http']; - for (const plugin of plugins) { - try { - await import(`@utcp/${plugin}`); - } catch (err) { - logger.debug(`UTCP plugin @utcp/${plugin} not available: ${err}`); - } - } - - // Create UTCP client - const timeout = (cfg.timeout || 60) * 1000; - const client = await UtcpClient.create(process.cwd(), { - manual_call_templates: [callTemplate], + // Call tool via shared static method (handles SDK import, client lifecycle, timeout) + const result = await UtcpCheckProvider.callTool(cfg.manual, cfg.method, methodArgs, { variables: resolvedVariables, - } as any); - - try { - // Resolve tool name - try exact match first, then suffix match - let toolName = cfg.method; - try { - const tools = await client.getTools(); - const toolNames = tools.map((t: any) => t.name as string); - logger.debug(`UTCP tools available: ${JSON.stringify(toolNames)}`); - - if (!toolNames.includes(toolName)) { - // Try suffix match: user may specify "get_ip" but tool is "manual_name.get_ip" - const suffixMatch = toolNames.find((name: string) => name.endsWith(`.${toolName}`)); - if (suffixMatch) { - logger.debug( - `UTCP method '${toolName}' resolved to '${suffixMatch}' via suffix match` - ); - toolName = suffixMatch; - } - } - } catch (err) { - logger.debug(`Failed to list UTCP tools for name resolution: ${err}`); - } - - // Call tool with timeout (clear timer on success to avoid resource leak) - let timer: ReturnType | undefined; - const result = await Promise.race([ - client.callTool(toolName, methodArgs as Record), - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`UTCP tool call timed out after ${cfg.timeout || 60}s`)), - timeout - ); - }), - ]).finally(() => clearTimeout(timer)); + plugins: cfg.plugins || ['http'], + timeoutMs: (cfg.timeout || 60) * 1000, + }); + { // Apply transforms let finalOutput = result; @@ -332,12 +283,6 @@ export class UtcpCheckProvider extends CheckProvider { issues: [], ...(finalOutput ? { output: finalOutput } : {}), } as ReviewSummary; - } finally { - try { - await client.close(); - } catch (err) { - logger.debug(`Failed to close UTCP client: ${err}`); - } } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -368,15 +313,6 @@ export class UtcpCheckProvider extends CheckProvider { } } - /** - * Resolve manual config to a UTCP call template object (instance method, delegates to static) - */ - private async resolveManualCallTemplate( - manual: string | Record - ): Promise> { - return UtcpCheckProvider.resolveManualCallTemplate(manual); - } - /** * Resolve manual config to a UTCP call template object. * Shared utility used by both the standalone UTCP provider and the AI check provider's UTCP-to-MCP bridge. @@ -483,13 +419,6 @@ export class UtcpCheckProvider extends CheckProvider { }; } - /** - * Derive a manual name from a URL (instance method, delegates to static) - */ - private deriveManualName(url: string): string { - return UtcpCheckProvider.deriveManualName(url); - } - /** * Derive a manual name from a URL. * Shared utility for UTCP manual name derivation. @@ -542,10 +471,32 @@ export class UtcpCheckProvider extends CheckProvider { } as any); try { + // Resolve tool name - try exact match first, then suffix match + let resolvedToolName = toolName; + try { + const tools = await client.getTools(); + const toolNames = tools.map((t: any) => t.name as string); + logger.debug(`UTCP tools available: ${JSON.stringify(toolNames)}`); + + if (!toolNames.includes(resolvedToolName)) { + const suffixMatch = toolNames.find((name: string) => + name.endsWith(`.${resolvedToolName}`) + ); + if (suffixMatch) { + logger.debug( + `UTCP method '${resolvedToolName}' resolved to '${suffixMatch}' via suffix match` + ); + resolvedToolName = suffixMatch; + } + } + } catch (err) { + logger.debug(`Failed to list UTCP tools for name resolution: ${err}`); + } + // Call tool with timeout (clear timer on success to avoid resource leak) let timer: ReturnType | undefined; const result = await Promise.race([ - client.callTool(toolName, args as Record), + client.callTool(resolvedToolName, args as Record), new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`UTCP tool '${toolName}' timed out after ${timeoutMs}ms`)),