feat: add lyzr-tools plugin bridging Lyzr-authorized tools into GitAgent - #78
feat: add lyzr-tools plugin bridging Lyzr-authorized tools into GitAgent#78akshatkumar2808 wants to merge 1 commit into
Conversation
GitAgent's LYZR_API_KEY was only wired into the model path, so users with Gmail/Slack/etc. already authorized in Lyzr still had to configure separate local credentials (e.g. local Gmail SMTP app passwords) for tool execution. Adds a lyzr-tools plugin that: - Discovers authorized provider actions (GET /v3/providers/tools/actions/*) and MCP server tools (/v3/tools/mcp/servers*), cross-referenced against connected-account status (/v3/tools/credentials/connected_accounts). - Registers each as a lyzr_-prefixed gitagent tool, avoiding collisions with local skills by construction. - Proxies execution through /v3/inference/tools/execute or /v3/tools/mcp/tools/execute, normalizing results into success / authorization_required / error, with secret redaction on all details. - Adds prompt guidance preferring lyzr_ tools over local duplicates (e.g. the bundled gmail-email skill) when both exist. Enabled by default in agent.yaml; no-ops with a single warning if LYZR_API_KEY isn't set, so it never makes a network call without a key. See docs/lyzr-tool-auth-rca.md for the RCA and design this implements, and docs/lyzr-tool-bridge-test-cases.md for the acceptance criteria covered by test/lyzr-tools.test.ts (32 tests, no real network calls). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
shreyas-lyzr
left a comment
There was a problem hiding this comment.
Four issues worth addressing before merge — one is a confirmed functional break on the default config, one is a silent misconfiguration that yields a broken user experience with no log output to help diagnose it, one is a schema normalization gap that silently drops valid tool parameters, and one is a key-based redaction limitation that can let raw credential strings through to model context. Inline comments below.
| ): Promise<void> { | ||
| for (const providerId of config.providers) { | ||
| stats.providersQueried++; | ||
| const res = await client.listProviderActions(providerId); |
There was a problem hiding this comment.
tool_source is never passed to listProviderActions. The client signature accepts opts?: { toolSource?: string; appId?: string } but this call site passes no second argument, so tool_source is always omitted from the query string.
The Swagger spec for GET /v3/providers/tools/actions/{provider_identifier} treats tool_source as a required disambiguator when provider_identifier is a plain provider name (e.g. "gmail", "slack") rather than a provider_uuid/ObjectId. Without it the backend returns 400, the error is pushed to stats.errors and logged as a warn, but the plugin loads silently with zero tools registered. The default providers: "gmail,slack" in plugin.yaml hits this path exactly.
Fix: thread toolSource through ResolvedConfig (it's already an optional field in plugin.yaml's config block) and pass it here:
| const res = await client.listProviderActions(providerId); | |
| const res = await client.listProviderActions(providerId, { toolSource: config.toolSource }); |
Or, if tool_source should be per-provider rather than global, make config.providers a { id: string; toolSource?: string }[] array.
| logger: Logger, | ||
| ): Promise<Map<string, ConnectedAccount>> { | ||
| const map = new Map<string, ConnectedAccount>(); | ||
| if (!config.userId) return map; |
There was a problem hiding this comment.
When config.userId is absent, fetchConnectedAccounts returns an empty map with no log output. discoverProviderActions then finds no connected account for any provider, marks every tool authorized: false, and the plugin loads with a full tool list where every call returns authorization_required.
With the example agent.yaml config (which only sets api_key), a user who omits user_id sees every tool fail immediately with no indication of why. A single warn log before the early return would make this diagnosable in under a minute:
| if (!config.userId) return map; | |
| if (!config.userId) { | |
| logger.warn("lyzr-tools: user_id is not configured; all provider tools will be marked unauthorized. Set user_id in agent.yaml or LYZR_USER_ID."); | |
| return map; | |
| } |
| return []; | ||
| } | ||
|
|
||
| function normalizeInputSchema(schema: unknown): { properties: Record<string, any>; required?: string[] } { |
There was a problem hiding this comment.
normalizeInputSchema only passes through schemas that are plain objects with a properties key. If Lyzr returns an action schema in OpenAI function-calling style — where parameters is an array of { name, type, description } objects, which is a common ACI format — that array fails the "properties" in schema check and is silently replaced with { properties: {} }.
The tool registers with no known parameters; the model has to guess argument names, which causes silent failures for any required fields.
At minimum, handle the array case by converting it to a JSON Schema object shape:
| function normalizeInputSchema(schema: unknown): { properties: Record<string, any>; required?: string[] } { | |
| function normalizeInputSchema(schema: unknown): { properties: Record<string, any>; required?: string[] } { | |
| if (Array.isArray(schema)) { | |
| // OpenAI/ACI-style: [{name, type, description, required?}] | |
| const properties: Record<string, any> = {}; | |
| const required: string[] = []; | |
| for (const p of schema as Array<Record<string, any>>) { | |
| if (!p.name) continue; | |
| properties[p.name] = { type: p.type ?? "string", description: p.description ?? "" }; | |
| if (p.required) required.push(p.name); | |
| } | |
| return required.length ? { properties, required } : { properties }; | |
| } | |
| if (schema && typeof schema === "object" && "properties" in (schema as Record<string, unknown>)) { | |
| return schema as { properties: Record<string, any>; required?: string[] }; | |
| } | |
| return { properties: {} }; | |
| } |
| // Only values whose *key* looks sensitive are masked. We deliberately do not | ||
| // try to pattern-match "secret-looking" strings by shape, because that would | ||
| // also mangle legitimate tool output (email bodies, message text, etc.) — | ||
| // see docs/lyzr-tool-bridge-test-cases.md TC-D03. |
There was a problem hiding this comment.
The comment accurately describes the design, but it understates the risk for users. Key-based redaction works well when sensitive data lives in a recognisably-named field. However, if Lyzr's execution API returns a raw OAuth token or credential blob as a plain string — e.g. data.result = "ya29.A0ARrd…" — the field key is result, which does not match SENSITIVE_KEY_RE. The value passes through unredacted, appears as the tool's text output, and lands directly in model context.
This isn't hypothetical: some OAuth provider responses embed access tokens at the top level of their result body.
Worth adding to the known-limitations note in README.md that Lyzr's execution backend must not return raw credentials in the result field, and optionally adding a shape-based heuristic here (e.g. flag strings that are long, high-entropy base64 and don't match human-readable output) or a specific blocklist check on the result key value regardless of key name.
| @@ -11,4 +11,12 @@ tools: | |||
| - write | |||
| - memory | |||
There was a problem hiding this comment.
The file is missing a trailing newline (the diff shows \ No newline at end of file on the last line). Minor, but it breaks POSIX compliance and produces noisy diffs.
Summary
lyzr-toolsplugin that discovers Lyzr-authorized tools (Gmail, Slack, MCP servers) and proxies execution through Lyzr instead of requiring separate local credentialslyzr_-prefixed GitAgent tools; unauthorized tools return a structuredauthorization_requiredresult instead of prompting for local credentialsdetails; adds prompt guidance preferringlyzr_*tools over local duplicate skillsagent.yaml; no-ops with a warning ifLYZR_API_KEYisn't set (no network call without a key)See
docs/lyzr-tool-auth-rca.mdfor the RCA/design anddocs/lyzr-tool-bridge-test-cases.mdfor the acceptance criteria this targets.Test plan
npm run build && npm test— 59/59 passing (32 new intest/lyzr-tools.test.ts, no real network calls)