diff --git a/CLAUDE.md b/CLAUDE.md index 4584b9aa..ecef062d 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); 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 8. **Multiple Output Formats**: table, json, markdown, sarif diff --git a/README.md b/README.md index 07bec0d9..4bc6aa4b 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. @@ -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. @@ -241,7 +242,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 +261,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 +780,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/ai-configuration.md b/docs/ai-configuration.md index c865cc3c..8c9d318b 100644 --- a/docs/ai-configuration.md +++ b/docs/ai-configuration.md @@ -757,6 +757,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 05959a26..8e347b65 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/architecture.md b/docs/architecture.md index 052c921f..db2252a9 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 89efcf1f..f4d9bb23 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 cdc66f63..f87d3182 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,7 +33,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). @@ -299,6 +299,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. 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 ### Validate Command diff --git a/docs/guides/build-ai-agent.md b/docs/guides/build-ai-agent.md index f4420722..fcd6bc97 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 e6a9843f..5ec14600 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -144,14 +144,33 @@ 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). + #### Built-in MCP Tools Visor's MCP SSE server automatically exposes a `graceful_stop` tool alongside any custom workflow tools. This tool is called by Probe when a [negotiated timeout](./timeouts.md#negotiated-timeout) observer declines an extension. It signals all active sub-workflow executions to wind down gracefully by shortening the shared execution deadline and notifying running ProbeAgent sessions. You do not need to configure `graceful_stop` — it is always available. See [Timeouts: Negotiated Timeout](./timeouts.md#negotiated-timeout) for details. - #### 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 - [Timeouts](./timeouts.md) - Timeout configuration including negotiated timeout and `graceful_stop` diff --git a/docs/migration.md b/docs/migration.md index 69a423c4..0c80d508 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); also usable as `ai_mcp_servers` entry with `type: utcp` | ### Provider Type Renames diff --git a/docs/pluggable.md b/docs/pluggable.md index db0f0a39..71415116 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,33 @@ 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: ',' }}" +``` + +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`) 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 00000000..9e1a94bf --- /dev/null +++ b/docs/utcp-provider.md @@ -0,0 +1,343 @@ +# 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 }}" +``` + +## 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" +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) +- [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 00000000..18889b2e --- /dev/null +++ b/examples/utcp-provider-example.yaml @@ -0,0 +1,290 @@ +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 + })); + +# ------------------------------------------------------------------- +# 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/package-lock.json b/package-lock.json index 642fba54..88e6ad3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "@probelabs/probe": "0.6.0-rc296", "@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 } } }, @@ -7675,6 +7685,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 59e52e36..66ec0eb6 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,8 @@ "@probelabs/probe": "0.6.0-rc296", "@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 5872a516..82d383eb 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 fd948b3a..3378d2fd 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')", @@ -1046,6 +1072,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 +1119,7 @@ export const configSchema = { description: 'Arguments/inputs for the workflow', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15510-30196-src_types_config.ts-0-58763%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-59584%3E%3E', description: 'Override specific step configurations in the workflow', }, output_mapping: { @@ -1088,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-15510-30196-src_types_config.ts-0-58763%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-59584%3E%3E', description: 'Alias for overrides - workflow step overrides (backward compatibility)', }, ref: { @@ -1192,6 +1240,7 @@ export const configSchema = { 'workflow', 'git-checkout', 'a2a', + 'utcp', ], description: 'Valid check types in configuration', }, @@ -1831,7 +1880,7 @@ export const configSchema = { description: 'Custom output name (defaults to workflow name)', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15510-30196-src_types_config.ts-0-58763%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-59584%3E%3E', description: 'Step overrides', }, output_mapping: { @@ -1846,14 +1895,14 @@ export const configSchema = { '^x-': {}, }, }, - 'Record>': + 'Record>': { type: 'object', additionalProperties: { - $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-15510-30196-src_types_config.ts-0-58763%3E', + $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-59584%3E', }, }, - 'Partial': { + 'Partial': { type: 'object', additionalProperties: false, }, diff --git a/src/providers/ai-check-provider.ts b/src/providers/ai-check-provider.ts index 30e9fe02..05780905 100644 --- a/src/providers/ai-check-provider.ts +++ b/src/providers/ai-check-provider.ts @@ -1290,14 +1290,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)) { @@ -1322,6 +1324,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); @@ -1495,6 +1504,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/check-provider-registry.ts b/src/providers/check-provider-registry.ts index 2841bbd8..64ba742b 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 82205381..9a041390 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/mcp-check-provider.ts b/src/providers/mcp-check-provider.ts index 80ea0214..4dc1588f 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,161 +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; - - // Only accept string values for issue message fields. - // Non-string values (e.g. Slack's `message: {text: "...", ...}` object) - // must not be coerced — they are API payloads, not issue descriptions. - const rawMessage = data.message || data.text || data.description || data.summary; - if (typeof rawMessage !== 'string') { - return null; - } - const message = rawMessage.trim(); - 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 183facd4..3f9c7098 100644 --- a/src/providers/mcp-custom-sse-server.ts +++ b/src/providers/mcp-custom-sse-server.ts @@ -32,6 +32,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 */ @@ -179,10 +186,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); } @@ -791,7 +798,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]; // Add graceful_stop tool for cooperative shutdown signaling allTools.push({ @@ -1061,6 +1075,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); @@ -1297,6 +1314,35 @@ export class CustomToolsSSEServer implements CustomMCPServer { } } + /** + * Execute a UTCP tool — delegates to UtcpCheckProvider.callTool() for shared lifecycle. + */ + private async executeUtcpTool( + tool: CustomToolDefinition, + toolName: string, + args: Record + ): Promise { + const manual = tool.__utcpManual; + const utcpToolName = tool.__utcpToolName || toolName; + + 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)}` + ); + } + + const { UtcpCheckProvider } = await import('./utcp-check-provider'); + return UtcpCheckProvider.callTool(manual, utcpToolName, args, { + variables: tool.__utcpVariables || {}, + plugins: tool.__utcpPlugins || ['http'], + timeoutMs: tool.timeout || 60000, + }); + } + /** * 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 new file mode 100644 index 00000000..dbaccd6c --- /dev/null +++ b/src/providers/utcp-check-provider.ts @@ -0,0 +1,598 @@ +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 { extractIssuesFromOutput } from '../utils/issue-normalizer'; +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 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)); + } + } + + // Call tool via shared static method (handles SDK import, client lifecycle, timeout) + const result = await UtcpCheckProvider.callTool(cfg.manual, cfg.method, methodArgs, { + variables: resolvedVariables, + plugins: cfg.plugins || ['http'], + timeoutMs: (cfg.timeout || 60) * 1000, + }); + + { + // 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}`); + // 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'}` + ); + } + } + + // 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}`); + // 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'}` + ); + } + } + + // Extract issues from output + const extracted = extractIssuesFromOutput(finalOutput, 'utcp'); + if (extracted) { + return { + issues: extracted.issues, + ...(extracted.remainingOutput ? { output: extracted.remainingOutput } : {}), + } as ReviewSummary; + } + + // Return output directly + return { + issues: [], + ...(finalOutput ? { output: finalOutput } : {}), + } as ReviewSummary; + } + } 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. + * 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) { + 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: UtcpCheckProvider.deriveManualName(manual), + call_template_type: 'http', + url: manual, + http_method: 'GET', + }; + } + + // 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}`); + } + + // 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 + 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. + * 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 + return parsed.hostname.replace(/\./g, '_').replace(/-/g, '_'); + } catch { + return 'utcp_manual'; + } + } + + /** + * 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 { + // 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(resolvedToolName, args as Record), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`UTCP tool '${toolName}' timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }), + ]).finally(() => clearTimeout(timer)); + + 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 + */ + 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; + } + + 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 507de95a..1cd92645 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 @@ -763,6 +764,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'). */ @@ -1223,7 +1233,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 +1312,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/src/utils/issue-normalizer.ts b/src/utils/issue-normalizer.ts new file mode 100644 index 00000000..390ea159 --- /dev/null +++ b/src/utils/issue-normalizer.ts @@ -0,0 +1,169 @@ +/** + * 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; + + // Only accept string values for issue message fields. + // Non-string values (e.g. Slack's `message: {text: "...", ...}` object) + // must not be coerced — they are API payloads, not issue descriptions. + const rawMessage = data.message || data.text || data.description || data.summary; + if (typeof rawMessage !== 'string') { + return null; + } + const message = rawMessage.trim(); + 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; +} diff --git a/tests/e2e/failure-conditions-workflow.test.ts b/tests/e2e/failure-conditions-workflow.test.ts index 624dd086..53de49ad 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'); + // 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; diff --git a/tests/unit/providers/check-provider-registry.test.ts b/tests/unit/providers/check-provider-registry.test.ts index 87f916f9..4724f2f7 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 00000000..3cb0b8c1 --- /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); + }); + }); +}); 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 00000000..9e627813 --- /dev/null +++ b/tests/unit/providers/utcp-mcp-bridge.test.ts @@ -0,0 +1,307 @@ +/** + * 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('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 = { + 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'); + }); + }); +});