diff --git a/README.md b/README.md index fdd926b..03051cd 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ repository. ```bash node bundle/backlog-api.mjs --version node bundle/backlog-api.mjs tools list +node bundle/backlog-api.mjs tools describe get_issue node bundle/backlog-api.mjs trace get_issue node bundle/backlog-api.mjs call get_issue --input request.json ``` @@ -32,9 +33,24 @@ The CLI supports all 58 normal tools registered by the checked upstream reads one JSON object and writes one structured JSON envelope containing the result, diagnostics, and upstream trace information. +`tools describe ` is the agent-oriented discovery command. It +returns the operation input as JSON Schema, the result-field schema used by +GraphQL-style `fields` selection, important output fields, mutation and +permission metadata, confirmation requirements, and curated examples when +available. `call --help` is an alias for the same credential-free +JSON output. + Delete operations and broad notification reset require `--confirm-destructive`. Use `--dry-run` to validate input without calling -Backlog. +Backlog or resolving a configured connection. Write permissions and destructive +confirmation continue to apply during dry-run. + +For an agent workflow: + +1. Run `tools list` to choose an operation and inspect its safety class. +2. Run `tools describe ` to obtain the input contract. +3. Run `call --dry-run` with the intended JSON. +4. Run the call without `--dry-run` only after validation succeeds. Use `--verbose` to write a short event for the start and outcome of each Backlog API access to stderr. Events identify the operation, Backlog client diff --git a/docs/development.md b/docs/development.md index a128e52..76900ef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ ## Initial Design Record - checked date: 2026-07-22 -- repository version: `0.4.1` +- repository version: `0.5.0` - implementation maturity: beta standalone Node Core/CLI - split source: `backlog-api-skills` initial combined implementation diff --git a/docs/traceability/cli-json-parity.md b/docs/traceability/cli-json-parity.md index 8baf972..907c975 100644 --- a/docs/traceability/cli-json-parity.md +++ b/docs/traceability/cli-json-parity.md @@ -39,6 +39,11 @@ use the same envelope with `success: false` and error diagnostics. - no MCP content blocks or protocol transport - no MCP dynamic-toolset calls - GraphQL-style `fields` selection is accepted as a top-level input property +- `tools describe ` exposes the input and result-field schemas as + credential-free JSON for agent discovery +- handler-level ID/key and ID/name alternatives are validated before dry-run + succeeds +- dry-run validates without resolving a Backlog connection - invalid `fields` is rejected before invoking Backlog, while the upstream MCP wrapper parses the selection after its handler returns - MCP token-count truncation is not exposed because its character cut can diff --git a/docs/traceability/upstream-tool-mapping.json b/docs/traceability/upstream-tool-mapping.json index 11af7c3..10558b2 100644 --- a/docs/traceability/upstream-tool-mapping.json +++ b/docs/traceability/upstream-tool-mapping.json @@ -10,7 +10,7 @@ "target": { "repository": "backlog-api", "product": "backlog-api", - "version": "0.4.1", + "version": "0.5.0", "strategy": "published-handler-direct-invocation" }, "operations": [ diff --git a/package-lock.json b/package-lock.json index 4b3752f..f7d7507 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "backlog-api", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backlog-api", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "backlog-js": "0.18.1", - "backlog-mcp-server": "0.13.2" + "backlog-mcp-server": "0.13.2", + "zod": "3.25.76", + "zod-to-json-schema": "3.25.2" }, "bin": { "backlog-api": "dist/cli.mjs" diff --git a/package.json b/package.json index 1a43c56..99c3a24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "backlog-api", - "version": "0.4.1", + "version": "0.5.0", "private": true, "type": "module", "bin": { @@ -20,7 +20,9 @@ }, "dependencies": { "backlog-js": "0.18.1", - "backlog-mcp-server": "0.13.2" + "backlog-mcp-server": "0.13.2", + "zod": "3.25.76", + "zod-to-json-schema": "3.25.2" }, "devDependencies": { "@types/node": "26.1.1", diff --git a/scripts/smoke-node.mjs b/scripts/smoke-node.mjs index 68236a7..b50062e 100644 --- a/scripts/smoke-node.mjs +++ b/scripts/smoke-node.mjs @@ -13,7 +13,7 @@ for (const args of [["--version"], ["--help"], ["tools", "list"]]) { const runtime = await import("../bundle/backlog-api-runtime.mjs"); assert.equal(runtime.product.name, "backlog-api"); -assert.equal(runtime.product.version, "0.4.1"); +assert.equal(runtime.product.version, "0.5.0"); assert.equal(runtime.listOperations().length, 59); assert.equal( runtime.listOperations().find((operation) => operation.name === "get_rate_limit") diff --git a/src/cli.ts b/src/cli.ts index 819e706..c4d9985 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import fs from "node:fs"; -import { listOperations } from "./core/catalog.js"; +import { describeOperation, listOperations } from "./core/catalog.js"; import { CliUsageError, parseCliArguments @@ -18,6 +18,7 @@ Usage: backlog-api --version backlog-api --help backlog-api tools list + backlog-api tools describe backlog-api trace [operation] backlog-api call [--input ] [--allow ] [--dry-run] [--confirm-destructive] [--verbose] @@ -31,7 +32,13 @@ Commands: tools list Print a JSON catalog of available operations. Each entry includes the - operation name, description, toolset, and mutation classification. + operation name, description, toolset, mutation classification, and + required permission. + + tools describe + Print the machine-readable contract for one operation: input JSON Schema, + result fields available to "fields", safety requirements, and examples. + No Backlog credentials are required. trace [operation] Print JSON traceability metadata for all operations or one operation. @@ -39,7 +46,9 @@ Commands: call Read one JSON object, invoke the named operation, and print one JSON - result envelope. Use "tools list" to discover operation names. + result envelope. Use "tools list" to discover operation names and + "tools describe " to discover its input contract. + "call --help" is an alias for "tools describe ". Call options: --input Read the request object from a UTF-8 JSON file. @@ -47,7 +56,9 @@ Call options: --allow Allow comma-separated CRUD permissions. Defaults to READ. Values: READ, CREATE, UPDATE, DELETE. This cannot exceed BACKLOG_API_ALLOWED_PERMISSIONS. - --dry-run Validate and normalize input without invoking Backlog. + --dry-run Validate and normalize input without resolving a + Backlog connection or requiring credentials. Write + permissions and destructive confirmation still apply. --confirm-destructive Explicitly authorize delete_* or broad reset calls. --verbose Write a safe summary of each Backlog API access to stderr as a "verbose: " prefixed JSON object. A @@ -66,7 +77,7 @@ Input JSON: only selected result fields. Output: - "tools list", "trace", and "call" write machine-readable JSON to stdout. + All commands except --help and --version write machine-readable JSON to stdout. A call result contains schemaVersion, operation, success, diagnostics, trace, and either result or dryRun/input data. --help and --version are the only plain-text stdout commands. Unexpected CLI errors are written to stderr. @@ -85,8 +96,9 @@ Safety: Environment: BACKLOG_DOMAIN and BACKLOG_API_KEY configure one connection. The upstream - BACKLOG_DEFAULT_ORG and BACKLOG_ORG__* variables configure multiple - organizations. Metadata commands do not require credentials. + BACKLOG_DEFAULT_ORG, BACKLOG_ORG__DOMAIN, and + BACKLOG_ORG__API_KEY variables configure multiple organizations. + Metadata commands do not require credentials. BACKLOG_API_ALLOWED_PERMISSIONS is a comma-separated environment-level maximum using READ, CREATE, UPDATE, and DELETE. It defaults to READ when @@ -115,8 +127,16 @@ Exit codes: 1 Configuration, confirmation, organization, or Backlog API failure. 2 CLI usage, fields-selection, or operation input-schema failure. +Agent discovery: + 1. Run "tools list" to choose an operation and inspect its safety class. + 2. Run "tools describe " to obtain its complete input contract. + 3. Run "call --dry-run" with the intended JSON. + 4. Only after successful validation, run the call without --dry-run. + Examples: backlog-api tools list + backlog-api tools describe get_issue + backlog-api call get_issue --help backlog-api trace get_issue printf '{"issueKey":"PROJ-1"}\\n' | backlog-api call get_issue printf '{"issueKey":"PROJ-1","fields":"{ id summary }"}\\n' | backlog-api call get_issue @@ -158,6 +178,18 @@ async function main(): Promise { writeJson({ schemaVersion: 1, product, operations: listOperations() }); return; } + if (command.kind === "tools-describe") { + const operation = describeOperation(command.operation); + if (operation === undefined) { + process.stderr.write( + `Unknown operation: ${command.operation}. Use "tools list" to discover operation names.\n` + ); + process.exitCode = 2; + return; + } + writeJson({ schemaVersion: 1, product, operation }); + return; + } if (command.kind === "trace") { const mapping = getMapping(); writeJson( diff --git a/src/core/catalog.ts b/src/core/catalog.ts index b890b28..d2dca37 100644 --- a/src/core/catalog.ts +++ b/src/core/catalog.ts @@ -1,6 +1,8 @@ import { allTools } from "backlog-mcp-server/build/tools/tools.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; import type { CrudPermission, MutationClass } from "./contracts.js"; import { createLocalToolset } from "./local-tools.js"; +import { getAlternativeFieldConstraints } from "./operation-input-constraints.js"; interface OperationPolicy { mutationClass: MutationClass; @@ -119,6 +121,39 @@ export function listOperations() { .sort((left, right) => compareUtf16(left.name, right.name)); } +export function describeOperation(operationName: string) { + const resolved = resolveTool(metadataOnlyClient, operationName); + if (resolved === undefined) { + return undefined; + } + const policy = requireOperationPolicy(operationName); + const inputSchema = addCliInputMetadata( + toJsonSchema(resolved.tool.schema), + operationName + ); + const outputSchema = resolved.tool.outputSchema === undefined + ? undefined + : toJsonSchema(resolved.tool.outputSchema); + const examples = OPERATION_EXAMPLES.get(operationName); + return { + name: resolved.tool.name, + description: resolved.tool.description, + toolset: resolved.toolset, + ...policy, + requiresConfirmation: + policy.mutationClass === "destructive" || + policy.mutationClass === "broad-mutation", + supportsDryRun: true, + credentialsRequiredForDryRun: false, + inputSchema, + ...(outputSchema === undefined ? {} : { outputFieldSchema: outputSchema }), + ...(resolved.tool.importantFields === undefined + ? {} + : { importantOutputFields: resolved.tool.importantFields }), + ...(examples === undefined ? {} : { examples }) + }; +} + export function hasOperation(operationName: string): boolean { return createToolsets().some((toolset) => toolset.tools.some((tool) => tool.name === operationName) @@ -165,3 +200,72 @@ function policyEntries( function compareUtf16(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } + +type JsonObject = Record; + +const OPERATION_EXAMPLES = new Map([ + ["get_issue", [{ issueKey: "PROJ-1" }, { issueId: 12345 }]], + ["get_project", [{ projectKey: "PROJ" }, { projectId: 12345 }]], + ["get_rate_limit", [{}]], + [ + "add_issue", + [{ + projectId: 12345, + summary: "Example issue", + issueTypeId: 1, + priorityId: 3 + }] + ], + ["delete_issue", [{ issueKey: "PROJ-1" }]] +]); + +function toJsonSchema(schema: unknown): JsonObject { + return zodToJsonSchema( + schema as Parameters[0], + { target: "jsonSchema7" } + ) as JsonObject; +} + +function addCliInputMetadata( + schema: JsonObject, + operationName: string +): JsonObject { + const properties = isJsonObject(schema.properties) + ? schema.properties + : {}; + const constraints = getAlternativeFieldConstraints(operationName); + const existingAllOf = Array.isArray(schema.allOf) ? schema.allOf : []; + return { + ...schema, + properties: { + ...properties, + organization: { + type: "string", + description: + "Configured Backlog organization name. Omit to use the default connection." + }, + fields: { + type: "string", + description: + 'GraphQL-style result field selection, for example "{ id summary }".' + } + }, + ...(constraints.length === 0 + ? {} + : { + allOf: [ + ...existingAllOf, + ...constraints.map((constraint) => ({ + description: constraint.message, + anyOf: constraint.fields.map((field) => ({ + required: [field] + })) + })) + ] + }) + }; +} + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/core/cli-arguments.ts b/src/core/cli-arguments.ts index d44d1ad..50ce40a 100644 --- a/src/core/cli-arguments.ts +++ b/src/core/cli-arguments.ts @@ -9,6 +9,7 @@ export type CliCommand = | { kind: "help" } | { kind: "version" } | { kind: "tools-list" } + | { kind: "tools-describe"; operation: string } | { kind: "trace"; operation?: string } | { kind: "call"; @@ -28,7 +29,19 @@ export class CliUsageError extends Error { } export function parseCliArguments(args: readonly string[]): CliCommand { - if (args.length === 0 || args.includes("--help")) { + if (args.length === 0) { + return { kind: "help" }; + } + if ( + args[0] === "call" && + args.length === 3 && + args[1] !== undefined && + !args[1].startsWith("--") && + args[2] === "--help" + ) { + return { kind: "tools-describe", operation: args[1] }; + } + if (args.includes("--help")) { return { kind: "help" }; } if (args[0] === "help") { @@ -43,6 +56,14 @@ export function parseCliArguments(args: readonly string[]): CliCommand { requireArgumentCount(args, 2, "tools list"); return { kind: "tools-list" }; } + if (args[0] === "tools" && args[1] === "describe") { + const operation = args[2]; + if (operation === undefined || operation.startsWith("--")) { + throw new CliUsageError("tools describe requires an operation name."); + } + requireArgumentCount(args, 3, "tools describe"); + return { kind: "tools-describe", operation }; + } if (args[0] === "trace") { if (args.length > 2) { throw new CliUsageError("trace accepts at most one operation name."); diff --git a/src/core/local-tools.ts b/src/core/local-tools.ts index c591863..c1ca306 100644 --- a/src/core/local-tools.ts +++ b/src/core/local-tools.ts @@ -1,7 +1,15 @@ +import { z } from "zod"; + interface LocalBacklogClient { getRateLimit(): Promise; } +const rateLimitBucketSchema = z.object({ + limit: z.number(), + remaining: z.number(), + reset: z.number() +}); + export function createLocalToolset(backlog: object) { const client = backlog as LocalBacklogClient; return { @@ -13,27 +21,16 @@ export function createLocalToolset(backlog: object) { name: "get_rate_limit", description: "Get Backlog API rate limits for read, update, search, and icon requests.", - schema: { - safeParse(input: unknown) { - if ( - typeof input !== "object" || - input === null || - Array.isArray(input) || - Object.keys(input).length > 0 - ) { - return { - success: false as const, - error: { - issues: [{ - path: [] as PropertyKey[], - message: "get_rate_limit does not accept operation arguments." - }] - } - }; - } - return { success: true as const, data: {} }; - } - }, + schema: z.object({}).strict(), + outputSchema: z.object({ + rateLimit: z.object({ + read: rateLimitBucketSchema, + update: rateLimitBucketSchema, + search: rateLimitBucketSchema, + icon: rateLimitBucketSchema + }) + }), + importantFields: ["rateLimit"], async handler() { return client.getRateLimit(); } diff --git a/src/core/operation-input-constraints.ts b/src/core/operation-input-constraints.ts new file mode 100644 index 0000000..9686522 --- /dev/null +++ b/src/core/operation-input-constraints.ts @@ -0,0 +1,96 @@ +export interface AlternativeFieldConstraint { + fields: readonly [string, string]; + message: string; +} + +const ISSUE_ID_OR_KEY_OPERATIONS = [ + "add_issue_comment", + "delete_issue", + "get_issue", + "get_issue_comments", + "update_issue" +] as const; + +const PROJECT_ID_OR_KEY_OPERATIONS = [ + "add_pull_request", + "add_pull_request_comment", + "add_version_milestone", + "delete_project", + "delete_version", + "get_categories", + "get_custom_fields", + "get_git_repositories", + "get_git_repository", + "get_issue_types", + "get_project", + "get_project_users", + "get_pull_request", + "get_pull_request_comments", + "get_pull_requests", + "get_pull_requests_count", + "get_version_milestone_list", + "get_wiki_pages", + "get_wikis_count", + "update_project", + "update_pull_request", + "update_pull_request_comment", + "update_version_milestone" +] as const; + +const REPOSITORY_ID_OR_NAME_OPERATIONS = [ + "add_pull_request", + "add_pull_request_comment", + "get_git_repository", + "get_pull_request", + "get_pull_request_comments", + "get_pull_requests", + "get_pull_requests_count", + "update_pull_request", + "update_pull_request_comment" +] as const; + +const constraintsByOperation = new Map(); + +addConstraints(ISSUE_ID_OR_KEY_OPERATIONS, { + fields: ["issueId", "issueKey"], + message: "Issue ID or key is required." +}); +addConstraints(PROJECT_ID_OR_KEY_OPERATIONS, { + fields: ["projectId", "projectKey"], + message: "Project ID or key is required." +}); +addConstraints(REPOSITORY_ID_OR_NAME_OPERATIONS, { + fields: ["repoId", "repoName"], + message: "Repository ID or name is required." +}); + +export function getAlternativeFieldConstraints( + operation: string +): readonly AlternativeFieldConstraint[] { + return constraintsByOperation.get(operation) ?? []; +} + +export function validateOperationInputConstraints( + operation: string, + input: Record +): Array<{ path: string; message: string }> { + return getAlternativeFieldConstraints(operation) + .filter((constraint) => + constraint.fields.every((field) => input[field] === undefined) + ) + .map((constraint) => ({ + path: constraint.fields.join("|"), + message: constraint.message + })); +} + +function addConstraints( + operations: readonly string[], + constraint: AlternativeFieldConstraint +): void { + for (const operation of operations) { + const constraints = constraintsByOperation.get(operation) ?? []; + constraints.push(constraint); + constraintsByOperation.set(operation, constraints); + } +} diff --git a/src/core/run-operation.ts b/src/core/run-operation.ts index 1c7771b..7954daf 100644 --- a/src/core/run-operation.ts +++ b/src/core/run-operation.ts @@ -19,6 +19,7 @@ import type { UpstreamTrace } from "./contracts.js"; import { selectResultFields, validateFieldsSelection } from "./field-selection.js"; +import { validateOperationInputConstraints } from "./operation-input-constraints.js"; import { getUpstreamTrace } from "./traceability.js"; import { observeBacklogClient } from "./verbose-client.js"; @@ -91,13 +92,6 @@ export async function runOperation( ); } - let registry; - try { - registry = options.registry ?? createBacklogClientRegistry({ env }); - } catch (error) { - return failure(operation, "CONFIGURATION_ERROR", errorMessage(error), trace); - } - const { organization, fields: _fields, ...toolInput } = request; if (organization !== undefined && typeof organization !== "string") { return failure( @@ -107,6 +101,41 @@ export async function runOperation( trace ); } + + const metadataResolved = resolveTool({}, operation); + if (!metadataResolved) { + return failure(operation, "UNKNOWN_OPERATION", `Unknown operation: ${operation}`, trace); + } + + if (options.dryRun === true) { + const validation = validateToolInput( + operation, + metadataResolved.toolset, + metadataResolved.tool.schema, + toolInput, + trace + ); + if (!validation.ok) { + return validation.failure; + } + return { + schemaVersion: 1, + operation, + toolset: metadataResolved.toolset, + success: true, + dryRun: true, + input: validation.data, + diagnostics: [], + trace + }; + } + + let registry; + try { + registry = options.registry ?? createBacklogClientRegistry({ env }); + } catch (error) { + return failure(operation, "CONFIGURATION_ERROR", errorMessage(error), trace); + } let backlog; try { backlog = registry.resolveClient(organization); @@ -129,42 +158,20 @@ export async function runOperation( if (!resolved) { return failure(operation, "UNKNOWN_OPERATION", `Unknown operation: ${operation}`, trace); } - - const parsed = resolved.tool.schema.safeParse(toolInput); - if (!parsed.success) { - return { - schemaVersion: 1, - operation, - toolset: resolved.toolset, - success: false, - diagnostics: parsed.error.issues.map((issue) => ({ - code: "INVALID_ARGUMENT", - severity: "error", - path: issue.path.map(String).join("."), - message: issue.message - })), - trace - }; - } - if (isRecord(parsed.data)) { - verboseContext.input = parsed.data; - } - - if (options.dryRun === true) { - return { - schemaVersion: 1, - operation, - toolset: resolved.toolset, - success: true, - dryRun: true, - input: parsed.data, - diagnostics: [], - trace - }; + const validation = validateToolInput( + operation, + resolved.toolset, + resolved.tool.schema, + toolInput, + trace + ); + if (!validation.ok) { + return validation.failure; } + verboseContext.input = validation.record; try { - const result = await resolved.tool.handler(parsed.data); + const result = await resolved.tool.handler(validation.data); const selectedResult = await selectResultFields(result, fields); return { schemaVersion: 1, @@ -187,6 +194,69 @@ export async function runOperation( } } +interface ToolInputSchema { + safeParse(input: unknown): + | { success: true; data: unknown } + | { + success: false; + error: { + issues: Array<{ path: PropertyKey[]; message: string }>; + }; + }; +} + +type ToolInputValidation = + | { ok: true; data: unknown; record: Record } + | { ok: false; failure: OperationFailure }; + +function validateToolInput( + operation: string, + toolset: string, + schema: ToolInputSchema, + input: Record, + trace: UpstreamTrace +): ToolInputValidation { + const parsed = schema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + failure: { + schemaVersion: 1, + operation, + toolset, + success: false, + diagnostics: parsed.error.issues.map((issue) => ({ + code: "INVALID_ARGUMENT", + severity: "error", + path: issue.path.map(String).join("."), + message: issue.message + })), + trace + } + }; + } + const record = isRecord(parsed.data) ? parsed.data : {}; + const constraintIssues = validateOperationInputConstraints(operation, record); + if (constraintIssues.length > 0) { + return { + ok: false, + failure: { + schemaVersion: 1, + operation, + toolset, + success: false, + diagnostics: constraintIssues.map((issue) => ({ + code: "INVALID_ARGUMENT", + severity: "error", + ...issue + })), + trace + } + }; + } + return { ok: true, data: parsed.data, record }; +} + function failure( operation: string, code: DiagnosticCode, diff --git a/src/product.ts b/src/product.ts index c5c0e39..8fae437 100644 --- a/src/product.ts +++ b/src/product.ts @@ -1,5 +1,5 @@ export const product = Object.freeze({ name: "backlog-api", - version: "0.4.1", + version: "0.5.0", upstream: "backlog-mcp-server@0.13.2" }); diff --git a/src/types/backlog-mcp-server.d.ts b/src/types/backlog-mcp-server.d.ts index 28c6279..03fe0d8 100644 --- a/src/types/backlog-mcp-server.d.ts +++ b/src/types/backlog-mcp-server.d.ts @@ -16,6 +16,8 @@ declare module "backlog-mcp-server/build/tools/tools.js" { }; }; }; + outputSchema?: unknown; + importantFields?: readonly string[]; handler(input: unknown): Promise; } diff --git a/tests/node-cli.test.mjs b/tests/node-cli.test.mjs index 9155591..25daa3d 100644 --- a/tests/node-cli.test.mjs +++ b/tests/node-cli.test.mjs @@ -7,13 +7,15 @@ const CLI = "bundle/backlog-api.mjs"; test("CLI metadata commands do not require credentials", () => { const version = run(["--version"]); assert.equal(version.status, 0); - assert.equal(version.stdout, "0.4.1\n"); + assert.equal(version.stdout, "0.5.0\n"); assert.equal(version.stderr, ""); const help = run(["--help"]); assert.equal(help.status, 0); assert.equal(help.stderr, ""); assert.match(help.stdout, /backlog-api call /); + assert.match(help.stdout, /tools describe /); + assert.match(help.stdout, /Agent discovery:/); assert.match(help.stdout, /machine-readable JSON to stdout/); assert.match(help.stdout, /--confirm-destructive/); assert.match(help.stdout, /--verbose/); @@ -44,6 +46,65 @@ test("CLI metadata commands do not require credentials", () => { catalog.operations.find((operation) => operation.name === "get_rate_limit").requiredPermission, "READ" ); + + const description = JSON.parse(run(["tools", "describe", "get_issue"]).stdout); + assert.equal(description.operation.name, "get_issue"); + assert.equal(description.operation.requiredPermission, "READ"); + assert.equal(description.operation.requiresConfirmation, false); + assert.equal(description.operation.credentialsRequiredForDryRun, false); + assert.equal(description.operation.inputSchema.properties.issueId.type, "number"); + assert.equal(description.operation.inputSchema.properties.issueKey.type, "string"); + assert.equal(description.operation.inputSchema.properties.organization.type, "string"); + assert.equal(description.operation.inputSchema.properties.fields.type, "string"); + assert.deepEqual(description.operation.inputSchema.allOf[0].anyOf, [ + { required: ["issueId"] }, + { required: ["issueKey"] } + ]); + assert.equal(description.operation.outputFieldSchema.properties.summary.type, "string"); + assert.deepEqual(description.operation.examples, [ + { issueKey: "PROJ-1" }, + { issueId: 12345 } + ]); + + const alias = JSON.parse(run(["call", "get_issue", "--help"]).stdout); + assert.deepEqual(alias, description); + + const localDescription = JSON.parse( + run(["tools", "describe", "get_rate_limit"]).stdout + ); + assert.deepEqual(localDescription.operation.inputSchema.required, undefined); + assert.equal( + localDescription.operation.outputFieldSchema.properties.rateLimit.type, + "object" + ); +}); + +test("CLI dry-run validates complete input without Backlog credentials", () => { + const valid = run( + ["call", "get_issue", "--input", "-", "--dry-run"], + '{"issueKey":"TEST-1"}' + ); + assert.equal(valid.status, 0); + assert.equal(JSON.parse(valid.stdout).dryRun, true); + + for (const [operation, path] of [ + ["get_issue", "issueId|issueKey"], + ["get_project", "projectId|projectKey"] + ]) { + const invalid = run(["call", operation, "--input", "-", "--dry-run"], "{}"); + assert.equal(invalid.status, 2); + const body = JSON.parse(invalid.stdout); + assert.equal(body.diagnostics[0].code, "INVALID_ARGUMENT"); + assert.equal(body.diagnostics[0].path, path); + } + + const write = run( + ["call", "add_issue", "--input", "-", "--allow", "CREATE", "--dry-run"], + '{"projectId":1,"summary":"Test","issueTypeId":2,"priorityId":3}', + { BACKLOG_API_ALLOWED_PERMISSIONS: "CREATE" } + ); + assert.equal(write.status, 0); + assert.equal(JSON.parse(write.stdout).dryRun, true); }); test("CLI allows READ only by default and checks permissions before credentials", () => { @@ -103,6 +164,8 @@ test("CLI rejects unknown, duplicate, and extra arguments", () => { [["call", "get_issue", "--dry-run", "--dry-run"], /specified only once/], [["call", "get_issue", "extra"], /Unexpected argument for call/], [["tools", "list", "extra"], /does not accept additional arguments/], + [["tools", "describe"], /requires an operation name/], + [["tools", "describe", "get_issue", "extra"], /does not accept additional arguments/], [["trace", "get_issue", "extra"], /at most one operation name/] ]; @@ -112,6 +175,11 @@ test("CLI rejects unknown, duplicate, and extra arguments", () => { assert.equal(result.stdout, ""); assert.match(result.stderr, expectedError); } + + const unknown = run(["tools", "describe", "not_an_operation"]); + assert.equal(unknown.status, 2); + assert.equal(unknown.stdout, ""); + assert.match(unknown.stderr, /Unknown operation/); }); function run(args, input = "", extraEnv = {}) { diff --git a/tests/node-runtime.test.mjs b/tests/node-runtime.test.mjs index 263684c..dda3421 100644 --- a/tests/node-runtime.test.mjs +++ b/tests/node-runtime.test.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import test from "node:test"; import { classifyMutation, + describeOperation, listOperations, requiredPermission } from "../dist/ts/core/catalog.js"; @@ -46,6 +47,21 @@ test("generated Node runtime excludes upstream MCP and HTTP server modules", () assert.equal(inputs.some((entry) => entry.includes("httpMcpServer")), false); }); +test("all operations expose machine-readable agent contracts", () => { + for (const catalogEntry of listOperations()) { + const description = describeOperation(catalogEntry.name); + assert.notEqual(description, undefined); + assert.equal(description.name, catalogEntry.name); + assert.equal(description.requiredPermission, catalogEntry.requiredPermission); + assert.equal(description.inputSchema.type, "object"); + assert.equal(description.inputSchema.properties.organization.type, "string"); + assert.equal(description.inputSchema.properties.fields.type, "string"); + assert.equal(description.credentialsRequiredForDryRun, false); + assert.equal(typeof description.outputFieldSchema, "object"); + } + assert.equal(describeOperation("not_an_operation"), undefined); +}); + test("mutation classification preserves unusual upstream names", () => { assert.equal(classifyMutation("get_issue"), "read"); assert.equal(classifyMutation("add_issue"), "mutation");