Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 <operation>` 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 <operation> --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 <operation>` to obtain the input contract.
3. Run `call <operation> --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
Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs/traceability/cli-json-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <operation>` 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
Expand Down
2 changes: 1 addition & 1 deletion docs/traceability/upstream-tool-mapping.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
8 changes: 5 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backlog-api",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"type": "module",
"bin": {
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke-node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
46 changes: 39 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +18,7 @@ Usage:
backlog-api --version
backlog-api --help
backlog-api tools list
backlog-api tools describe <operation>
backlog-api trace [operation]
backlog-api call <operation> [--input <file|->] [--allow <permissions>]
[--dry-run] [--confirm-destructive] [--verbose]
Expand All @@ -31,23 +32,33 @@ 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 <operation>
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.
Use this to relate a Node operation to its upstream implementation.

call <operation>
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 <operation>" to discover its input contract.
"call <operation> --help" is an alias for "tools describe <operation>".

Call options:
--input <file> Read the request object from a UTF-8 JSON file.
--input - Read the request object from stdin (default).
--allow <permissions> 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
Expand All @@ -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.
Expand All @@ -85,8 +96,9 @@ Safety:

Environment:
BACKLOG_DOMAIN and BACKLOG_API_KEY configure one connection. The upstream
BACKLOG_DEFAULT_ORG and BACKLOG_ORG_<NAME>_* variables configure multiple
organizations. Metadata commands do not require credentials.
BACKLOG_DEFAULT_ORG, BACKLOG_ORG_<NAME>_DOMAIN, and
BACKLOG_ORG_<NAME>_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
Expand Down Expand Up @@ -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 <operation>" to obtain its complete input contract.
3. Run "call <operation> --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
Expand Down Expand Up @@ -158,6 +178,18 @@ async function main(): Promise<void> {
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(
Expand Down
104 changes: 104 additions & 0 deletions src/core/catalog.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -165,3 +200,72 @@ function policyEntries(
function compareUtf16(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

type JsonObject = Record<string, unknown>;

const OPERATION_EXAMPLES = new Map<string, readonly JsonObject[]>([
["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<typeof zodToJsonSchema>[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);
}
23 changes: 22 additions & 1 deletion src/core/cli-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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") {
Expand All @@ -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.");
Expand Down
Loading