GCF Integration: Native Token-Optimized Output for agent-lsp¶
Goal¶
Add GCF (Graph Compact Format) as a native output format for agent-lsp's MCP tool responses. Tool responses currently serialized as JSON can optionally be emitted as GCF tabular format, saving 34-44% tokens on structured data.
Quick Start¶
GCF is enabled by default. To revert to JSON:
Measured Token Savings¶
Benchmarked on representative tool responses (go run scripts/gcf-benchmark.go):
| Tool Response | JSON | GCF | Savings |
|---|---|---|---|
list_symbols (10 symbols) |
~334 tokens | ~165 tokens | 50.6% |
list_symbols (30 symbols) |
~1,082 tokens | ~548 tokens | 49.4% |
find_references (12 locations) |
~220 tokens | ~125 tokens | 43.2% |
find_references (50 locations) |
~858 tokens | ~437 tokens | 49.1% |
get_diagnostics (5 diagnostics) |
~213 tokens | ~133 tokens | 37.6% |
blast_radius (5 symbols, 6 callers) |
~526 tokens | ~365 tokens | 30.6% |
Token estimates use byte length / 3.5 (validated: 0.97 correlation with o200k_base for ASCII-dominant payloads).
Savings grow with record count because the eliminated overhead (field names, delimiters, quotes) is per-record. At 50+ records, savings converge to ~49%.
Nested-container responses (grouped data)¶
Some tools return grouped data: a nested object array inside a field (blast_radius symbols each with their own callers, detect_changes files each with affected symbols, diagnostics with relatedInformation, explore_symbol with parallel refs/calls). GCF encodes these with the ^ attachment marker and a nested tabular block per group:
| Tool Response (nested shape) | JSON | GCF | Savings |
|---|---|---|---|
blast_radius grouped (symbols → callers) |
~139 tokens | ~112 tokens | 19.5% |
get_diagnostics (+ relatedInformation) |
~114 tokens | ~100 tokens | 12.2% |
explore_symbol (refs + incoming_calls) |
~87 tokens | ~75 tokens | 13.7% |
detect_changes (files → affected_symbols) |
~75 tokens | ~70 tokens | 5.7% |
| Total | ~417 tokens | ~359 tokens | 13.8% |
These savings depend on the gcf-go v1.7.1 encoder (the v1.5.1 nested-container fix). Before it, a nested object array fell through to Go's fmt map printing, an unparseable blob, so the nested cases both saved less and were not valid GCF. v1.7.1 emits a proper tabular section, which roughly tripled savings-vs-JSON on nested data (~5% → ~14%) and, more importantly, made grouped responses parseable. Reproduce with go run scripts/gcf-benchmark.go.
Why¶
- 34-44% fewer tokens on structured data. Most agent-lsp tools return arrays of objects (symbols, references, diagnostics, callers). GCF tabular encoding eliminates field name repetition and structural delimiters.
- Session dedup compounds. Multi-turn code exploration reuses symbols. By the 5th tool call, 92.7% savings vs JSON.
- Competitive moat. No other MCP code intelligence server has token-optimized output.
- We own both projects. No external approval needed. Ship when ready.
Profile Fit Analysis¶
GCF has two encoding profiles. agent-lsp's data model determines which one applies.
Graph profile (Sections 3-6): Does NOT fit natively¶
The graph profile has a fixed schema: @id kind qualified_name score provenance
agent-lsp's tools do not compute scores or provenance:
| GCF graph field | knowing (has it) | agent-lsp (has it) |
|---|---|---|
qualified_name |
Yes | Partial (has name + file, not always qualified) |
kind |
Yes | Yes (from LSP SymbolKind) |
score |
Yes (relevance ranking) | No (LSP doesn't score results) |
provenance |
Yes (lsp_resolved, ast_inferred) | No (all results come from LSP) |
distance |
Yes (graph distance from query) | No (no graph distance model) |
Bottom line: The graph profile was designed for knowing's data model (scored, ranked, distance-grouped symbols). Forcing it onto agent-lsp would require either fake values or adding a scoring layer, both of which are wrong for this phase.
Tabular profile (Section 6a): Fits naturally¶
The tabular profile encodes arbitrary arrays of objects with a field declaration header:
## symbols [12]{name,kind,file,line,test_callers,non_test_callers}
AuthMiddleware|fn|auth.go|45|2|5
NewServer|fn|server.go|12|1|8
HandleRequest|method|handler.go|78|3|12
This maps directly to every agent-lsp tool response:
| Tool | Current JSON shape | Tabular fit |
|---|---|---|
| blast_radius | {changed_symbols: [{name, file, line, callers}]} |
Direct: array of uniform objects |
| find_references | [{file, line, character}] |
Direct: flat array |
| list_symbols | [{name, kind, range, detail}] |
Direct: flat array |
| find_callers | {items, incoming, outgoing} |
Direct: arrays + nested |
| get_diagnostics | [{file, range, severity, message}] |
Direct: flat array |
| explore_symbol | {hover, impls, calls, refs} |
Object with nested arrays |
| get_completions | [{label, kind, detail}] |
Direct: flat array |
Future: Graph profile as opt-in upgrade¶
If agent-lsp later adds relevance scoring (e.g., ranking blast_radius results by caller count, or scoring symbols by distance from query center), the graph profile becomes a natural fit. This is a separate feature decision, not a serialization concern.
Current State¶
Serialization Layer¶
All 66 tool handlers follow the same pattern:
// internal/tools/*.go (53 json.Marshal call sites)
response := map[string]any{ ... }
data, err := json.Marshal(response)
return types.TextResult(string(data)), nil
The response type is always:
// internal/types/types.go
type ToolResult struct {
Content []ContentItem `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type ContentItem struct {
Type string `json:"type"` // always "text"
Text string `json:"text"` // <-- JSON string lives here
}
MCP protocol requires Content to be a list of text/image items. GCF output replaces the JSON string inside Text with a GCF string. The MCP protocol envelope stays JSON; only the tool response payload changes format.
Tool Categories by Expected Savings¶
| Category | Tools | Current output | Expected savings |
|---|---|---|---|
| High-value arrays | blast_radius, find_references, list_symbols, find_callers, call_hierarchy, cross_repo | arrays of uniform objects with 4-8 fields | 34-44% |
| Medium-value arrays | get_diagnostics, suggest_fixes, get_completions, semantic_tokens | arrays of objects | 30-40% |
| Mixed structures | explore_symbol, detect_changes, simulate_chain | objects with nested arrays | 25-35% |
| Simulation results | simulate_edit, preview_edit, evaluate_session | diff results, diagnostic deltas | 20-30% |
| Low-value / skip | hover, get_signature_help, format_document, apply_edit, rename_symbol | mostly text or small objects | Skip |
Priority: High-value array tools first¶
blast_radius, find_references, list_symbols, and find_callers produce the largest payloads with the most uniform structure. These get the biggest savings and prove the concept.
Architecture¶
Phase 1: Format negotiation¶
Add format preference to the MCP session:
// Option A: Per-tool parameter
{"tool": "blast_radius", "args": {"file": "auth.go", "format": "gcf"}}
// Option B: Session-level capability (preferred)
// Client sends during initialize:
{"capabilities": {"experimental": {"output_format": "gcf"}}}
Option B is cleaner: set once, applies to all tool responses. Agent-lsp reads the capability during session init and stores it.
Phase 2: Encoding layer¶
New package: internal/encoding/gcf/
// internal/encoding/gcf/encode.go
package gcf
import gcfgo "github.com/blackwell-systems/gcf-go"
// Encode converts a tool response to GCF tabular format.
// Falls back to JSON if the data structure is not suitable for tabular encoding.
func Encode(tool string, data any) (string, error) {
return gcfgo.EncodeGeneric(data)
}
Phase 3: Migration helper¶
Replace the 53 json.Marshal calls with a format-aware helper:
// internal/tools/helpers.go
func encodeResult(ctx context.Context, tool string, data any) (types.ToolResult, error) {
format := session.GetOutputFormat(ctx) // "json" or "gcf"
switch format {
case "gcf":
encoded, err := gcf.Encode(tool, data)
if err != nil {
// Fall back to JSON if GCF encoding fails
raw, _ := json.Marshal(data)
return types.TextResult(string(raw)), nil
}
return types.TextResult(encoded), nil
default:
raw, err := json.Marshal(data)
if err != nil {
return types.ErrorResult(err.Error()), nil
}
return types.TextResult(string(raw)), nil
}
}
Phase 4: Session deduplication¶
agent-lsp already has session state (internal/session/). Add GCF session tracking so that symbols sent in prior responses become bare references:
// internal/session/gcf_state.go
type GCFState struct {
mu sync.Mutex
session *gcfgo.Session
}
func (s *GCFState) Encode(data any) string {
s.mu.Lock()
defer s.mu.Unlock()
return s.session.EncodeGeneric(data)
}
Session dedup is where the compounding savings happen. Multi-turn exploration (blast_radius then explore then find_callers then inspect) reuses records automatically. By the 5th call, 92.7% cumulative savings vs JSON.
Phase 5: Delta encoding (future)¶
When a user re-queries after an edit, delta encoding sends only what changed. This depends on the tabular profile gaining delta support in GCF (currently only the graph profile has it).
Decision: Defer until tabular delta encoding is specified and implemented in gcf-go.
Example: blast_radius Before and After¶
Current JSON output (estimated ~2,400 tokens for 12 symbols)¶
{
"changed_symbols": [
{"name": "AuthMiddleware", "file": "auth.go", "line": 45},
{"name": "NewServer", "file": "server.go", "line": 12}
],
"affected_symbols": [
{"name": "AuthMiddleware", "file": "auth.go", "line": 45,
"test_callers": [{"name": "TestAuth", "file": "auth_test.go", "line": 10}],
"non_test_callers": [{"name": "main", "file": "main.go", "line": 5}]},
{"name": "NewServer", "file": "server.go", "line": 12,
"test_callers": [{"name": "TestServer", "file": "server_test.go", "line": 8}],
"non_test_callers": [{"name": "main", "file": "main.go", "line": 3}, {"name": "Setup", "file": "setup.go", "line": 20}]}
],
"test_files": ["auth_test.go", "server_test.go"],
"test_functions": ["TestAuth", "TestServer"],
"non_test_callers": [
{"name": "main", "file": "main.go", "line": 5},
{"name": "main", "file": "main.go", "line": 3},
{"name": "Setup", "file": "setup.go", "line": 20}
],
"summary": "Found 2 changed symbols with 2 test references across 2 test files."
}
GCF tabular output (estimated ~1,500 tokens, 37% savings)¶
summary=Found 2 changed symbols with 2 test references across 2 test files.
## changed_symbols [2]{name,file,line}
AuthMiddleware|auth.go|45
NewServer|server.go|12
## affected_symbols [2]{name,file,line,sync_guarded}
@0 AuthMiddleware|auth.go|45|false
.test_callers
## [1]{name,file,line}
TestAuth|auth_test.go|10
.non_test_callers
## [1]{name,file,line}
main|main.go|5
@1 NewServer|server.go|12|false
.test_callers
## [1]{name,file,line}
TestServer|server_test.go|8
.non_test_callers
## [2]{name,file,line}
main|main.go|3
Setup|setup.go|20
## test_files [2]
auth_test.go
server_test.go
## test_functions [2]
TestAuth
TestServer
## non_test_callers [3]{name,file,line}
main|main.go|5
main|main.go|3
Setup|setup.go|20
The field declarations ({name,file,line}) appear once per section. Each record is just pipe-separated values. For a blast_radius response with 50 symbols, the savings grow linearly because the eliminated waste is per-record.
Rollout Plan¶
Milestone 1: Format negotiation (2 days)¶
- [ ] Add
output_formatfield to session config - [ ] Read format preference from MCP experimental capabilities
- [ ] Thread format through context to tool handlers
- [ ] Add
--format gcfflag to CLI for testing
Milestone 2: Tabular encoding for high-value tools (3 days)¶
- [ ] Add
internal/encoding/gcf/package - [ ] Add
encodeResulthelper tointernal/tools/helpers.go - [ ] Migrate blast_radius (change_impact.go)
- [ ] Migrate find_references, list_symbols, find_symbol (navigation.go, analysis.go)
- [ ] Migrate find_callers, call_hierarchy (callhierarchy.go)
- [ ] Migrate cross_repo (cross_repo.go)
- [ ] Integration tests: verify GCF output decodes to equivalent data
Milestone 3: Tabular encoding for remaining tools (2 days)¶
- [ ] Migrate get_diagnostics, suggest_fixes
- [ ] Migrate explore_symbol, detect_changes
- [ ] Migrate simulation tools (simulate_edit, preview_edit, etc.)
- [ ] Migrate remaining tools (get_completions, semantic_tokens, etc.)
- [ ] Skip scalar/text tools (hover, format_document, etc.)
Milestone 4: Session deduplication (2 days)¶
- [ ] Add GCFState to session manager
- [ ] Wire session encoding through tool handlers
- [ ] Test: multi-turn exploration shows dedup in action
- [ ] Benchmark: measure actual token savings across 5-call session
Milestone 5: Benchmarks and documentation (1 day)¶
- [ ] Benchmark: JSON vs GCF token counts on real tool responses (all tool categories)
- [ ] Update agent-lsp README with measured savings ("Token-Optimized Output" section)
- [ ] Update GCF README to list agent-lsp as production consumer
- [ ] Publish results
Dependencies¶
| Dependency | Version | Purpose |
|---|---|---|
github.com/blackwell-systems/gcf-go |
v0.1.0 | Tabular encoding via EncodeGeneric, session management |
Zero additional runtime dependencies (gcf-go is zero-dep itself).
Risks¶
1. MCP clients that can't handle non-JSON text¶
Risk: Some MCP clients might parse the text field as JSON and break on GCF output.
Mitigation: GCF is opt-in via capabilities. Default remains JSON. Clients that don't negotiate get JSON.
2. Comprehension regression on tabular format¶
Risk: LLMs might understand JSON better than GCF tabular for small payloads. Mitigation: GCF tabular has 100% comprehension parity with JSON for structured extraction. For payloads under 10 records, savings are minimal, so keep JSON for scalar/text tools. Test with real agent-lsp payloads before expanding.
3. Debugging difficulty¶
Risk: GCF output is less familiar than JSON for debugging.
Mitigation: GCF is human-readable by design. Keep JSON as default. Add gcf decode CLI command for debugging. Consider a --verbose mode that emits both formats.
4. Tabular profile maturity¶
Risk: GCF tabular profile is v1.1 (released same day as v1.0). May have edge cases with deeply nested data. Mitigation: agent-lsp's nested data is shallow (1-2 levels max: affected_symbols with test_callers/non_test_callers). Test all tool outputs through encode/decode round-trip before shipping.
5. EncodeGeneric performance¶
Risk: Reflection-based generic encoding may be slower than direct json.Marshal. Mitigation: Profile on real payloads. If slow, add type-specific encoders for high-frequency tools (blast_radius, find_references). Encoding time is negligible compared to LSP round-trip latency.
Success Metrics¶
| Metric | Target | How to measure |
|---|---|---|
| Token savings (high-value tools) | >30% vs JSON | Benchmark blast_radius, find_references, list_symbols |
| Token savings (medium-value tools) | >20% vs JSON | Benchmark get_diagnostics, explore_symbol |
| Session dedup (5-call session) | >80% cumulative | Benchmark multi-turn exploration |
| Comprehension accuracy | 100% on extraction tasks | Run extraction eval with real agent-lsp GCF output |
| Zero regressions | All existing tests pass | CI green after migration |
Timeline¶
Total: ~10 days of focused work
| Week | Milestones | Deliverable |
|---|---|---|
| Week 1 | M1 + M2 | Format negotiation, high-value tool encoding |
| Week 2 | M3 + M4 + M5 | Remaining tools, session dedup, benchmarks |
Future: Graph Profile Upgrade Path¶
If agent-lsp later adds relevance scoring (ranking symbols by caller count, blast radius, recency), the graph profile becomes viable:
GCF tool=blast_radius budget=5000 tokens=847 symbols=12
## targets
@0 fn auth.go:AuthMiddleware 0.95 lsp
@1 fn server.go:NewServer 0.72 lsp
## related
@2 fn main.go:main 0.40 lsp
@3 fn setup.go:Setup 0.35 lsp
## edges
@0<@2 called_by
@0<@3 called_by
@1<@2 called_by
This would unlock 79-84% savings on graph tools. The scoring model is a separate feature decision tracked in the roadmap; the encoding infrastructure built in this plan will support it when it arrives.
Open Questions¶
- Should GCF be the default? No. Opt-in via capabilities. Make default only after comprehension validation across Claude, GPT, Gemini.
- MCP spec alignment? Is there an emerging standard for output format negotiation in MCP? Check with spec contributors (Sam Morrow is one).
- Per-tool opt-in? Scalar/text tools don't benefit. Format negotiation should be global with per-tool fallback to JSON when GCF adds no value.
- Tabular delta encoding? Currently only the graph profile supports delta. Should GCF spec add tabular deltas? If so, agent-lsp would benefit from re-query efficiency.