toonify-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ # Claude Code Toonify Hook
2
+
3
+ Automatic TOON format optimization hook for Claude Code CLI.
4
+
5
+ ## What This Does
6
+
7
+ This hook **automatically intercepts** tool results from Read, Grep, and other file operations, detects structured data (JSON/CSV/YAML), and applies TOON format optimization when token savings > 30%.
8
+
9
+ **No manual calls required** - optimization happens transparently.
10
+
11
+ ## Installation
12
+
13
+ ### 1. Build the hook
14
+
15
+ ```bash
16
+ cd hooks/
17
+ npm install
18
+ npm run build
19
+ ```
20
+
21
+ ### 2. Register with Claude Code
22
+
23
+ ```bash
24
+ # From hooks/ directory
25
+ npm run install-hook
26
+
27
+ # Or manually:
28
+ claude hooks add PostToolUse -- node /Users/ktseng/Developer/Projects/toonify-mcp/hooks/post-tool-use.js
29
+ ```
30
+
31
+ ### 3. Verify installation
32
+
33
+ ```bash
34
+ claude hooks list
35
+ # Should show: PostToolUse: node .../post-tool-use.js
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ Create `~/.claude/toonify-hook-config.json`:
41
+
42
+ ```json
43
+ {
44
+ "enabled": true,
45
+ "minTokensThreshold": 50,
46
+ "minSavingsThreshold": 30,
47
+ "skipToolPatterns": ["Bash", "Write", "Edit"]
48
+ }
49
+ ```
50
+
51
+ ### Options
52
+
53
+ - `enabled`: Enable/disable automatic optimization (default: true)
54
+ - `minTokensThreshold`: Minimum tokens before optimization (default: 50)
55
+ - `minSavingsThreshold`: Minimum savings percentage required (default: 30%)
56
+ - `skipToolPatterns`: Tools to never optimize (default: ["Bash", "Write", "Edit"])
57
+
58
+ ### Environment Variables
59
+
60
+ - `TOONIFY_SHOW_STATS=true` - Show optimization stats in output
61
+
62
+ ## How It Works
63
+
64
+ ```
65
+ User: Read large JSON file
66
+
67
+ Claude Code calls Read tool
68
+
69
+ PostToolUse hook intercepts result
70
+
71
+ Hook detects JSON, converts to TOON
72
+
73
+ Optimized content sent to Claude API
74
+
75
+ 60% token reduction achieved
76
+ ```
77
+
78
+ ## vs MCP Server
79
+
80
+ | Feature | MCP Server | PostToolUse Hook |
81
+ |---------|-----------|------------------|
82
+ | **Activation** | Manual (call tool) | Automatic (intercept) |
83
+ | **Compatibility** | Any MCP client | Claude Code only |
84
+ | **Configuration** | MCP tools | Hook config file |
85
+ | **Performance** | Call overhead | Zero overhead |
86
+
87
+ **Recommendation**: Use PostToolUse hook for automatic optimization in Claude Code. Use MCP server for explicit control or other MCP clients.
88
+
89
+ ## Uninstall
90
+
91
+ ```bash
92
+ claude hooks remove PostToolUse
93
+ rm ~/.claude/toonify-hook-config.json
94
+ ```
95
+
96
+ ## Troubleshooting
97
+
98
+ ### Hook not triggering
99
+
100
+ ```bash
101
+ # Check if hook is registered
102
+ claude hooks list
103
+
104
+ # Check configuration
105
+ cat ~/.claude/toonify-hook-config.json
106
+
107
+ # Test manually
108
+ echo '{"toolName":"Read","toolResult":{"content":[{"type":"text","text":"{\"test\":123}"}]}}' | node post-tool-use.js
109
+ ```
110
+
111
+ ### Optimization not applied
112
+
113
+ - Check `minTokensThreshold` - content might be too small
114
+ - Check `minSavingsThreshold` - savings might be < 30%
115
+ - Check `skipToolPatterns` - tool might be in skip list
116
+ - Verify content is valid JSON/CSV/YAML
117
+
118
+ ## Examples
119
+
120
+ **Before (142 tokens)**:
121
+ ```json
122
+ {
123
+ "products": [
124
+ {"id": 101, "name": "Laptop Pro", "price": 1299},
125
+ {"id": 102, "name": "Magic Mouse", "price": 79}
126
+ ]
127
+ }
128
+ ```
129
+
130
+ **After (57 tokens, -60%)**:
131
+ ```
132
+ [TOON-JSON]
133
+ products[2]{id,name,price}:
134
+ 101,Laptop Pro,1299
135
+ 102,Magic Mouse,79
136
+ ```
137
+
138
+ **Automatically applied** - no manual calls needed!
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "toonify-mcp-hook",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code hook for automatic TOON format optimization",
5
+ "type": "module",
6
+ "main": "post-tool-use.js",
7
+ "bin": {
8
+ "toonify-hook": "./post-tool-use.js"
9
+ },
10
+ "dependencies": {
11
+ "@toon-format/toon": "^2.1.0",
12
+ "tiktoken": "^1.0.0",
13
+ "yaml": "^2.3.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^20.0.0",
17
+ "typescript": "^5.0.0"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "install-hook": "npm run build && claude hooks add PostToolUse -- node $(pwd)/post-tool-use.js"
22
+ }
23
+ }
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code PostToolUse Hook for Automatic Toonify Optimization
4
+ */
5
+ import { encode as toonEncode } from '@toon-format/toon';
6
+ import { encoding_for_model } from 'tiktoken';
7
+ import * as yaml from 'yaml';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+ class ToonifyHook {
12
+ config;
13
+ encoder;
14
+ constructor() {
15
+ this.config = this.loadConfig();
16
+ this.encoder = encoding_for_model('gpt-4');
17
+ }
18
+ loadConfig() {
19
+ const configPath = path.join(os.homedir(), '.claude', 'toonify-hook-config.json');
20
+ const defaultConfig = {
21
+ enabled: true,
22
+ minTokensThreshold: 50,
23
+ minSavingsThreshold: 30,
24
+ skipToolPatterns: ['Bash', 'Write', 'Edit']
25
+ };
26
+ try {
27
+ if (fs.existsSync(configPath)) {
28
+ const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
29
+ return { ...defaultConfig, ...userConfig };
30
+ }
31
+ }
32
+ catch (error) {
33
+ console.error('[Toonify Hook] Config load error:', error);
34
+ }
35
+ return defaultConfig;
36
+ }
37
+ shouldSkipTool(toolName) {
38
+ return this.config.skipToolPatterns.some(pattern => toolName.toLowerCase().includes(pattern.toLowerCase()));
39
+ }
40
+ countTokens(text) {
41
+ try {
42
+ return this.encoder.encode(text).length;
43
+ }
44
+ catch (error) {
45
+ return Math.ceil(text.length / 4);
46
+ }
47
+ }
48
+ detectStructuredData(content) {
49
+ try {
50
+ const data = JSON.parse(content);
51
+ if (typeof data === 'object' && data !== null) {
52
+ return { type: 'json', data };
53
+ }
54
+ }
55
+ catch { }
56
+ try {
57
+ const data = yaml.parse(content);
58
+ if (typeof data === 'object' && data !== null && !content.includes(',')) {
59
+ return { type: 'yaml', data };
60
+ }
61
+ }
62
+ catch { }
63
+ const lines = content.trim().split('\n');
64
+ if (lines.length > 1) {
65
+ const firstLine = lines[0];
66
+ const delimiter = firstLine.includes('\t') ? '\t' : ',';
67
+ const columnCount = firstLine.split(delimiter).length;
68
+ if (columnCount > 1 && lines.every(line => line.split(delimiter).length === columnCount)) {
69
+ const headers = firstLine.split(delimiter);
70
+ const rows = lines.slice(1).map(line => {
71
+ const values = line.split(delimiter);
72
+ return headers.reduce((obj, header, i) => {
73
+ obj[header.trim()] = values[i]?.trim() || '';
74
+ return obj;
75
+ }, {});
76
+ });
77
+ return { type: 'csv', data: rows };
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ optimize(content) {
83
+ if (!this.config.enabled) {
84
+ return { optimized: false, content };
85
+ }
86
+ const originalTokens = this.countTokens(content);
87
+ if (originalTokens < this.config.minTokensThreshold) {
88
+ return { optimized: false, content };
89
+ }
90
+ const structured = this.detectStructuredData(content);
91
+ if (!structured) {
92
+ return { optimized: false, content };
93
+ }
94
+ try {
95
+ const toonContent = toonEncode(structured.data);
96
+ const optimizedTokens = this.countTokens(toonContent);
97
+ const savings = ((originalTokens - optimizedTokens) / originalTokens) * 100;
98
+ if (savings < this.config.minSavingsThreshold) {
99
+ return { optimized: false, content };
100
+ }
101
+ const optimizedContent = `[TOON-${structured.type.toUpperCase()}]\n${toonContent}`;
102
+ return {
103
+ optimized: true,
104
+ content: optimizedContent,
105
+ stats: {
106
+ originalTokens,
107
+ optimizedTokens,
108
+ savings: Math.round(savings)
109
+ }
110
+ };
111
+ }
112
+ catch (error) {
113
+ return { optimized: false, content };
114
+ }
115
+ }
116
+ async process(input) {
117
+ if (this.shouldSkipTool(input.toolName)) {
118
+ return input;
119
+ }
120
+ const processedContent = input.toolResult.content.map(block => {
121
+ if (block.type === 'text' && block.text) {
122
+ const result = this.optimize(block.text);
123
+ if (result.optimized && result.stats) {
124
+ const notice = `\n\n[Token Optimization: ${result.stats.originalTokens} → ${result.stats.optimizedTokens} tokens (-${result.stats.savings}%)]`;
125
+ return {
126
+ ...block,
127
+ text: result.content + (process.env.TOONIFY_SHOW_STATS === 'true' ? notice : '')
128
+ };
129
+ }
130
+ return block;
131
+ }
132
+ return block;
133
+ });
134
+ return {
135
+ ...input,
136
+ toolResult: {
137
+ ...input.toolResult,
138
+ content: processedContent
139
+ }
140
+ };
141
+ }
142
+ }
143
+ async function main() {
144
+ try {
145
+ const stdin = await new Promise((resolve) => {
146
+ const chunks = [];
147
+ process.stdin.on('data', chunk => chunks.push(chunk));
148
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
149
+ });
150
+ const input = JSON.parse(stdin);
151
+ const hook = new ToonifyHook();
152
+ const output = await hook.process(input);
153
+ process.stdout.write(JSON.stringify(output, null, 2));
154
+ process.exit(0);
155
+ }
156
+ catch (error) {
157
+ process.stdout.write(process.stdin.read());
158
+ process.exit(0);
159
+ }
160
+ }
161
+ main();
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code PostToolUse Hook for Automatic Toonify Optimization
4
+ */
5
+
6
+ import { encode as toonEncode } from '@toon-format/toon';
7
+ import { encoding_for_model } from 'tiktoken';
8
+ import * as yaml from 'yaml';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as os from 'os';
12
+
13
+ interface HookInput {
14
+ toolName: string;
15
+ toolInput: Record<string, unknown>;
16
+ toolResult: {
17
+ content: Array<{ type: string; text?: string }>;
18
+ };
19
+ }
20
+
21
+ interface OptimizationConfig {
22
+ enabled: boolean;
23
+ minTokensThreshold: number;
24
+ minSavingsThreshold: number;
25
+ skipToolPatterns: string[];
26
+ }
27
+
28
+ class ToonifyHook {
29
+ private config: OptimizationConfig;
30
+ private encoder;
31
+
32
+ constructor() {
33
+ this.config = this.loadConfig();
34
+ this.encoder = encoding_for_model('gpt-4');
35
+ }
36
+
37
+ private loadConfig(): OptimizationConfig {
38
+ const configPath = path.join(os.homedir(), '.claude', 'toonify-hook-config.json');
39
+ const defaultConfig: OptimizationConfig = {
40
+ enabled: true,
41
+ minTokensThreshold: 50,
42
+ minSavingsThreshold: 30,
43
+ skipToolPatterns: ['Bash', 'Write', 'Edit']
44
+ };
45
+
46
+ try {
47
+ if (fs.existsSync(configPath)) {
48
+ const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
49
+ return { ...defaultConfig, ...userConfig };
50
+ }
51
+ } catch (error) {
52
+ console.error('[Toonify Hook] Config load error:', error);
53
+ }
54
+
55
+ return defaultConfig;
56
+ }
57
+
58
+ private shouldSkipTool(toolName: string): boolean {
59
+ return this.config.skipToolPatterns.some(pattern =>
60
+ toolName.toLowerCase().includes(pattern.toLowerCase())
61
+ );
62
+ }
63
+
64
+ private countTokens(text: string): number {
65
+ try {
66
+ return this.encoder.encode(text).length;
67
+ } catch (error) {
68
+ return Math.ceil(text.length / 4);
69
+ }
70
+ }
71
+
72
+ private detectStructuredData(content: string): { type: 'json' | 'csv' | 'yaml'; data: any } | null {
73
+ try {
74
+ const data = JSON.parse(content);
75
+ if (typeof data === 'object' && data !== null) {
76
+ return { type: 'json', data };
77
+ }
78
+ } catch {}
79
+
80
+ try {
81
+ const data = yaml.parse(content);
82
+ if (typeof data === 'object' && data !== null && !content.includes(',')) {
83
+ return { type: 'yaml', data };
84
+ }
85
+ } catch {}
86
+
87
+ const lines = content.trim().split('\n');
88
+ if (lines.length > 1) {
89
+ const firstLine = lines[0];
90
+ const delimiter = firstLine.includes('\t') ? '\t' : ',';
91
+ const columnCount = firstLine.split(delimiter).length;
92
+
93
+ if (columnCount > 1 && lines.every(line => line.split(delimiter).length === columnCount)) {
94
+ const headers = firstLine.split(delimiter);
95
+ const rows = lines.slice(1).map(line => {
96
+ const values = line.split(delimiter);
97
+ return headers.reduce((obj, header, i) => {
98
+ obj[header.trim()] = values[i]?.trim() || '';
99
+ return obj;
100
+ }, {} as Record<string, string>);
101
+ });
102
+ return { type: 'csv', data: rows };
103
+ }
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ private optimize(content: string): { optimized: boolean; content: string; stats?: { originalTokens: number; optimizedTokens: number; savings: number } } {
110
+ if (!this.config.enabled) {
111
+ return { optimized: false, content };
112
+ }
113
+
114
+ const originalTokens = this.countTokens(content);
115
+ if (originalTokens < this.config.minTokensThreshold) {
116
+ return { optimized: false, content };
117
+ }
118
+
119
+ const structured = this.detectStructuredData(content);
120
+ if (!structured) {
121
+ return { optimized: false, content };
122
+ }
123
+
124
+ try {
125
+ const toonContent = toonEncode(structured.data);
126
+ const optimizedTokens = this.countTokens(toonContent);
127
+ const savings = ((originalTokens - optimizedTokens) / originalTokens) * 100;
128
+
129
+ if (savings < this.config.minSavingsThreshold) {
130
+ return { optimized: false, content };
131
+ }
132
+
133
+ const optimizedContent = `[TOON-${structured.type.toUpperCase()}]\n${toonContent}`;
134
+
135
+ return {
136
+ optimized: true,
137
+ content: optimizedContent,
138
+ stats: {
139
+ originalTokens,
140
+ optimizedTokens,
141
+ savings: Math.round(savings)
142
+ }
143
+ };
144
+ } catch (error) {
145
+ return { optimized: false, content };
146
+ }
147
+ }
148
+
149
+ async process(input: HookInput): Promise<HookInput> {
150
+ if (this.shouldSkipTool(input.toolName)) {
151
+ return input;
152
+ }
153
+
154
+ const processedContent = input.toolResult.content.map(block => {
155
+ if (block.type === 'text' && block.text) {
156
+ const result = this.optimize(block.text);
157
+
158
+ if (result.optimized && result.stats) {
159
+ const notice = `\n\n[Token Optimization: ${result.stats.originalTokens} → ${result.stats.optimizedTokens} tokens (-${result.stats.savings}%)]`;
160
+ return {
161
+ ...block,
162
+ text: result.content + (process.env.TOONIFY_SHOW_STATS === 'true' ? notice : '')
163
+ };
164
+ }
165
+
166
+ return block;
167
+ }
168
+ return block;
169
+ });
170
+
171
+ return {
172
+ ...input,
173
+ toolResult: {
174
+ ...input.toolResult,
175
+ content: processedContent
176
+ }
177
+ };
178
+ }
179
+ }
180
+
181
+ async function main() {
182
+ try {
183
+ const stdin = await new Promise<string>((resolve) => {
184
+ const chunks: Buffer[] = [];
185
+ process.stdin.on('data', chunk => chunks.push(chunk));
186
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
187
+ });
188
+
189
+ const input: HookInput = JSON.parse(stdin);
190
+ const hook = new ToonifyHook();
191
+ const output = await hook.process(input);
192
+
193
+ process.stdout.write(JSON.stringify(output, null, 2));
194
+ process.exit(0);
195
+ } catch (error) {
196
+ process.stdout.write(process.stdin.read());
197
+ process.exit(0);
198
+ }
199
+ }
200
+
201
+ main();
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "node",
7
+ "outDir": ".",
8
+ "rootDir": ".",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": false
15
+ },
16
+ "include": ["post-tool-use.ts"],
17
+ "exclude": ["node_modules", "**/*.test.ts"]
18
+ }
package/jest.config.js ADDED
@@ -0,0 +1,23 @@
1
+ /** @type {import('jest').Config} */
2
+ export default {
3
+ preset: 'ts-jest/presets/default-esm',
4
+ testEnvironment: 'node',
5
+ extensionsToTreatAsEsm: ['.ts'],
6
+ moduleNameMapper: {
7
+ '^(\\.{1,2}/.*)\\.js$': '$1',
8
+ },
9
+ transform: {
10
+ '^.+\\.tsx?$': [
11
+ 'ts-jest',
12
+ {
13
+ useESM: true,
14
+ },
15
+ ],
16
+ },
17
+ transformIgnorePatterns: [
18
+ 'node_modules/(?!(@toon-format|tiktoken)/)',
19
+ ],
20
+ testMatch: ['**/tests/**/*.test.ts'],
21
+ collectCoverageFrom: ['src/**/*.ts'],
22
+ coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
23
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "toonify-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for TOON format token optimization - works with any MCP client (Claude Code, ChatGPT, etc.)",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "toonify-mcp": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsc --watch",
13
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest",
14
+ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch",
15
+ "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
16
+ "prepare": "npm run build"
17
+ },
18
+ "keywords": [
19
+ "claude",
20
+ "claude-code",
21
+ "mcp",
22
+ "token-optimization",
23
+ "toonify",
24
+ "llm-optimization"
25
+ ],
26
+ "author": "ktseng",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.0.0",
30
+ "@toon-format/toon": "^2.1.0",
31
+ "tiktoken": "^1.0.0",
32
+ "yaml": "^2.3.0"
33
+ },
34
+ "devDependencies": {
35
+ "@jest/globals": "^30.2.0",
36
+ "@types/jest": "^29.0.0",
37
+ "@types/node": "^20.0.0",
38
+ "jest": "^29.0.0",
39
+ "ts-jest": "^29.4.6",
40
+ "typescript": "^5.3.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ }
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Claude Code Toonify MCP Server
5
+ *
6
+ * Optimizes token usage by converting structured data to TOON format
7
+ * before sending to Claude API, achieving 60%+ token reduction.
8
+ */
9
+
10
+ import { ToonifyMCPServer } from './server/mcp-server.js';
11
+
12
+ async function main() {
13
+ const server = new ToonifyMCPServer();
14
+
15
+ try {
16
+ await server.start();
17
+ } catch (error) {
18
+ console.error('Failed to start Toonify MCP server:', error);
19
+ process.exit(1);
20
+ }
21
+ }
22
+
23
+ main();