Skip to content

Commit 50de5b9

Browse files
authored
feat: add TOON output format for token-efficient LLM responses (#155)
Implement TOON (Token-Oriented Object Notation) as the default output format for all Jira API responses, providing 30-60% token reduction compared to JSON. Changes: - Add @toon-format/toon package dependency - Create toon.util.ts with TOON conversion logic and JSON fallback - Add toOutputString function to jq.util.ts for format conversion - Add outputFormat parameter to all tool types (toon/json) - Update controller to respect outputFormat parameter (defaults to TOON) - Add --output-format CLI option to all commands - Update all tool descriptions with: * TOON as default output format * Cost optimization warnings (always use jq filter) * Schema discovery pattern (fetch one item first to explore fields) - Add comprehensive test coverage for TOON utilities TOON conversion falls back to JSON if encoding fails, ensuring reliability.
1 parent e56470c commit 50de5b9

9 files changed

Lines changed: 412 additions & 17 deletions

File tree

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: 19 additions & 5 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+
'--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+
'--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);
@@ -193,39 +207,39 @@ function register(program: Command): void {
193207
registerReadCommand(
194208
program,
195209
'get',
196-
'GET any Jira endpoint. Returns JSON, optionally filtered with JMESPath.',
210+
'GET any Jira endpoint. Returns TOON by default (or JSON with --output-format json), optionally filtered with JMESPath.',
197211
handleGet,
198212
);
199213

200214
// Register POST command
201215
registerWriteCommand(
202216
program,
203217
'post',
204-
'POST to any Jira endpoint. Returns JSON, optionally filtered with JMESPath.',
218+
'POST to any Jira endpoint. Returns TOON by default (or JSON with --output-format json), optionally filtered with JMESPath.',
205219
handlePost,
206220
);
207221

208222
// Register PUT command
209223
registerWriteCommand(
210224
program,
211225
'put',
212-
'PUT to any Jira endpoint. Returns JSON, optionally filtered with JMESPath.',
226+
'PUT to any Jira endpoint. Returns TOON by default (or JSON with --output-format json), optionally filtered with JMESPath.',
213227
handlePut,
214228
);
215229

216230
// Register PATCH command
217231
registerWriteCommand(
218232
program,
219233
'patch',
220-
'PATCH any Jira endpoint. Returns JSON, optionally filtered with JMESPath.',
234+
'PATCH any Jira endpoint. Returns TOON by default (or JSON with --output-format json), optionally filtered with JMESPath.',
221235
handlePatch,
222236
);
223237

224238
// Register DELETE command
225239
registerReadCommand(
226240
program,
227241
'delete',
228-
'DELETE any Jira endpoint. Returns JSON (if any), optionally filtered with JMESPath.',
242+
'DELETE any Jira endpoint. Returns TOON by default (or JSON with --output-format json, if any), optionally filtered with JMESPath.',
229243
handleDelete,
230244
);
231245

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: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,18 @@ function registerTools(server: McpServer) {
125125
// Register the GET tool
126126
server.tool(
127127
'jira_get',
128-
`Read any Jira data. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
128+
`Read any Jira 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 \`maxResults\` query param to restrict result count (e.g., \`maxResults: "5"\`)
133+
- If unsure about available fields, first fetch ONE item with \`maxResults: "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: "/rest/api/3/search", queryParams: {"maxResults": "1", "jql": "project=PROJ"}\` (no jq) - explore available fields
137+
2. Then use: \`jq: "issues[*].{key: key, summary: fields.summary, status: fields.status.name}"\` - extract only what you need
138+
139+
**Output format:** TOON (default, token-efficient) or JSON (\`outputFormat: "json"\`)
129140
130141
**Common paths:**
131142
- \`/rest/api/3/project\` - list all projects
@@ -140,7 +151,7 @@ function registerTools(server: McpServer) {
140151
- \`/rest/api/3/issuetype\` - list issue types
141152
- \`/rest/api/3/priority\` - list priorities
142153
143-
**Query params:** \`maxResults\` (page size), \`startAt\` (offset), \`jql\` (JQL query), \`fields\` (field selection), \`expand\` (include additional data)
154+
**JQ examples:** \`issues[*].key\`, \`issues[0]\`, \`issues[*].{key: key, summary: fields.summary}\`
144155
145156
**Example JQL queries:** \`project=PROJ\`, \`assignee=currentUser()\`, \`status="In Progress"\`, \`created >= -7d\`
146157
@@ -152,7 +163,13 @@ API reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/`,
152163
// Register the POST tool
153164
server.tool(
154165
'jira_post',
155-
`Create Jira resources. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
166+
`Create Jira resources. Returns TOON format by default (token-efficient).
167+
168+
**IMPORTANT - Cost Optimization:**
169+
- Use \`jq\` param to extract only needed fields from response (e.g., \`jq: "{key: key, id: id}"\`)
170+
- Unfiltered responses include all metadata and are expensive!
171+
172+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
156173
157174
**Common operations:**
158175
@@ -179,7 +196,11 @@ API reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/`,
179196
// Register the PUT tool
180197
server.tool(
181198
'jira_put',
182-
`Replace Jira resources (full update). Returns JSON, optionally filtered with JMESPath (\`jq\` param).
199+
`Replace Jira resources (full update). Returns TOON format by default.
200+
201+
**IMPORTANT - Cost Optimization:** Use \`jq\` param to extract only needed fields from response
202+
203+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
183204
184205
**Common operations:**
185206
@@ -202,7 +223,11 @@ API reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/`,
202223
// Register the PATCH tool
203224
server.tool(
204225
'jira_patch',
205-
`Partially update Jira resources. Returns JSON, optionally filtered with JMESPath (\`jq\` param).
226+
`Partially update Jira resources. Returns TOON format by default.
227+
228+
**IMPORTANT - Cost Optimization:** Use \`jq\` param to filter response fields.
229+
230+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
206231
207232
**Common operations:**
208233
@@ -225,7 +250,9 @@ API reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/`,
225250
// Register the DELETE tool
226251
server.tool(
227252
'jira_delete',
228-
`Delete Jira resources. Returns JSON (if any), optionally filtered with JMESPath (\`jq\` param).
253+
`Delete Jira resources. Returns TOON format by default.
254+
255+
**Output format:** TOON (default) or JSON (\`outputFormat: "json"\`)
229256
230257
**Common operations:**
231258

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: "issues[*].key" (extract keys), "total" (single field), "{key: key, summary: fields.summary}" (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: "issues[*].{key: key, summary: fields.summary}" (extract specific fields), "issues[0]" (first result), "issues[*].key" (keys 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)