Skip to content

Commit 33ffa7b

Browse files
authored
feat: add TOON output format for token-efficient LLM responses (#145)
- Add @toon-format/toon package for Token-Oriented Object Notation - TOON is now the default output format (30-60% fewer tokens than JSON) - Add outputFormat parameter to switch between 'toon' (default) and 'json' - Update tool descriptions with cost optimization guidance - Add schema discovery pattern guidance for AI to explore fields first - Fallback to JSON automatically if TOON conversion fails TOON format example: [2]{id,name,key}: "327682",Codashop,SHOP "16711683",Codapay,PAY vs JSON: [{"id":"327682","name":"Codashop","key":"SHOP"},...]
1 parent c0b36a5 commit 33ffa7b

File tree

9 files changed

+424
-60
lines changed

9 files changed

+424
-60
lines changed

package-lock.json

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
},
9797
"dependencies": {
9898
"@modelcontextprotocol/sdk": "^1.17.5",
99+
"@toon-format/toon": "^2.0.1",
99100
"commander": "^14.0.0",
100101
"cors": "^2.8.5",
101102
"dotenv": "^17.2.2",

src/cli/atlassian.api.cli.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ function registerReadCommand(
6868
path: string;
6969
queryParams?: Record<string, string>;
7070
jq?: string;
71+
outputFormat?: 'toon' | 'json';
7172
}) => Promise<{ content: string }>,
7273
): void {
7374
program
@@ -85,6 +86,11 @@ function registerReadCommand(
8586
'--jq <expression>',
8687
'JMESPath expression to filter/transform the response.',
8788
)
89+
.option(
90+
'-o, --output-format <format>',
91+
'Output format: "toon" (default, token-efficient) or "json".',
92+
'toon',
93+
)
8894
.action(async (options) => {
8995
const actionLogger = cliLogger.forMethod(name);
9096
try {
@@ -103,6 +109,7 @@ function registerReadCommand(
103109
path: options.path,
104110
queryParams,
105111
jq: options.jq,
112+
outputFormat: options.outputFormat as 'toon' | 'json',
106113
});
107114

108115
console.log(result.content);
@@ -128,6 +135,7 @@ function registerWriteCommand(
128135
body: Record<string, unknown>;
129136
queryParams?: Record<string, string>;
130137
jq?: string;
138+
outputFormat?: 'toon' | 'json';
131139
}) => Promise<{ content: string }>,
132140
): void {
133141
program
@@ -143,6 +151,11 @@ function registerWriteCommand(
143151
'--jq <expression>',
144152
'JMESPath expression to filter/transform the response.',
145153
)
154+
.option(
155+
'-o, --output-format <format>',
156+
'Output format: "toon" (default, token-efficient) or "json".',
157+
'toon',
158+
)
146159
.action(async (options) => {
147160
const actionLogger = cliLogger.forMethod(name);
148161
try {
@@ -168,6 +181,7 @@ function registerWriteCommand(
168181
body,
169182
queryParams,
170183
jq: options.jq,
184+
outputFormat: options.outputFormat as 'toon' | 'json',
171185
});
172186

173187
console.log(result.content);

src/controllers/atlassian.api.controller.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
GetApiToolArgsType,
1010
RequestWithBodyArgsType,
1111
} from '../tools/atlassian.api.types.js';
12-
import { applyJqFilter, toJsonString } from '../utils/jq.util.js';
12+
import { applyJqFilter, toOutputString } from '../utils/jq.util.js';
1313
import { createAuthMissingError } from '../utils/error.util.js';
1414

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

23+
/**
24+
* Output format type
25+
*/
26+
type OutputFormat = 'toon' | 'json';
27+
2328
/**
2429
* Base options for all API requests
2530
*/
2631
interface BaseRequestOptions {
2732
path: string;
2833
queryParams?: Record<string, string>;
2934
jq?: string;
35+
outputFormat?: OutputFormat;
3036
}
3137

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

128+
// Convert to output format (TOON by default, JSON if requested)
129+
const useToon = options.outputFormat !== 'json';
130+
const content = await toOutputString(result, useToon);
131+
122132
return {
123-
content: toJsonString(result),
133+
content,
124134
};
125135
} catch (error) {
126136
throw handleControllerError(error, {

src/tools/atlassian.api.tool.ts

Lines changed: 54 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -125,26 +125,27 @@ function registerTools(server: McpServer) {
125125
// Register the GET tool
126126
server.tool(
127127
'conf_get',
128-
`Read any Confluence data. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
128+
`Read any Confluence data. Returns TOON format by default (30-60% fewer tokens than JSON).
129+
130+
**IMPORTANT - Cost Optimization:**
131+
- ALWAYS use \`jq\` param to filter response fields. Unfiltered responses are very expensive!
132+
- Use \`limit\` query param to restrict result count (e.g., \`limit: "5"\`)
133+
- 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
134+
135+
**Schema Discovery Pattern:**
136+
1. First call: \`path: "/wiki/api/v2/spaces", queryParams: {"limit": "1"}\` (no jq) - explore available fields
137+
2. Then use: \`jq: "results[*].{id: id, key: key, name: name}"\` - extract only what you need
138+
139+
**Output format:** TOON (default, token-efficient) or JSON (\`outputFormat: "json"\`)
129140
130141
**Common paths:**
131-
- \`/wiki/api/v2/spaces\` - list all spaces
132-
- \`/wiki/api/v2/spaces/{id}\` - get space details
133-
- \`/wiki/api/v2/pages\` - list pages (use \`space-id\` query param to filter)
142+
- \`/wiki/api/v2/spaces\` - list spaces
143+
- \`/wiki/api/v2/pages\` - list pages (use \`space-id\` query param)
134144
- \`/wiki/api/v2/pages/{id}\` - get page details
135-
- \`/wiki/api/v2/pages/{id}/body\` - get page body (use \`body-format\` param: storage, atlas_doc_format, view)
136-
- \`/wiki/api/v2/pages/{id}/children\` - get child pages
137-
- \`/wiki/api/v2/pages/{id}/labels\` - get page labels
138-
- \`/wiki/api/v2/pages/{id}/footer-comments\` - get page comments
139-
- \`/wiki/api/v2/blogposts\` - list blog posts
140-
- \`/wiki/api/v2/blogposts/{id}\` - get blog post
141-
- \`/wiki/api/v2/labels/{id}\` - get label details
142-
- \`/wiki/api/v2/content/{id}/attachments\` - get content attachments
143-
- \`/wiki/rest/api/search\` - search content (use \`cql\` query param)
145+
- \`/wiki/api/v2/pages/{id}/body\` - get page body (\`body-format\`: storage, atlas_doc_format, view)
146+
- \`/wiki/rest/api/search\` - search content (\`cql\` query param)
144147
145-
**Query params:** \`limit\` (page size, default 25), \`cursor\` (pagination), \`space-id\` (filter by space), \`body-format\` (storage, atlas_doc_format, view)
146-
147-
**Example CQL queries:** \`type=page AND space=DEV\`, \`title~"search term"\`, \`label=important\`, \`creator=currentUser()\`
148+
**JQ examples:** \`results[*].id\`, \`results[0]\`, \`results[*].{id: id, title: title}\`
148149
149150
API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
150151
GetApiToolArgs.shape,
@@ -154,24 +155,25 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
154155
// Register the POST tool
155156
server.tool(
156157
'conf_post',
157-
`Create Confluence resources. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
158+
`Create Confluence resources. Returns TOON format by default (token-efficient).
159+
160+
**IMPORTANT - Cost Optimization:**
161+
- Use \`jq\` param to extract only needed fields from response (e.g., \`jq: "{id: id, title: title}"\`)
162+
- Unfiltered responses include all metadata and are expensive!
163+
164+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
158165
159166
**Common operations:**
160167
161168
1. **Create page:** \`/wiki/api/v2/pages\`
162-
body: \`{"spaceId": "123456", "status": "current", "title": "Page Title", "parentId": "789", "body": {"representation": "storage", "value": "<p>Content here</p>"}}\`
169+
body: \`{"spaceId": "123456", "status": "current", "title": "Page Title", "parentId": "789", "body": {"representation": "storage", "value": "<p>Content</p>"}}\`
163170
164171
2. **Create blog post:** \`/wiki/api/v2/blogposts\`
165-
body: \`{"spaceId": "123456", "status": "current", "title": "Blog Title", "body": {"representation": "storage", "value": "<p>Blog content</p>"}}\`
166-
167-
3. **Add label to page:** \`/wiki/api/v2/pages/{id}/labels\`
168-
body: \`{"name": "label-name"}\` (labels must be lowercase, no spaces)
172+
body: \`{"spaceId": "123456", "status": "current", "title": "Blog Title", "body": {"representation": "storage", "value": "<p>Content</p>"}}\`
169173
170-
4. **Add footer comment:** \`/wiki/api/v2/pages/{id}/footer-comments\`
171-
body: \`{"body": {"representation": "storage", "value": "<p>Comment text</p>"}}\`
174+
3. **Add label:** \`/wiki/api/v2/pages/{id}/labels\` - body: \`{"name": "label-name"}\`
172175
173-
5. **Create inline comment:** \`/wiki/api/v2/pages/{id}/inline-comments\`
174-
body: \`{"body": {"representation": "storage", "value": "<p>Inline comment</p>"}, "inlineCommentProperties": {"textSelection": "text to anchor to"}}\`
176+
4. **Add comment:** \`/wiki/api/v2/pages/{id}/footer-comments\`
175177
176178
API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
177179
RequestWithBodyArgs.shape,
@@ -181,18 +183,23 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
181183
// Register the PUT tool
182184
server.tool(
183185
'conf_put',
184-
`Replace Confluence resources (full update). Returns JSON, optionally filtered with JMESPath (\`jq\` param).
186+
`Replace Confluence resources (full update). Returns TOON format by default.
187+
188+
**IMPORTANT - Cost Optimization:**
189+
- Use \`jq\` param to extract only needed fields from response
190+
- Example: \`jq: "{id: id, version: version.number}"\`
191+
192+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
185193
186194
**Common operations:**
187195
188196
1. **Update page:** \`/wiki/api/v2/pages/{id}\`
189-
body: \`{"id": "123", "status": "current", "title": "Updated Title", "spaceId": "456", "body": {"representation": "storage", "value": "<p>Updated content</p>"}, "version": {"number": 2, "message": "Update reason"}}\`
190-
Note: version.number must be incremented from current version
197+
body: \`{"id": "123", "status": "current", "title": "Updated Title", "spaceId": "456", "body": {"representation": "storage", "value": "<p>Content</p>"}, "version": {"number": 2}}\`
198+
Note: version.number must be incremented
191199
192200
2. **Update blog post:** \`/wiki/api/v2/blogposts/{id}\`
193-
body: \`{"id": "123", "status": "current", "title": "Updated Blog", "spaceId": "456", "body": {"representation": "storage", "value": "<p>Updated content</p>"}, "version": {"number": 2}}\`
194201
195-
Note: PUT replaces the entire resource. Version number must be incremented.
202+
Note: PUT replaces entire resource. Version number must be incremented.
196203
197204
API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
198205
RequestWithBodyArgs.shape,
@@ -202,19 +209,20 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
202209
// Register the PATCH tool
203210
server.tool(
204211
'conf_patch',
205-
`Partially update Confluence resources. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
212+
`Partially update Confluence resources. Returns TOON format by default.
206213
207-
**Common operations:**
214+
**IMPORTANT - Cost Optimization:** Use \`jq\` param to filter response fields.
215+
216+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
208217
209-
Note: Confluence v2 API primarily uses PUT for updates. PATCH is available for some endpoints:
218+
**Common operations:**
210219
211220
1. **Update space:** \`/wiki/api/v2/spaces/{id}\`
212-
body: \`{"name": "New Space Name", "description": {"plain": {"value": "Description", "representation": "plain"}}}\`
221+
body: \`{"name": "New Name", "description": {"plain": {"value": "Desc", "representation": "plain"}}}\`
213222
214-
2. **Update footer comment:** \`/wiki/api/v2/footer-comments/{comment-id}\`
215-
body: \`{"version": {"number": 2}, "body": {"representation": "storage", "value": "<p>Updated comment</p>"}}\`
223+
2. **Update comment:** \`/wiki/api/v2/footer-comments/{id}\`
216224
217-
For page content updates, use PUT with the full page object including incremented version number.
225+
Note: Confluence v2 API primarily uses PUT for updates.
218226
219227
API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
220228
RequestWithBodyArgs.shape,
@@ -224,24 +232,16 @@ API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/`,
224232
// Register the DELETE tool
225233
server.tool(
226234
'conf_delete',
227-
`Delete Confluence resources. Returns JSON (if any), optionally filtered with JMESPath (\`jq\` param).
228-
229-
**Common operations:**
235+
`Delete Confluence resources. Returns TOON format by default.
230236
231-
1. **Delete page:** \`/wiki/api/v2/pages/{id}\`
232-
Returns 204 No Content on success
237+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
233238
234-
2. **Delete blog post:** \`/wiki/api/v2/blogposts/{id}\`
235-
Returns 204 No Content on success
236-
237-
3. **Remove label from page:** \`/wiki/api/v2/pages/{id}/labels/{label-id}\`
238-
Returns 204 No Content on success
239-
240-
4. **Delete comment:** \`/wiki/api/v2/footer-comments/{comment-id}\`
241-
Returns 204 No Content on success
242-
243-
5. **Delete attachment:** \`/wiki/api/v2/attachments/{attachment-id}\`
244-
Returns 204 No Content on success
239+
**Common operations:**
240+
- \`/wiki/api/v2/pages/{id}\` - Delete page
241+
- \`/wiki/api/v2/blogposts/{id}\` - Delete blog post
242+
- \`/wiki/api/v2/pages/{id}/labels/{label-id}\` - Remove label
243+
- \`/wiki/api/v2/footer-comments/{id}\` - Delete comment
244+
- \`/wiki/api/v2/attachments/{id}\` - Delete attachment
245245
246246
Note: Most DELETE endpoints return 204 No Content on success.
247247

src/tools/atlassian.api.types.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import { z } from 'zod';
22

3+
/**
4+
* Output format options for API responses
5+
* - toon: Token-Oriented Object Notation (default, more token-efficient for LLMs)
6+
* - json: Standard JSON format
7+
*/
8+
export const OutputFormat = z
9+
.enum(['toon', 'json'])
10+
.optional()
11+
.describe(
12+
'Output format: "toon" (default, 30-60% fewer tokens) or "json". TOON is optimized for LLMs with tabular arrays and minimal syntax.',
13+
);
14+
315
/**
416
* Base schema fields shared by all API tool arguments
5-
* Contains path, queryParams, and jq filter
17+
* Contains path, queryParams, jq filter, and outputFormat
618
*/
719
const BaseApiToolArgs = {
820
/**
@@ -33,13 +45,20 @@ const BaseApiToolArgs = {
3345

3446
/**
3547
* Optional JMESPath expression to filter/transform the response
48+
* IMPORTANT: Always use this to reduce response size and token costs
3649
*/
3750
jq: z
3851
.string()
3952
.optional()
4053
.describe(
41-
'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.',
54+
'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',
4255
),
56+
57+
/**
58+
* Output format for the response
59+
* Defaults to TOON (token-efficient), can be set to JSON if needed
60+
*/
61+
outputFormat: OutputFormat,
4362
};
4463

4564
/**

src/utils/jq.util.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import jmespath from 'jmespath';
22
import { Logger } from './logger.util.js';
3+
import { toToonOrJson } from './toon.util.js';
34

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

@@ -60,3 +61,31 @@ export function toJsonString(data: unknown, pretty: boolean = true): string {
6061
}
6162
return JSON.stringify(data);
6263
}
64+
65+
/**
66+
* Convert data to output string for MCP response
67+
*
68+
* By default, converts to TOON format (Token-Oriented Object Notation)
69+
* for improved LLM token efficiency (30-60% fewer tokens).
70+
* Falls back to JSON if TOON conversion fails or if useToon is false.
71+
*
72+
* @param data - The data to convert
73+
* @param useToon - Whether to use TOON format (default: true)
74+
* @param pretty - Whether to pretty-print JSON (default: true)
75+
* @returns TOON formatted string (default), or JSON string
76+
*/
77+
export async function toOutputString(
78+
data: unknown,
79+
useToon: boolean = true,
80+
pretty: boolean = true,
81+
): Promise<string> {
82+
const jsonString = toJsonString(data, pretty);
83+
84+
// Return JSON directly if TOON is not requested
85+
if (!useToon) {
86+
return jsonString;
87+
}
88+
89+
// Try TOON conversion with JSON fallback
90+
return toToonOrJson(data, jsonString);
91+
}

0 commit comments

Comments
 (0)