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
11 changes: 9 additions & 2 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.5",
"@toon-format/toon": "^2.0.1",
"commander": "^14.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.2",
Expand Down
14 changes: 14 additions & 0 deletions src/cli/atlassian.api.cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function registerReadCommand(
path: string;
queryParams?: Record<string, string>;
jq?: string;
outputFormat?: 'toon' | 'json';
}) => Promise<{ content: string }>,
): void {
program
Expand All @@ -85,6 +86,11 @@ function registerReadCommand(
'--jq <expression>',
'JMESPath expression to filter/transform the response.',
)
.option(
'-o, --output-format <format>',
'Output format: "toon" (default, token-efficient) or "json".',
'toon',
)
.action(async (options) => {
const actionLogger = cliLogger.forMethod(name);
try {
Expand All @@ -103,6 +109,7 @@ function registerReadCommand(
path: options.path,
queryParams,
jq: options.jq,
outputFormat: options.outputFormat as 'toon' | 'json',
});

console.log(result.content);
Expand All @@ -128,6 +135,7 @@ function registerWriteCommand(
body: Record<string, unknown>;
queryParams?: Record<string, string>;
jq?: string;
outputFormat?: 'toon' | 'json';
}) => Promise<{ content: string }>,
): void {
program
Expand All @@ -143,6 +151,11 @@ function registerWriteCommand(
'--jq <expression>',
'JMESPath expression to filter/transform the response.',
)
.option(
'-o, --output-format <format>',
'Output format: "toon" (default, token-efficient) or "json".',
'toon',
)
.action(async (options) => {
const actionLogger = cliLogger.forMethod(name);
try {
Expand All @@ -168,6 +181,7 @@ function registerWriteCommand(
body,
queryParams,
jq: options.jq,
outputFormat: options.outputFormat as 'toon' | 'json',
});

console.log(result.content);
Expand Down
14 changes: 12 additions & 2 deletions src/controllers/atlassian.api.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
GetApiToolArgsType,
RequestWithBodyArgsType,
} from '../tools/atlassian.api.types.js';
import { applyJqFilter, toJsonString } from '../utils/jq.util.js';
import { applyJqFilter, toOutputString } from '../utils/jq.util.js';
import { createAuthMissingError } from '../utils/error.util.js';

// Logger instance for this module
Expand All @@ -20,13 +20,19 @@ const logger = Logger.forContext('controllers/atlassian.api.controller.ts');
*/
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

/**
* Output format type
*/
type OutputFormat = 'toon' | 'json';

/**
* Base options for all API requests
*/
interface BaseRequestOptions {
path: string;
queryParams?: Record<string, string>;
jq?: string;
outputFormat?: OutputFormat;
}

/**
Expand Down Expand Up @@ -119,8 +125,12 @@ async function handleRequest(
// Apply JQ filter if provided, otherwise return raw data
const result = applyJqFilter(response, options.jq);

// Convert to output format (TOON by default, JSON if requested)
const useToon = options.outputFormat !== 'json';
const content = await toOutputString(result, useToon);

return {
content: toJsonString(result),
content,
};
} catch (error) {
throw handleControllerError(error, {
Expand Down
108 changes: 54 additions & 54 deletions src/tools/atlassian.api.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,26 +125,27 @@ function registerTools(server: McpServer) {
// Register the GET tool
server.tool(
'conf_get',
`Read any Confluence data. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
`Read any Confluence data. Returns TOON format by default (30-60% fewer tokens than JSON).

**IMPORTANT - Cost Optimization:**
- ALWAYS use \`jq\` param to filter response fields. Unfiltered responses are very expensive!
- Use \`limit\` query param to restrict result count (e.g., \`limit: "5"\`)
- If unsure about available fields, first fetch ONE item with \`limit: "1"\` and NO jq filter to explore the schema, then use jq in subsequent calls

**Schema Discovery Pattern:**
1. First call: \`path: "/wiki/api/v2/spaces", queryParams: {"limit": "1"}\` (no jq) - explore available fields
2. Then use: \`jq: "results[*].{id: id, key: key, name: name}"\` - extract only what you need

**Output format:** TOON (default, token-efficient) or JSON (\`outputFormat: "json"\`)

**Common paths:**
- \`/wiki/api/v2/spaces\` - list all spaces
- \`/wiki/api/v2/spaces/{id}\` - get space details
- \`/wiki/api/v2/pages\` - list pages (use \`space-id\` query param to filter)
- \`/wiki/api/v2/spaces\` - list spaces
- \`/wiki/api/v2/pages\` - list pages (use \`space-id\` query param)
- \`/wiki/api/v2/pages/{id}\` - get page details
- \`/wiki/api/v2/pages/{id}/body\` - get page body (use \`body-format\` param: storage, atlas_doc_format, view)
- \`/wiki/api/v2/pages/{id}/children\` - get child pages
- \`/wiki/api/v2/pages/{id}/labels\` - get page labels
- \`/wiki/api/v2/pages/{id}/footer-comments\` - get page comments
- \`/wiki/api/v2/blogposts\` - list blog posts
- \`/wiki/api/v2/blogposts/{id}\` - get blog post
- \`/wiki/api/v2/labels/{id}\` - get label details
- \`/wiki/api/v2/content/{id}/attachments\` - get content attachments
- \`/wiki/rest/api/search\` - search content (use \`cql\` query param)
- \`/wiki/api/v2/pages/{id}/body\` - get page body (\`body-format\`: storage, atlas_doc_format, view)
- \`/wiki/rest/api/search\` - search content (\`cql\` query param)

**Query params:** \`limit\` (page size, default 25), \`cursor\` (pagination), \`space-id\` (filter by space), \`body-format\` (storage, atlas_doc_format, view)

**Example CQL queries:** \`type=page AND space=DEV\`, \`title~"search term"\`, \`label=important\`, \`creator=currentUser()\`
**JQ examples:** \`results[*].id\`, \`results[0]\`, \`results[*].{id: id, title: title}\`

API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
GetApiToolArgs.shape,
Expand All @@ -154,24 +155,25 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
// Register the POST tool
server.tool(
'conf_post',
`Create Confluence resources. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
`Create Confluence resources. Returns TOON format by default (token-efficient).

**IMPORTANT - Cost Optimization:**
- Use \`jq\` param to extract only needed fields from response (e.g., \`jq: "{id: id, title: title}"\`)
- Unfiltered responses include all metadata and are expensive!

**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)

**Common operations:**

1. **Create page:** \`/wiki/api/v2/pages\`
body: \`{"spaceId": "123456", "status": "current", "title": "Page Title", "parentId": "789", "body": {"representation": "storage", "value": "<p>Content here</p>"}}\`
body: \`{"spaceId": "123456", "status": "current", "title": "Page Title", "parentId": "789", "body": {"representation": "storage", "value": "<p>Content</p>"}}\`

2. **Create blog post:** \`/wiki/api/v2/blogposts\`
body: \`{"spaceId": "123456", "status": "current", "title": "Blog Title", "body": {"representation": "storage", "value": "<p>Blog content</p>"}}\`

3. **Add label to page:** \`/wiki/api/v2/pages/{id}/labels\`
body: \`{"name": "label-name"}\` (labels must be lowercase, no spaces)
body: \`{"spaceId": "123456", "status": "current", "title": "Blog Title", "body": {"representation": "storage", "value": "<p>Content</p>"}}\`

4. **Add footer comment:** \`/wiki/api/v2/pages/{id}/footer-comments\`
body: \`{"body": {"representation": "storage", "value": "<p>Comment text</p>"}}\`
3. **Add label:** \`/wiki/api/v2/pages/{id}/labels\` - body: \`{"name": "label-name"}\`

5. **Create inline comment:** \`/wiki/api/v2/pages/{id}/inline-comments\`
body: \`{"body": {"representation": "storage", "value": "<p>Inline comment</p>"}, "inlineCommentProperties": {"textSelection": "text to anchor to"}}\`
4. **Add comment:** \`/wiki/api/v2/pages/{id}/footer-comments\`

API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
RequestWithBodyArgs.shape,
Expand All @@ -181,18 +183,23 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
// Register the PUT tool
server.tool(
'conf_put',
`Replace Confluence resources (full update). Returns JSON, optionally filtered with JMESPath (\`jq\` param).
`Replace Confluence resources (full update). Returns TOON format by default.

**IMPORTANT - Cost Optimization:**
- Use \`jq\` param to extract only needed fields from response
- Example: \`jq: "{id: id, version: version.number}"\`

**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)

**Common operations:**

1. **Update page:** \`/wiki/api/v2/pages/{id}\`
body: \`{"id": "123", "status": "current", "title": "Updated Title", "spaceId": "456", "body": {"representation": "storage", "value": "<p>Updated content</p>"}, "version": {"number": 2, "message": "Update reason"}}\`
Note: version.number must be incremented from current version
body: \`{"id": "123", "status": "current", "title": "Updated Title", "spaceId": "456", "body": {"representation": "storage", "value": "<p>Content</p>"}, "version": {"number": 2}}\`
Note: version.number must be incremented

2. **Update blog post:** \`/wiki/api/v2/blogposts/{id}\`
body: \`{"id": "123", "status": "current", "title": "Updated Blog", "spaceId": "456", "body": {"representation": "storage", "value": "<p>Updated content</p>"}, "version": {"number": 2}}\`

Note: PUT replaces the entire resource. Version number must be incremented.
Note: PUT replaces entire resource. Version number must be incremented.

API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
RequestWithBodyArgs.shape,
Expand All @@ -202,19 +209,20 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
// Register the PATCH tool
server.tool(
'conf_patch',
`Partially update Confluence resources. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
`Partially update Confluence resources. Returns TOON format by default.

**Common operations:**
**IMPORTANT - Cost Optimization:** Use \`jq\` param to filter response fields.

**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)

Note: Confluence v2 API primarily uses PUT for updates. PATCH is available for some endpoints:
**Common operations:**

1. **Update space:** \`/wiki/api/v2/spaces/{id}\`
body: \`{"name": "New Space Name", "description": {"plain": {"value": "Description", "representation": "plain"}}}\`
body: \`{"name": "New Name", "description": {"plain": {"value": "Desc", "representation": "plain"}}}\`

2. **Update footer comment:** \`/wiki/api/v2/footer-comments/{comment-id}\`
body: \`{"version": {"number": 2}, "body": {"representation": "storage", "value": "<p>Updated comment</p>"}}\`
2. **Update comment:** \`/wiki/api/v2/footer-comments/{id}\`

For page content updates, use PUT with the full page object including incremented version number.
Note: Confluence v2 API primarily uses PUT for updates.

API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
RequestWithBodyArgs.shape,
Expand All @@ -224,24 +232,16 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
// Register the DELETE tool
server.tool(
'conf_delete',
`Delete Confluence resources. Returns JSON (if any), optionally filtered with JMESPath (\`jq\` param).

**Common operations:**
`Delete Confluence resources. Returns TOON format by default.

1. **Delete page:** \`/wiki/api/v2/pages/{id}\`
Returns 204 No Content on success
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)

2. **Delete blog post:** \`/wiki/api/v2/blogposts/{id}\`
Returns 204 No Content on success

3. **Remove label from page:** \`/wiki/api/v2/pages/{id}/labels/{label-id}\`
Returns 204 No Content on success

4. **Delete comment:** \`/wiki/api/v2/footer-comments/{comment-id}\`
Returns 204 No Content on success

5. **Delete attachment:** \`/wiki/api/v2/attachments/{attachment-id}\`
Returns 204 No Content on success
**Common operations:**
- \`/wiki/api/v2/pages/{id}\` - Delete page
- \`/wiki/api/v2/blogposts/{id}\` - Delete blog post
- \`/wiki/api/v2/pages/{id}/labels/{label-id}\` - Remove label
- \`/wiki/api/v2/footer-comments/{id}\` - Delete comment
- \`/wiki/api/v2/attachments/{id}\` - Delete attachment

Note: Most DELETE endpoints return 204 No Content on success.

Expand Down
23 changes: 21 additions & 2 deletions src/tools/atlassian.api.types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { z } from 'zod';

/**
* Output format options for API responses
* - toon: Token-Oriented Object Notation (default, more token-efficient for LLMs)
* - json: Standard JSON format
*/
export const OutputFormat = z
.enum(['toon', 'json'])
.optional()
.describe(
'Output format: "toon" (default, 30-60% fewer tokens) or "json". TOON is optimized for LLMs with tabular arrays and minimal syntax.',
);

/**
* Base schema fields shared by all API tool arguments
* Contains path, queryParams, and jq filter
* Contains path, queryParams, jq filter, and outputFormat
*/
const BaseApiToolArgs = {
/**
Expand Down Expand Up @@ -33,13 +45,20 @@ const BaseApiToolArgs = {

/**
* Optional JMESPath expression to filter/transform the response
* IMPORTANT: Always use this to reduce response size and token costs
*/
jq: z
.string()
.optional()
.describe(
'JMESPath expression to filter/transform the JSON response. Examples: "results[*].id" (extract IDs), "results[0]" (first result), "{id: id, title: title}" (reshape object). See https://jmespath.org for syntax.',
'JMESPath expression to filter/transform the response. IMPORTANT: Always use this to extract only needed fields and reduce token costs. Examples: "results[*].{id: id, title: title}" (extract specific fields), "results[0]" (first result), "results[*].id" (IDs only). See https://jmespath.org',
),

/**
* Output format for the response
* Defaults to TOON (token-efficient), can be set to JSON if needed
*/
outputFormat: OutputFormat,
};

/**
Expand Down
29 changes: 29 additions & 0 deletions src/utils/jq.util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import jmespath from 'jmespath';
import { Logger } from './logger.util.js';
import { toToonOrJson } from './toon.util.js';

const logger = Logger.forContext('utils/jq.util.ts');

Expand Down Expand Up @@ -60,3 +61,31 @@ export function toJsonString(data: unknown, pretty: boolean = true): string {
}
return JSON.stringify(data);
}

/**
* Convert data to output string for MCP response
*
* By default, converts to TOON format (Token-Oriented Object Notation)
* for improved LLM token efficiency (30-60% fewer tokens).
* Falls back to JSON if TOON conversion fails or if useToon is false.
*
* @param data - The data to convert
* @param useToon - Whether to use TOON format (default: true)
* @param pretty - Whether to pretty-print JSON (default: true)
* @returns TOON formatted string (default), or JSON string
*/
export async function toOutputString(
data: unknown,
useToon: boolean = true,
pretty: boolean = true,
): Promise<string> {
const jsonString = toJsonString(data, pretty);

// Return JSON directly if TOON is not requested
if (!useToon) {
return jsonString;
}

// Try TOON conversion with JSON fallback
return toToonOrJson(data, jsonString);
}
Loading