fix(tools): return structured error objects from skill tool validation failures
Convert Error throws and plain-string returns in skill-mcp and call-omo-agent tools into structured objects with { output, metadata: { kind } }.
call-omo-agent uses 'unsupported_agents_action' kind for agent validation errors.
skill-mcp uses 'unsupported_mcp_action' kind for MCP operation validation errors.
This enables callers to distinguish error responses from successful ones by checking metadata.kind rather than parsing error strings.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -87,7 +87,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("disabled via disabled_agents")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("disabled via disabled_agents")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
|
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
|
||||||
@@ -100,7 +101,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("disabled via disabled_agents")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("disabled via disabled_agents")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should allow agent not in disabled_agents list", async () => {
|
test("should allow agent not in disabled_agents list", async () => {
|
||||||
@@ -141,7 +143,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("subagent_type is required")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("subagent_type is required")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should reject general even when returned by client.app.agents()", async () => {
|
test("should reject general even when returned by client.app.agents()", async () => {
|
||||||
@@ -155,8 +158,10 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("Invalid agent type")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
expect(result).toContain("Only explore, librarian are allowed")
|
const output = typeof result === "object" ? result.output : result
|
||||||
|
expect(output).toContain("Invalid agent type")
|
||||||
|
expect(output).toContain("Only explore, librarian are allowed")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should reject unknown non-allowed agents", async () => {
|
test("should reject unknown non-allowed agents", async () => {
|
||||||
@@ -169,7 +174,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("Invalid agent type")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("Invalid agent type")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should perform case-insensitive matching for allowed agents", async () => {
|
test("should perform case-insensitive matching for allowed agents", async () => {
|
||||||
@@ -182,7 +188,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).not.toContain("Invalid agent type")
|
expect(typeof result === "object" ? result.metadata?.kind : null).not.toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).not.toContain("Invalid agent type")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should exclude primary-mode agents from callable list", async () => {
|
test("should exclude primary-mode agents from callable list", async () => {
|
||||||
@@ -199,7 +206,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("Invalid agent type")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("Invalid agent type")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should fall back to ALLOWED_AGENTS when client.app.agents() fails", async () => {
|
test("should fall back to ALLOWED_AGENTS when client.app.agents() fails", async () => {
|
||||||
@@ -212,7 +220,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).not.toContain("Invalid agent type")
|
expect(typeof result === "object" ? result.metadata?.kind : null).not.toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).not.toContain("Invalid agent type")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should reject unknown agent even when client.app.agents() fails (fallback mode)", async () => {
|
test("should reject unknown agent even when client.app.agents() fails (fallback mode)", async () => {
|
||||||
@@ -225,7 +234,8 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("Invalid agent type")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("Invalid agent type")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should reject non-allowed agents before disabled_agents can make them appear callable", async () => {
|
test("should reject non-allowed agents before disabled_agents can make them appear callable", async () => {
|
||||||
@@ -239,8 +249,10 @@ describe("createCallOmoAgent", () => {
|
|||||||
toolCtx
|
toolCtx
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("Invalid agent type")
|
expect(typeof result === "object" ? result.metadata?.kind : null).toBe("unsupported_agents_action")
|
||||||
expect(result).not.toContain("disabled via disabled_agents")
|
const output = typeof result === "object" ? result.output : result
|
||||||
|
expect(output).toContain("Invalid agent type")
|
||||||
|
expect(output).not.toContain("disabled via disabled_agents")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ export function createCallOmoAgent(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (typeof args.subagent_type !== "string" || args.subagent_type.trim() === "") {
|
if (typeof args.subagent_type !== "string" || args.subagent_type.trim() === "") {
|
||||||
return "Error: subagent_type is required."
|
return {
|
||||||
|
output: "Error: subagent_type is required.",
|
||||||
|
metadata: { kind: "unsupported_agents_action" },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const callableAgents = await resolveCallableAgents(ctx.client);
|
const callableAgents = await resolveCallableAgents(ctx.client);
|
||||||
@@ -153,7 +156,10 @@ export function createCallOmoAgent(
|
|||||||
(name) => name.toLowerCase() === strippedAgentType.toLowerCase(),
|
(name) => name.toLowerCase() === strippedAgentType.toLowerCase(),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return `Error: Invalid agent type "${args.subagent_type}". Only ${callableAgents.join(", ")} are allowed.`;
|
return {
|
||||||
|
output: `Error: Invalid agent type "${args.subagent_type}". Only ${callableAgents.join(", ")} are allowed.`,
|
||||||
|
metadata: { kind: "unsupported_agents_action" },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedAgent = strippedAgentType.toLowerCase();
|
const normalizedAgent = strippedAgentType.toLowerCase();
|
||||||
@@ -161,7 +167,10 @@ export function createCallOmoAgent(
|
|||||||
|
|
||||||
// Check if agent is disabled
|
// Check if agent is disabled
|
||||||
if (disabledAgents.some((disabled) => stripInvisibleAgentCharacters(disabled).toLowerCase() === normalizedAgent)) {
|
if (disabledAgents.some((disabled) => stripInvisibleAgentCharacters(disabled).toLowerCase() === normalizedAgent)) {
|
||||||
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your ${CONFIG_BASENAME}.json to use it.`
|
return {
|
||||||
|
output: `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your ${CONFIG_BASENAME}.json to use it.`,
|
||||||
|
metadata: { kind: "unsupported_agents_action" },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { model: resolvedModel, fallbackChain } = resolveModelAndFallbackChain({
|
const { model: resolvedModel, fallbackChain } = resolveModelAndFallbackChain({
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("skill_mcp tool", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("parameter validation", () => {
|
describe("parameter validation", () => {
|
||||||
it("throws when no operation specified", async () => {
|
it("returns unsupported_mcp_action when no operation specified", async () => {
|
||||||
// given
|
// given
|
||||||
const tool = createSkillMcpTool({
|
const tool = createSkillMcpTool({
|
||||||
manager,
|
manager,
|
||||||
@@ -50,13 +50,15 @@ describe("skill_mcp tool", () => {
|
|||||||
getSessionID: () => sessionID,
|
getSessionID: () => sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
// when / #then
|
// when
|
||||||
await expect(
|
const result = await tool.execute({ mcp_name: "test-server" }, mockContext)
|
||||||
tool.execute({ mcp_name: "test-server" }, mockContext)
|
|
||||||
).rejects.toThrow(/Missing operation/)
|
// then
|
||||||
|
expect(typeof result === "object" && result.metadata?.kind).toBe("unsupported_mcp_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("Missing operation")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws when multiple operations specified", async () => {
|
it("returns unsupported_mcp_action when multiple operations specified", async () => {
|
||||||
// given
|
// given
|
||||||
const tool = createSkillMcpTool({
|
const tool = createSkillMcpTool({
|
||||||
manager,
|
manager,
|
||||||
@@ -64,17 +66,19 @@ describe("skill_mcp tool", () => {
|
|||||||
getSessionID: () => sessionID,
|
getSessionID: () => sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
// when / #then
|
// when
|
||||||
await expect(
|
const result = await tool.execute({
|
||||||
tool.execute({
|
mcp_name: "test-server",
|
||||||
mcp_name: "test-server",
|
tool_name: "some-tool",
|
||||||
tool_name: "some-tool",
|
resource_name: "some://resource",
|
||||||
resource_name: "some://resource",
|
}, mockContext)
|
||||||
}, mockContext)
|
|
||||||
).rejects.toThrow(/Multiple operations/)
|
// then
|
||||||
|
expect(typeof result === "object" && result.metadata?.kind).toBe("unsupported_mcp_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("Multiple operations")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws when mcp_name not found in any skill", async () => {
|
it("returns unsupported_mcp_action when mcp_name not found in any skill", async () => {
|
||||||
// given
|
// given
|
||||||
loadedSkills = [
|
loadedSkills = [
|
||||||
createMockSkillWithMcp("test-skill", {
|
createMockSkillWithMcp("test-skill", {
|
||||||
@@ -87,13 +91,15 @@ describe("skill_mcp tool", () => {
|
|||||||
getSessionID: () => sessionID,
|
getSessionID: () => sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
// when / #then
|
// when
|
||||||
await expect(
|
const result = await tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext)
|
||||||
tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext)
|
|
||||||
).rejects.toThrow(/not found/)
|
// then
|
||||||
|
expect(typeof result === "object" && result.metadata?.kind).toBe("unsupported_mcp_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("includes available MCP servers in error message", async () => {
|
it("includes available MCP servers in unsupported_mcp_action response", async () => {
|
||||||
// given
|
// given
|
||||||
loadedSkills = [
|
loadedSkills = [
|
||||||
createMockSkillWithMcp("db-skill", {
|
createMockSkillWithMcp("db-skill", {
|
||||||
@@ -109,13 +115,16 @@ describe("skill_mcp tool", () => {
|
|||||||
getSessionID: () => sessionID,
|
getSessionID: () => sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
// when / #then
|
// when
|
||||||
await expect(
|
const result = await tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext)
|
||||||
tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext)
|
|
||||||
).rejects.toThrow(/sqlite.*db-skill|rest-api.*api-skill/s)
|
// then
|
||||||
|
expect(typeof result === "object" && result.metadata?.kind).toBe("unsupported_mcp_action")
|
||||||
|
const output = typeof result === "object" ? result.output : result
|
||||||
|
expect(output).toMatch(/sqlite.*db-skill|rest-api.*api-skill/s)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws on invalid JSON arguments", async () => {
|
it("returns unsupported_mcp_action on invalid JSON arguments", async () => {
|
||||||
// given
|
// given
|
||||||
loadedSkills = [
|
loadedSkills = [
|
||||||
createMockSkillWithMcp("test-skill", {
|
createMockSkillWithMcp("test-skill", {
|
||||||
@@ -128,14 +137,16 @@ describe("skill_mcp tool", () => {
|
|||||||
getSessionID: () => sessionID,
|
getSessionID: () => sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
// when / #then
|
// when
|
||||||
await expect(
|
const result = await tool.execute({
|
||||||
tool.execute({
|
mcp_name: "test-server",
|
||||||
mcp_name: "test-server",
|
tool_name: "some-tool",
|
||||||
tool_name: "some-tool",
|
arguments: "not valid json",
|
||||||
arguments: "not valid json",
|
}, mockContext)
|
||||||
}, mockContext)
|
|
||||||
).rejects.toThrow(/Invalid arguments JSON/)
|
// then
|
||||||
|
expect(typeof result === "object" && result.metadata?.kind).toBe("unsupported_mcp_action")
|
||||||
|
expect(typeof result === "object" ? result.output : result).toContain("Invalid arguments JSON")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -115,28 +115,45 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
|
|||||||
.describe("Regex pattern to filter output lines (only matching lines returned)"),
|
.describe("Regex pattern to filter output lines (only matching lines returned)"),
|
||||||
},
|
},
|
||||||
async execute(args: SkillMcpArgs, toolContext: ToolContext) {
|
async execute(args: SkillMcpArgs, toolContext: ToolContext) {
|
||||||
const operation = validateOperationParams(args)
|
let operation: OperationType
|
||||||
|
try {
|
||||||
|
operation = validateOperationParams(args)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return {
|
||||||
|
output: message,
|
||||||
|
metadata: { kind: "unsupported_mcp_action" },
|
||||||
|
}
|
||||||
|
}
|
||||||
const skills = getLoadedSkills()
|
const skills = getLoadedSkills()
|
||||||
const found = findMcpServer(args.mcp_name, skills)
|
const found = findMcpServer(args.mcp_name, skills)
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
const builtinHint = formatBuiltinMcpHint(args.mcp_name)
|
const builtinHint = formatBuiltinMcpHint(args.mcp_name)
|
||||||
if (builtinHint) {
|
if (builtinHint) {
|
||||||
throw new Error(builtinHint)
|
return {
|
||||||
|
output: builtinHint,
|
||||||
|
metadata: { kind: "unsupported_mcp_action" },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
return {
|
||||||
`MCP server "${args.mcp_name}" not found.\n\n` +
|
output:
|
||||||
|
`MCP server "${args.mcp_name}" not found.\n\n` +
|
||||||
`Available MCP servers in loaded skills:\n` +
|
`Available MCP servers in loaded skills:\n` +
|
||||||
formatAvailableMcps(skills) +
|
formatAvailableMcps(skills) +
|
||||||
`\n\n` +
|
`\n\n` +
|
||||||
`Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`,
|
`Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`,
|
||||||
)
|
metadata: { kind: "unsupported_mcp_action" },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionID = toolContext.sessionID || getSessionID?.()
|
const sessionID = toolContext.sessionID || getSessionID?.()
|
||||||
if (!sessionID) {
|
if (!sessionID) {
|
||||||
throw new Error("No active session available for skill MCP call.")
|
return {
|
||||||
|
output: "No active session available for skill MCP call.",
|
||||||
|
metadata: { kind: "unsupported_mcp_action" },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const info: SkillMcpClientInfo = {
|
const info: SkillMcpClientInfo = {
|
||||||
@@ -152,28 +169,45 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
|
|||||||
skillName: found.skill.name,
|
skillName: found.skill.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedArgs = parseSkillMcpArguments(args.arguments)
|
let parsedArgs: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
parsedArgs = parseSkillMcpArguments(args.arguments)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return {
|
||||||
|
output: message,
|
||||||
|
metadata: { kind: "unsupported_mcp_action" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let output: string
|
let output: string
|
||||||
switch (operation.type) {
|
try {
|
||||||
case "tool": {
|
switch (operation.type) {
|
||||||
const result = await manager.callTool(info, context, operation.name, parsedArgs)
|
case "tool": {
|
||||||
output = JSON.stringify(result, null, 2)
|
const result = await manager.callTool(info, context, operation.name, parsedArgs)
|
||||||
break
|
output = JSON.stringify(result, null, 2)
|
||||||
}
|
break
|
||||||
case "resource": {
|
|
||||||
const result = await manager.readResource(info, context, operation.name)
|
|
||||||
output = JSON.stringify(result, null, 2)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "prompt": {
|
|
||||||
const stringArgs: Record<string, string> = {}
|
|
||||||
for (const [key, value] of Object.entries(parsedArgs)) {
|
|
||||||
stringArgs[key] = String(value)
|
|
||||||
}
|
}
|
||||||
const result = await manager.getPrompt(info, context, operation.name, stringArgs)
|
case "resource": {
|
||||||
output = JSON.stringify(result, null, 2)
|
const result = await manager.readResource(info, context, operation.name)
|
||||||
break
|
output = JSON.stringify(result, null, 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "prompt": {
|
||||||
|
const stringArgs: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(parsedArgs)) {
|
||||||
|
stringArgs[key] = String(value)
|
||||||
|
}
|
||||||
|
const result = await manager.getPrompt(info, context, operation.name, stringArgs)
|
||||||
|
output = JSON.stringify(result, null, 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return {
|
||||||
|
output: message,
|
||||||
|
metadata: { kind: "unsupported_mcp_action" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return applyGrepFilter(output, args.grep)
|
return applyGrepFilter(output, args.grep)
|
||||||
|
|||||||
Reference in New Issue
Block a user