From 838696f361871615af615f3108244105ae0af28d Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:39:32 +0530 Subject: [PATCH 1/6] feat(anf): generic lossless JSON to ANF translator Deterministic structural mapping of arbitrary JSON into an ANF document. Sorted keys, no semantic inference, lossless (name/id kept as props). Basis for the anf_encode MCP tool. Apache-2.0. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- translators/generic/translate.go | 165 ++++++++++++++++ translators/generic/translate_test.go | 274 ++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 translators/generic/translate.go create mode 100644 translators/generic/translate_test.go diff --git a/translators/generic/translate.go b/translators/generic/translate.go new file mode 100644 index 0000000..2a05f60 --- /dev/null +++ b/translators/generic/translate.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package generic provides a deterministic, lossless translator that converts +// arbitrary decoded JSON into an ANF document. It performs a purely structural +// mapping: every key and scalar in the input appears exactly once in the output +// and no semantics (health, status, alerts, or actions) are inferred. +package generic + +import ( + "math" + "sort" + "strconv" + "time" + + "github.com/Clawdlinux/agent-native-format/pkg/anf" +) + +// translatorName identifies this translator in the document header. +const translatorName = "clawdlinux/generic-translator" + +// Translate converts an arbitrary decoded JSON value (the result of +// json.Unmarshal into any) into an ANF Document. It is deterministic and +// lossless: every key and scalar in the input appears exactly once in the +// output, and no semantics are inferred. source and scope populate the document +// header; now sets the timestamp. +func Translate(input any, source, scope string, now time.Time) (*anf.Document, error) { + doc := anf.NewDocument(source, scope, now) + doc.SetTranslator(translatorName) + + switch v := input.(type) { + case map[string]any: + scalars, containers := partitionKeys(v) + if len(scalars) > 0 { + e := anf.Entity{Type: "object", Name: scope, Status: anf.StatusEmpty} + for _, k := range scalars { + e.Props = append(e.Props, anf.Property{Key: k, Value: scalarString(v[k])}) + } + doc.AddEntity(e) + } + for _, k := range containers { + doc.AddEntity(entityFromValue(k, v[k])) + } + case []any: + e := anf.Entity{Type: "array", Name: scope, Status: anf.StatusEmpty} + for _, elem := range v { + e.Children = append(e.Children, entityFromValue("item", elem)) + } + doc.AddEntity(e) + default: + doc.AddEntity(anf.Entity{ + Type: "value", + Name: scope, + Status: anf.StatusEmpty, + Props: []anf.Property{{Key: "value", Value: scalarString(input)}}, + }) + } + + return doc, nil +} + +// entityFromValue recursively maps a keyed JSON value into an ANF entity. +func entityFromValue(key string, value any) anf.Entity { + switch v := value.(type) { + case map[string]any: + e := anf.Entity{Type: key, Name: objectName(v), Status: anf.StatusEmpty} + for _, k := range sortedKeys(v) { + if isContainer(v[k]) { + e.Children = append(e.Children, entityFromValue(k, v[k])) + } else { + e.Props = append(e.Props, anf.Property{Key: k, Value: scalarString(v[k])}) + } + } + return e + case []any: + e := anf.Entity{Type: key, Status: anf.StatusEmpty} + for _, elem := range v { + if isContainer(elem) { + e.Children = append(e.Children, entityFromValue(key, elem)) + } else { + e.Children = append(e.Children, anf.Entity{ + Type: key, + Status: anf.StatusEmpty, + Props: []anf.Property{{Key: "value", Value: scalarString(elem)}}, + }) + } + } + return e + default: + return anf.Entity{ + Type: key, + Status: anf.StatusEmpty, + Props: []anf.Property{{Key: "value", Value: scalarString(value)}}, + } + } +} + +// partitionKeys splits an object's keys into scalar-valued and container-valued +// groups, each sorted lexicographically. +func partitionKeys(m map[string]any) (scalars, containers []string) { + for k := range m { + if isContainer(m[k]) { + containers = append(containers, k) + } else { + scalars = append(scalars, k) + } + } + sort.Strings(scalars) + sort.Strings(containers) + return scalars, containers +} + +// sortedKeys returns the object's keys sorted lexicographically. +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// isContainer reports whether v is an object or array (as opposed to a scalar). +func isContainer(v any) bool { + switch v.(type) { + case map[string]any, []any: + return true + default: + return false + } +} + +// objectName returns a human name for an object: the "name" string if present, +// else the "id" string if present, else "". The chosen key remains a property +// and is not consumed here (losslessness is preserved by the caller). +func objectName(m map[string]any) string { + if s, ok := m["name"].(string); ok { + return s + } + if s, ok := m["id"].(string); ok { + return s + } + return "" +} + +// scalarString renders a JSON scalar as its ANF string form. +func scalarString(v any) string { + switch s := v.(type) { + case string: + return s + case bool: + if s { + return "true" + } + return "false" + case float64: + if !math.IsInf(s, 0) && !math.IsNaN(s) && s == math.Trunc(s) { + return strconv.FormatFloat(s, 'f', -1, 64) + } + return strconv.FormatFloat(s, 'g', -1, 64) + case nil: + return "null" + default: + return "null" + } +} diff --git a/translators/generic/translate_test.go b/translators/generic/translate_test.go new file mode 100644 index 0000000..f94a208 --- /dev/null +++ b/translators/generic/translate_test.go @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 +package generic + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/Clawdlinux/agent-native-format/pkg/anf" +) + +// fixedTime is a stable timestamp for deterministic header output. +var fixedTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + +// TestTranslateEntities checks the structural mapping of arbitrary JSON into +// ANF entities. Each case asserts on the resulting Document.Entities so the +// deterministic, lossless mapping rules are verified directly. +func TestTranslateEntities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + scope string + want []anf.Entity + }{ + { + name: "flat object with only scalars", + input: map[string]any{"b": "x", "a": float64(3), "c": true}, + scope: "root", + want: []anf.Entity{ + {Type: "object", Name: "root", Props: []anf.Property{ + {Key: "a", Value: "3"}, + {Key: "b", Value: "x"}, + {Key: "c", Value: "true"}, + }}, + }, + }, + { + name: "nested object keeps name and props", + input: map[string]any{"deploy": map[string]any{"name": "api", "replicas": float64(3)}}, + scope: "root", + want: []anf.Entity{ + {Type: "deploy", Name: "api", Props: []anf.Property{ + {Key: "name", Value: "api"}, + {Key: "replicas", Value: "3"}, + }}, + }, + }, + { + name: "id used as name and kept as prop", + input: map[string]any{"svc": map[string]any{"id": "i1", "port": float64(80)}}, + scope: "root", + want: []anf.Entity{ + {Type: "svc", Name: "i1", Props: []anf.Property{ + {Key: "id", Value: "i1"}, + {Key: "port", Value: "80"}, + }}, + }, + }, + { + name: "name preferred over id both kept", + input: map[string]any{"svc": map[string]any{"id": "i1", "name": "apppp"}}, + scope: "root", + want: []anf.Entity{ + {Type: "svc", Name: "apppp", Props: []anf.Property{ + {Key: "id", Value: "i1"}, + {Key: "name", Value: "apppp"}, + }}, + }, + }, + { + name: "array of objects", + input: map[string]any{"pods": []any{map[string]any{"name": "p1"}, map[string]any{"name": "p2"}}}, + scope: "root", + want: []anf.Entity{ + {Type: "pods", Children: []anf.Entity{ + {Type: "pods", Name: "p1", Props: []anf.Property{{Key: "name", Value: "p1"}}}, + {Type: "pods", Name: "p2", Props: []anf.Property{{Key: "name", Value: "p2"}}}, + }}, + }, + }, + { + name: "array of scalars", + input: map[string]any{"tags": []any{"a", float64(2)}}, + scope: "root", + want: []anf.Entity{ + {Type: "tags", Children: []anf.Entity{ + {Type: "tags", Props: []anf.Property{{Key: "value", Value: "a"}}}, + {Type: "tags", Props: []anf.Property{{Key: "value", Value: "2"}}}, + }}, + }, + }, + { + name: "root array", + input: []any{map[string]any{"name": "x"}, "y"}, + scope: "root", + want: []anf.Entity{ + {Type: "array", Name: "root", Children: []anf.Entity{ + {Type: "item", Name: "x", Props: []anf.Property{{Key: "name", Value: "x"}}}, + {Type: "item", Props: []anf.Property{{Key: "value", Value: "y"}}}, + }}, + }, + }, + { + name: "root scalar string", + input: "hello", + scope: "root", + want: []anf.Entity{ + {Type: "value", Name: "root", Props: []anf.Property{{Key: "value", Value: "hello"}}}, + }, + }, + { + name: "root scalar number", + input: float64(3), + scope: "root", + want: []anf.Entity{ + {Type: "value", Name: "root", Props: []anf.Property{{Key: "value", Value: "3"}}}, + }, + }, + { + name: "mixed scalar and container top level", + input: map[string]any{"z": "scal", "a": map[string]any{"k": "v"}, "m": float64(2)}, + scope: "root", + want: []anf.Entity{ + {Type: "object", Name: "root", Props: []anf.Property{ + {Key: "m", Value: "2"}, + {Key: "z", Value: "scal"}, + }}, + {Type: "a", Props: []anf.Property{{Key: "k", Value: "v"}}}, + }, + }, + { + name: "float bool and null formatting", + input: map[string]any{"f": float64(3.5), "g": float64(3.0), "b": false, "n": nil}, + scope: "root", + want: []anf.Entity{ + {Type: "object", Name: "root", Props: []anf.Property{ + {Key: "b", Value: "false"}, + {Key: "f", Value: "3.5"}, + {Key: "g", Value: "3"}, + {Key: "n", Value: "null"}, + }}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + doc, err := Translate(tt.input, "test", tt.scope, fixedTime) + if err != nil { + t.Fatalf("Translate returned error: %v", err) + } + if !reflect.DeepEqual(doc.Entities, tt.want) { + t.Errorf("entities mismatch\n got: %#v\nwant: %#v", doc.Entities, tt.want) + } + }) + } +} + +// TestTranslateSetsTranslatorHeader verifies the translator name header is set. +func TestTranslateSetsTranslatorHeader(t *testing.T) { + t.Parallel() + + doc, err := Translate(map[string]any{"a": "b"}, "test", "root", fixedTime) + if err != nil { + t.Fatalf("Translate returned error: %v", err) + } + + var found bool + for _, h := range doc.Headers { + if h.Key == "translator" { + found = true + if h.Value != "clawdlinux/generic-translator" { + t.Errorf("translator header = %q, want clawdlinux/generic-translator", h.Value) + } + } + } + if !found { + t.Errorf("translator header not set; headers: %#v", doc.Headers) + } +} + +// TestTranslateNoSemanticInvention ensures the generic translator never emits +// status, alerts, or actions. +func TestTranslateNoSemanticInvention(t *testing.T) { + t.Parallel() + + input := map[string]any{ + "status": "failing", + "healthy": false, + "pods": []any{map[string]any{"name": "p1", "phase": "Running"}}, + } + doc, err := Translate(input, "test", "root", fixedTime) + if err != nil { + t.Fatalf("Translate returned error: %v", err) + } + if len(doc.Alerts) != 0 { + t.Errorf("expected no alerts, got %d", len(doc.Alerts)) + } + if len(doc.Actions) != 0 { + t.Errorf("expected no actions, got %d", len(doc.Actions)) + } + var walk func(e anf.Entity) + walk = func(e anf.Entity) { + if e.Status != anf.StatusEmpty { + t.Errorf("entity %q has non-empty status %q", e.Type, e.Status) + } + for _, c := range e.Children { + walk(c) + } + } + for _, e := range doc.Entities { + walk(e) + } +} + +// TestTranslateEncodeGolden asserts the full ANF text for a nested object. +func TestTranslateEncodeGolden(t *testing.T) { + t.Parallel() + + input := map[string]any{"deploy": map[string]any{"name": "api", "replicas": float64(3)}} + doc, err := Translate(input, "test", "root", fixedTime) + if err != nil { + t.Fatalf("Translate returned error: %v", err) + } + + want := "@source test\n" + + "@scope root\n" + + "@time 2026-01-02T03:04:05Z\n" + + "@translator clawdlinux/generic-translator\n" + + "\n" + + "deploy api\n" + + " name api\n" + + " replicas 3\n" + + if got := anf.EncodeToString(doc); got != want { + t.Errorf("encoded output mismatch\n got:\n%s\nwant:\n%s", got, want) + } +} + +// TestTranslateDeterministic checks that the same input yields identical output +// across repeated calls, including from real json.Unmarshal decoding. +func TestTranslateDeterministic(t *testing.T) { + t.Parallel() + + raw := []byte(`{"z":"last","a":1,"m":{"k":"v","j":2},"b":[3,2,1]}`) + + var in1, in2 any + if err := json.Unmarshal(raw, &in1); err != nil { + t.Fatalf("unmarshal in1: %v", err) + } + if err := json.Unmarshal(raw, &in2); err != nil { + t.Fatalf("unmarshal in2: %v", err) + } + + doc1, err := Translate(in1, "test", "root", fixedTime) + if err != nil { + t.Fatalf("Translate in1: %v", err) + } + doc2, err := Translate(in2, "test", "root", fixedTime) + if err != nil { + t.Fatalf("Translate in2: %v", err) + } + + if anf.EncodeToString(doc1) != anf.EncodeToString(doc2) { + t.Errorf("output not deterministic\nfirst:\n%s\nsecond:\n%s", + anf.EncodeToString(doc1), anf.EncodeToString(doc2)) + } +} From a1c166afb02ba9b8fad9869d9605f59848d8f138 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:14:10 +0530 Subject: [PATCH 2/6] feat(anfmcp): stateless MCP stdio server core JSON-RPC 2.0 over stdio, no third-party deps, Apache-2.0. Register tools and resources, then Serve. Implements server/discover per MCP SEP-2575 and keeps initialize for current clients. No per-session state is retained. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- pkg/anfmcp/server.go | 439 ++++++++++++++++++++++++++++++++++++++ pkg/anfmcp/server_test.go | 320 +++++++++++++++++++++++++++ 2 files changed, 759 insertions(+) create mode 100644 pkg/anfmcp/server.go create mode 100644 pkg/anfmcp/server_test.go diff --git a/pkg/anfmcp/server.go b/pkg/anfmcp/server.go new file mode 100644 index 0000000..0e13d3f --- /dev/null +++ b/pkg/anfmcp/server.go @@ -0,0 +1,439 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package anfmcp is a small, dependency-free MCP server that speaks JSON-RPC +// 2.0 over stdio. It exists to expose ANF encoding as MCP tools so any +// MCP-capable agent (Claude, Cursor, Codex, VS Code) can turn verbose system +// state into token-minimal ANF. +// +// The server is stateless by construction. Tools and resources are registered +// once at startup and never mutate per client, and no session state is retained +// between requests. This matches the stateless-first direction of MCP SEP-2575: +// the server implements server/discover for version and capability discovery, +// and every request is handled in isolation. It also keeps the older initialize +// handshake so current clients (Claude, Cursor, VS Code), which still send it, +// interoperate. SEP-2575 explicitly permits a server to support both. +// +// The server is intentionally minimal. Register tools and resources, then call +// Serve. It handles server/discover, initialize, tools/list, tools/call, +// resources/list, resources/read, and ping. It has no third-party dependencies +// and is licensed Apache-2.0 so it is free to embed and ship. +package anfmcp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "sync" +) + +// protocolVersion is the MCP protocol version this server implements. The +// server echoes the client's requested version when the client sends one. +const protocolVersion = "2025-06-18" + +// ToolHandler runs a tool call. args is the decoded "arguments" object from the +// request. The returned string is delivered as text content. A non-nil error is +// reported to the caller as an MCP tool error (isError: true), not a transport +// error, so the agent can read and react to it. +type ToolHandler func(ctx context.Context, args map[string]any) (string, error) + +// Tool is an MCP tool exposed by the server. +type Tool struct { + Name string + Description string + // InputSchema is a JSON Schema object describing the tool arguments. When + // nil, an empty object schema is advertised. + InputSchema map[string]any + Handler ToolHandler +} + +// ResourceReader returns the body of a resource. +type ResourceReader func(ctx context.Context) (string, error) + +// Resource is an MCP resource exposed by the server. +type Resource struct { + URI string + Name string + Description string + MimeType string + Read ResourceReader +} + +// Server is a minimal MCP stdio server. +type Server struct { + name string + version string + instructions string + logger *slog.Logger + + mu sync.RWMutex + tools map[string]Tool + toolOrder []string + resources map[string]Resource + resOrder []string +} + +// Option configures a Server. +type Option func(*Server) + +// WithLogger sets the logger used for diagnostics. Logs go to the provided +// handler, never to stdout, so they cannot corrupt the JSON-RPC stream. +func WithLogger(l *slog.Logger) Option { + return func(s *Server) { + if l != nil { + s.logger = l + } + } +} + +// WithInstructions sets natural-language usage guidance returned by +// server/discover. A client may add it to a system prompt to help an LLM use +// the server's tools well. +func WithInstructions(text string) Option { + return func(s *Server) { s.instructions = text } +} + +// NewServer creates a Server that identifies itself with name and version. +func NewServer(name, version string, opts ...Option) *Server { + s := &Server{ + name: name, + version: version, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + tools: make(map[string]Tool), + resources: make(map[string]Resource), + } + for _, opt := range opts { + opt(s) + } + return s +} + +// RegisterTool adds a tool. It returns an error if the tool name is empty, the +// handler is nil, or a tool with the same name is already registered. +func (s *Server) RegisterTool(t Tool) error { + if t.Name == "" { + return fmt.Errorf("anfmcp: tool name is empty") + } + if t.Handler == nil { + return fmt.Errorf("anfmcp: tool %q has nil handler", t.Name) + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.tools[t.Name]; exists { + return fmt.Errorf("anfmcp: tool %q already registered", t.Name) + } + s.tools[t.Name] = t + s.toolOrder = append(s.toolOrder, t.Name) + return nil +} + +// RegisterResource adds a resource. It returns an error if the URI is empty, +// the reader is nil, or a resource with the same URI is already registered. +func (s *Server) RegisterResource(r Resource) error { + if r.URI == "" { + return fmt.Errorf("anfmcp: resource URI is empty") + } + if r.Read == nil { + return fmt.Errorf("anfmcp: resource %q has nil reader", r.URI) + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.resources[r.URI]; exists { + return fmt.Errorf("anfmcp: resource %q already registered", r.URI) + } + s.resources[r.URI] = r + s.resOrder = append(s.resOrder, r.URI) + return nil +} + +// JSON-RPC 2.0 wire types. + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// JSON-RPC standard error codes. +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 + codeInternalError = -32603 +) + +// Serve reads newline-delimited JSON-RPC requests from in and writes responses +// to out until in reaches EOF or ctx is cancelled. Notifications (requests with +// no id) receive no response. Serve is single-threaded: one request is handled +// at a time, which matches the stdio transport and keeps output ordered. +func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error { + scanner := bufio.NewScanner(in) + // Allow large payloads (system state can be big). 16 MiB line cap. + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + enc := json.NewEncoder(out) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + // Cannot recover an id from an unparseable message; reply with a + // null-id parse error per JSON-RPC. + s.write(enc, rpcResponse{ + JSONRPC: "2.0", + ID: json.RawMessage("null"), + Error: &rpcError{Code: codeParseError, Message: "parse error"}, + }) + continue + } + + resp, respond := s.handle(ctx, req) + if respond { + s.write(enc, resp) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("anfmcp: read: %w", err) + } + return nil +} + +// handle dispatches one request. The second return reports whether a response +// should be written (false for notifications). +func (s *Server) handle(ctx context.Context, req rpcRequest) (rpcResponse, bool) { + isNotification := len(req.ID) == 0 + + switch req.Method { + case "server/discover": + // SEP-2575 stateless discovery. Preferred over initialize. + return s.ok(req.ID, s.discoverResult()), true + case "initialize": + // Legacy handshake, retained for current clients that still send it. + return s.ok(req.ID, s.initializeResult(req.Params)), true + case "notifications/initialized", "notifications/cancelled": + return rpcResponse{}, false + case "ping": + return s.ok(req.ID, map[string]any{}), true + case "tools/list": + return s.ok(req.ID, s.toolsList()), true + case "tools/call": + return s.toolsCall(ctx, req), true + case "resources/list": + return s.ok(req.ID, s.resourcesList()), true + case "resources/read": + return s.resourcesRead(ctx, req), true + default: + if isNotification { + // Unknown notification: ignore silently per JSON-RPC. + return rpcResponse{}, false + } + return s.fail(req.ID, codeMethodNotFound, "method not found: "+req.Method), true + } +} + +// discoverResult builds the server/discover response defined by SEP-2575: +// supported protocol versions, capabilities, server identity, and optional +// usage instructions. +func (s *Server) discoverResult() map[string]any { + res := map[string]any{ + "supportedVersions": []string{protocolVersion}, + "capabilities": map[string]any{ + "tools": map[string]any{}, + "resources": map[string]any{}, + }, + "serverInfo": map[string]any{ + "name": s.name, + "version": s.version, + }, + } + if s.instructions != "" { + res["instructions"] = s.instructions + } + return res +} + +func (s *Server) initializeResult(params json.RawMessage) map[string]any { + version := protocolVersion + if len(params) > 0 { + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + if err := json.Unmarshal(params, &p); err == nil && p.ProtocolVersion != "" { + version = p.ProtocolVersion + } + } + return map[string]any{ + "protocolVersion": version, + "capabilities": map[string]any{ + "tools": map[string]any{}, + "resources": map[string]any{}, + }, + "serverInfo": map[string]any{ + "name": s.name, + "version": s.version, + }, + } +} + +func (s *Server) toolsList() map[string]any { + s.mu.RLock() + defer s.mu.RUnlock() + + list := make([]map[string]any, 0, len(s.toolOrder)) + for _, name := range s.toolOrder { + t := s.tools[name] + schema := t.InputSchema + if schema == nil { + schema = map[string]any{"type": "object"} + } + list = append(list, map[string]any{ + "name": t.Name, + "description": t.Description, + "inputSchema": schema, + }) + } + return map[string]any{"tools": list} +} + +type toolCallParams struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +func (s *Server) toolsCall(ctx context.Context, req rpcRequest) rpcResponse { + var p toolCallParams + if err := json.Unmarshal(req.Params, &p); err != nil { + return s.fail(req.ID, codeInvalidParams, "invalid params: "+err.Error()) + } + if p.Name == "" { + return s.fail(req.ID, codeInvalidParams, "missing tool name") + } + + s.mu.RLock() + tool, ok := s.tools[p.Name] + s.mu.RUnlock() + if !ok { + return s.fail(req.ID, codeInvalidParams, "unknown tool: "+p.Name) + } + + text, err := tool.Handler(ctx, p.Arguments) + if err != nil { + s.logger.Warn("tool call failed", "tool", p.Name, "error", err) + return s.ok(req.ID, toolResult(err.Error(), true)) + } + return s.ok(req.ID, toolResult(text, false)) +} + +// toolResult builds an MCP tools/call result payload. +func toolResult(text string, isError bool) map[string]any { + return map[string]any{ + "content": []map[string]any{ + {"type": "text", "text": text}, + }, + "isError": isError, + } +} + +func (s *Server) resourcesList() map[string]any { + s.mu.RLock() + defer s.mu.RUnlock() + + list := make([]map[string]any, 0, len(s.resOrder)) + for _, uri := range s.resOrder { + r := s.resources[uri] + list = append(list, map[string]any{ + "uri": r.URI, + "name": r.Name, + "description": r.Description, + "mimeType": r.MimeType, + }) + } + return map[string]any{"resources": list} +} + +type resourceReadParams struct { + URI string `json:"uri"` +} + +func (s *Server) resourcesRead(ctx context.Context, req rpcRequest) rpcResponse { + var p resourceReadParams + if err := json.Unmarshal(req.Params, &p); err != nil { + return s.fail(req.ID, codeInvalidParams, "invalid params: "+err.Error()) + } + if p.URI == "" { + return s.fail(req.ID, codeInvalidParams, "missing resource uri") + } + + s.mu.RLock() + res, ok := s.resources[p.URI] + s.mu.RUnlock() + if !ok { + return s.fail(req.ID, codeInvalidParams, "unknown resource: "+p.URI) + } + + body, err := res.Read(ctx) + if err != nil { + s.logger.Warn("resource read failed", "uri", p.URI, "error", err) + return s.fail(req.ID, codeInternalError, "read resource: "+err.Error()) + } + return s.ok(req.ID, map[string]any{ + "contents": []map[string]any{ + {"uri": res.URI, "mimeType": res.MimeType, "text": body}, + }, + }) +} + +// ok builds a success response. +func (s *Server) ok(id json.RawMessage, result any) rpcResponse { + return rpcResponse{JSONRPC: "2.0", ID: normalizeID(id), Result: result} +} + +// fail builds an error response. +func (s *Server) fail(id json.RawMessage, code int, message string) rpcResponse { + return rpcResponse{JSONRPC: "2.0", ID: normalizeID(id), Error: &rpcError{Code: code, Message: message}} +} + +// normalizeID returns the id as-is, or JSON null when absent, so the response +// always carries an id field per JSON-RPC 2.0. +func normalizeID(id json.RawMessage) json.RawMessage { + if len(id) == 0 { + return json.RawMessage("null") + } + return id +} + +// write encodes a response. Encode appends a newline, giving newline-delimited +// framing. Errors are logged, not returned, so one bad write does not kill the +// serve loop. +func (s *Server) write(enc *json.Encoder, resp rpcResponse) { + if err := enc.Encode(resp); err != nil { + s.logger.Error("write response", "error", err) + } +} diff --git a/pkg/anfmcp/server_test.go b/pkg/anfmcp/server_test.go new file mode 100644 index 0000000..b0f9f37 --- /dev/null +++ b/pkg/anfmcp/server_test.go @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: Apache-2.0 +package anfmcp + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +// runServer feeds newline-delimited JSON-RPC request lines through a fresh +// Server (configured by setup) and returns the decoded responses in order. +func runServer(t *testing.T, setup func(*Server), requests ...string) []rpcResponse { + t.Helper() + + s := NewServer("anf-mcp", "test", WithInstructions("use anf_encode to shrink JSON")) + if setup != nil { + setup(s) + } + + in := strings.NewReader(strings.Join(requests, "\n") + "\n") + var out bytes.Buffer + if err := s.Serve(context.Background(), in, &out); err != nil { + t.Fatalf("Serve: %v", err) + } + + var responses []rpcResponse + sc := bufio.NewScanner(&out) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var resp rpcResponse + if err := json.Unmarshal(line, &resp); err != nil { + t.Fatalf("decode response %q: %v", line, err) + } + responses = append(responses, resp) + } + if err := sc.Err(); err != nil { + t.Fatalf("scan output: %v", err) + } + return responses +} + +// resultMap re-decodes a response's Result into a map for assertions. +func resultMap(t *testing.T, r rpcResponse) map[string]any { + t.Helper() + raw, err := json.Marshal(r.Result) + if err != nil { + t.Fatalf("marshal result: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + return m +} + +func echoTool() Tool { + return Tool{ + Name: "echo", + Description: "echoes its text argument", + InputSchema: map[string]any{"type": "object"}, + Handler: func(_ context.Context, args map[string]any) (string, error) { + s, _ := args["text"].(string) + return s, nil + }, + } +} + +func TestServerDiscover(t *testing.T) { + t.Parallel() + + resp := runServer(t, func(s *Server) { + if err := s.RegisterTool(echoTool()); err != nil { + t.Fatalf("register: %v", err) + } + }, `{"jsonrpc":"2.0","id":1,"method":"server/discover"}`) + + if len(resp) != 1 { + t.Fatalf("want 1 response, got %d", len(resp)) + } + m := resultMap(t, resp[0]) + versions, ok := m["supportedVersions"].([]any) + if !ok || len(versions) == 0 || versions[0] != protocolVersion { + t.Errorf("supportedVersions = %v, want [%s]", m["supportedVersions"], protocolVersion) + } + if m["instructions"] != "use anf_encode to shrink JSON" { + t.Errorf("instructions = %v", m["instructions"]) + } + info, ok := m["serverInfo"].(map[string]any) + if !ok || info["name"] != "anf-mcp" { + t.Errorf("serverInfo = %v", m["serverInfo"]) + } + if _, ok := m["capabilities"].(map[string]any); !ok { + t.Errorf("capabilities missing: %v", m) + } +} + +func TestServerInitializeEchoesVersion(t *testing.T) { + t.Parallel() + + resp := runServer(t, nil, + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}`) + + m := resultMap(t, resp[0]) + if m["protocolVersion"] != "2024-11-05" { + t.Errorf("protocolVersion = %v, want echoed 2024-11-05", m["protocolVersion"]) + } +} + +func TestServerToolsList(t *testing.T) { + t.Parallel() + + resp := runServer(t, func(s *Server) { + _ = s.RegisterTool(echoTool()) + }, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) + + m := resultMap(t, resp[0]) + tools, ok := m["tools"].([]any) + if !ok || len(tools) != 1 { + t.Fatalf("tools = %v", m["tools"]) + } + tool := tools[0].(map[string]any) + if tool["name"] != "echo" { + t.Errorf("tool name = %v", tool["name"]) + } + if _, ok := tool["inputSchema"].(map[string]any); !ok { + t.Errorf("inputSchema missing: %v", tool) + } +} + +func TestServerToolsCallSuccess(t *testing.T) { + t.Parallel() + + resp := runServer(t, func(s *Server) { + _ = s.RegisterTool(echoTool()) + }, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}`) + + m := resultMap(t, resp[0]) + if m["isError"] != false { + t.Errorf("isError = %v, want false", m["isError"]) + } + content := m["content"].([]any) + first := content[0].(map[string]any) + if first["type"] != "text" || first["text"] != "hi" { + t.Errorf("content = %v", content) + } +} + +func TestServerToolsCallHandlerError(t *testing.T) { + t.Parallel() + + resp := runServer(t, func(s *Server) { + _ = s.RegisterTool(Tool{ + Name: "boom", + Handler: func(_ context.Context, _ map[string]any) (string, error) { + return "", errors.New("kaboom") + }, + }) + }, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"boom","arguments":{}}}`) + + m := resultMap(t, resp[0]) + if m["isError"] != true { + t.Fatalf("isError = %v, want true", m["isError"]) + } + content := m["content"].([]any) + first := content[0].(map[string]any) + if first["text"] != "kaboom" { + t.Errorf("error text = %v", first["text"]) + } + if resp[0].Error != nil { + t.Errorf("handler error must be a tool error, not a transport error: %v", resp[0].Error) + } +} + +func TestServerToolsCallUnknownTool(t *testing.T) { + t.Parallel() + + resp := runServer(t, nil, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nope","arguments":{}}}`) + + if resp[0].Error == nil || resp[0].Error.Code != codeInvalidParams { + t.Errorf("want invalid params error, got %+v", resp[0].Error) + } +} + +func TestServerResources(t *testing.T) { + t.Parallel() + + setup := func(s *Server) { + if err := s.RegisterResource(Resource{ + URI: "anf://spec", + Name: "ANF spec", + MimeType: "text/markdown", + Read: func(_ context.Context) (string, error) { + return "# ANF", nil + }, + }); err != nil { + t.Fatalf("register resource: %v", err) + } + } + + resp := runServer(t, setup, + `{"jsonrpc":"2.0","id":1,"method":"resources/list"}`, + `{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"anf://spec"}}`, + `{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"anf://missing"}}`, + ) + + if len(resp) != 3 { + t.Fatalf("want 3 responses, got %d", len(resp)) + } + + list := resultMap(t, resp[0]) + if res, ok := list["resources"].([]any); !ok || len(res) != 1 { + t.Errorf("resources/list = %v", list["resources"]) + } + + read := resultMap(t, resp[1]) + contents := read["contents"].([]any) + first := contents[0].(map[string]any) + if first["text"] != "# ANF" || first["uri"] != "anf://spec" { + t.Errorf("resources/read = %v", contents) + } + + if resp[2].Error == nil || resp[2].Error.Code != codeInvalidParams { + t.Errorf("unknown resource want invalid params, got %+v", resp[2].Error) + } +} + +func TestServerPingAndUnknownMethod(t *testing.T) { + t.Parallel() + + resp := runServer(t, nil, + `{"jsonrpc":"2.0","id":1,"method":"ping"}`, + `{"jsonrpc":"2.0","id":2,"method":"does/notexist"}`, + ) + + if resp[0].Error != nil { + t.Errorf("ping error: %v", resp[0].Error) + } + if resp[1].Error == nil || resp[1].Error.Code != codeMethodNotFound { + t.Errorf("unknown method want -32601, got %+v", resp[1].Error) + } +} + +func TestServerNotificationHasNoResponse(t *testing.T) { + t.Parallel() + + // A request without an id is a notification; it must produce no response. + resp := runServer(t, nil, + `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + `{"jsonrpc":"2.0","id":1,"method":"ping"}`, + ) + + if len(resp) != 1 { + t.Fatalf("want 1 response (ping only), got %d", len(resp)) + } + if string(resp[0].ID) != "1" { + t.Errorf("response id = %s, want 1", resp[0].ID) + } +} + +func TestServerParseError(t *testing.T) { + t.Parallel() + + resp := runServer(t, nil, `{not valid json`) + + if len(resp) != 1 { + t.Fatalf("want 1 response, got %d", len(resp)) + } + if resp[0].Error == nil || resp[0].Error.Code != codeParseError { + t.Errorf("want parse error -32700, got %+v", resp[0].Error) + } + if string(resp[0].ID) != "null" { + t.Errorf("parse error id = %s, want null", resp[0].ID) + } +} + +func TestRegisterToolValidation(t *testing.T) { + t.Parallel() + + s := NewServer("t", "0") + if err := s.RegisterTool(Tool{Name: "", Handler: func(context.Context, map[string]any) (string, error) { return "", nil }}); err == nil { + t.Error("empty name should error") + } + if err := s.RegisterTool(Tool{Name: "x"}); err == nil { + t.Error("nil handler should error") + } + if err := s.RegisterTool(echoTool()); err != nil { + t.Fatalf("first register: %v", err) + } + if err := s.RegisterTool(echoTool()); err == nil { + t.Error("duplicate name should error") + } +} + +func TestRegisterResourceValidation(t *testing.T) { + t.Parallel() + + s := NewServer("t", "0") + if err := s.RegisterResource(Resource{URI: "", Read: func(context.Context) (string, error) { return "", nil }}); err == nil { + t.Error("empty uri should error") + } + if err := s.RegisterResource(Resource{URI: "u"}); err == nil { + t.Error("nil reader should error") + } + ok := Resource{URI: "u", Read: func(context.Context) (string, error) { return "", nil }} + if err := s.RegisterResource(ok); err != nil { + t.Fatalf("first register: %v", err) + } + if err := s.RegisterResource(ok); err == nil { + t.Error("duplicate uri should error") + } +} From 83f0e2e573fcf8193ac43a6807a1e56e04456f1d Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:17:00 +0530 Subject: [PATCH 3/6] feat(anftools): anf_encode and anf_encode_kubernetes MCP tools Registers two tools on an anfmcp.Server: anf_encode (generic lossless JSON to ANF) and anf_encode_kubernetes (domain translator). Apache-2.0. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- pkg/anftools/tools.go | 119 +++++++++++++++++++++++++++++++++++++ pkg/anftools/tools_test.go | 84 ++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 pkg/anftools/tools.go create mode 100644 pkg/anftools/tools_test.go diff --git a/pkg/anftools/tools.go b/pkg/anftools/tools.go new file mode 100644 index 0000000..180b424 --- /dev/null +++ b/pkg/anftools/tools.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package anftools registers the ANF encoding tools on an anfmcp.Server. These +// tools let an MCP client turn verbose system state into token-minimal ANF for +// context engineering and token reduction. +package anftools + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/Clawdlinux/agent-native-format/pkg/anf" + "github.com/Clawdlinux/agent-native-format/pkg/anfmcp" + "github.com/Clawdlinux/agent-native-format/translators/generic" + k8s "github.com/Clawdlinux/agent-native-format/translators/kubernetes" +) + +// Register adds the anf_encode and anf_encode_kubernetes tools to s. +func Register(s *anfmcp.Server) error { + if err := s.RegisterTool(encodeTool()); err != nil { + return err + } + if err := s.RegisterTool(kubernetesTool()); err != nil { + return err + } + return nil +} + +func encodeTool() anfmcp.Tool { + return anfmcp.Tool{ + Name: "anf_encode", + Description: "Encode an arbitrary JSON value as Agent Native Format (ANF), a " + + "line-oriented, token-minimal representation. The mapping is lossless and " + + "deterministic: same facts, far fewer tokens. Use it to compress verbose JSON " + + "state before putting it in the context window.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "data": map[string]any{ + "description": "Any JSON value (object, array, or scalar) to encode as ANF.", + }, + "source": map[string]any{ + "type": "string", + "description": "Optional label for the @source header (default \"agent-input\").", + }, + "scope": map[string]any{ + "type": "string", + "description": "Optional label for the @scope header and root entity name.", + }, + }, + "required": []any{"data"}, + }, + Handler: encodeHandler, + } +} + +func encodeHandler(_ context.Context, args map[string]any) (string, error) { + data, ok := args["data"] + if !ok { + return "", fmt.Errorf("missing required argument: data") + } + source := stringArg(args, "source", "agent-input") + scope := stringArg(args, "scope", "") + + doc, err := generic.Translate(data, source, scope, time.Now().UTC()) + if err != nil { + return "", fmt.Errorf("encode: %w", err) + } + return anf.EncodeToString(doc), nil +} + +func kubernetesTool() anfmcp.Tool { + return anfmcp.Tool{ + Name: "anf_encode_kubernetes", + Description: "Encode a Kubernetes namespace view as ANF using the domain " + + "translator, which surfaces health, alerts, and available actions first. Input " + + "is a namespace view object with cluster, namespace, and resource lists.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "view": map[string]any{ + "type": "object", + "description": "A Kubernetes namespace view: cluster, namespace, deployments[], " + + "services[], jobs[], cronjobs[], events[], agentPermissions{}.", + }, + }, + "required": []any{"view"}, + }, + Handler: kubernetesHandler, + } +} + +func kubernetesHandler(_ context.Context, args map[string]any) (string, error) { + raw, ok := args["view"] + if !ok { + return "", fmt.Errorf("missing required argument: view") + } + // Re-marshal the decoded value, then decode into the typed view. JSON field + // matching is case-insensitive, so camelCase keys map onto the struct. + b, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("marshal view: %w", err) + } + var view k8s.NamespaceView + if err := json.Unmarshal(b, &view); err != nil { + return "", fmt.Errorf("decode view: %w", err) + } + doc := k8s.Translate(view, time.Now().UTC()) + return anf.EncodeToString(doc), nil +} + +func stringArg(args map[string]any, key, def string) string { + if v, ok := args[key].(string); ok { + return v + } + return def +} diff --git a/pkg/anftools/tools_test.go b/pkg/anftools/tools_test.go new file mode 100644 index 0000000..c5e7eb4 --- /dev/null +++ b/pkg/anftools/tools_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +package anftools + +import ( + "context" + "strings" + "testing" + + "github.com/Clawdlinux/agent-native-format/pkg/anfmcp" +) + +func TestEncodeHandler(t *testing.T) { + t.Parallel() + + out, err := encodeHandler(context.Background(), map[string]any{ + "data": map[string]any{"name": "api", "replicas": float64(3)}, + "scope": "svc", + }) + if err != nil { + t.Fatalf("encodeHandler: %v", err) + } + if !strings.Contains(out, "replicas 3") { + t.Errorf("missing replicas property in output:\n%s", out) + } + if !strings.Contains(out, "@scope svc") { + t.Errorf("missing @scope header in output:\n%s", out) + } + if !strings.Contains(out, "@source agent-input") { + t.Errorf("missing default @source header in output:\n%s", out) + } +} + +func TestEncodeHandlerMissingData(t *testing.T) { + t.Parallel() + + if _, err := encodeHandler(context.Background(), map[string]any{}); err == nil { + t.Error("expected error when data argument is missing") + } +} + +func TestKubernetesHandler(t *testing.T) { + t.Parallel() + + view := map[string]any{ + "cluster": "prod", + "namespace": "payments", + "deployments": []any{ + map[string]any{ + "name": "api", + "replicas": float64(3), + "readyReplicas": float64(3), + }, + }, + } + out, err := kubernetesHandler(context.Background(), map[string]any{"view": view}) + if err != nil { + t.Fatalf("kubernetesHandler: %v", err) + } + if !strings.Contains(out, "deployment api") { + t.Errorf("missing deployment entity in output:\n%s", out) + } +} + +func TestKubernetesHandlerMissingView(t *testing.T) { + t.Parallel() + + if _, err := kubernetesHandler(context.Background(), map[string]any{}); err == nil { + t.Error("expected error when view argument is missing") + } +} + +func TestRegisterWiresTools(t *testing.T) { + t.Parallel() + + s := anfmcp.NewServer("anf-mcp", "test") + if err := Register(s); err != nil { + t.Fatalf("Register: %v", err) + } + // Registering again must fail because the tool names are already taken, + // which confirms both tools were registered the first time. + if err := Register(s); err == nil { + t.Error("expected duplicate registration to error") + } +} From 14d918c857ac043bd1a7f46d1cd95e0a07344416 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:20:07 +0530 Subject: [PATCH 4/6] feat(anf-mcp): stdio MCP server binary exposing ANF tools cmd/anf-mcp wires the ANF tools and embeds the spec as the anf://spec/format resource, so the binary is self-contained. A drift test guards the embedded spec against the canonical FORMAT.md. Adds the Makefile build target and a LICENSE note marking cmd/anf-mcp and translators/generic as Apache-2.0 so the whole packaging is open source. go install .../cmd/anf-mcp@latest. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- LICENSE | 1 + Makefile | 1 + cmd/anf-mcp/FORMAT.md | 380 +++++++++++++++++++++++++++++++++++++++ cmd/anf-mcp/main.go | 106 +++++++++++ cmd/anf-mcp/main_test.go | 94 ++++++++++ 5 files changed, 582 insertions(+) create mode 100644 cmd/anf-mcp/FORMAT.md create mode 100644 cmd/anf-mcp/main.go create mode 100644 cmd/anf-mcp/main_test.go diff --git a/LICENSE b/LICENSE index 1e34a80..bdbd3ba 100644 --- a/LICENSE +++ b/LICENSE @@ -15,6 +15,7 @@ This repository also contains materials under separate licenses: - SPEC.md and docs/protocol.md: Creative Commons Attribution 4.0 International (CC BY 4.0) - pkg/ and adapters/: Apache License, Version 2.0 +- cmd/anf-mcp/ and translators/generic/: Apache License, Version 2.0 - benchmark methodology and scenario definitions: Apache License, Version 2.0 unless otherwise noted If any file contains an explicit license notice, that notice controls for that file. diff --git a/Makefile b/Makefile index 6768ea1..bc00ba0 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ docker-build: build: $(GO) build -trimpath -ldflags="-s -w" -o bin/acp-server ./cmd/acp-server $(GO) build -trimpath -ldflags="-s -w" -o bin/acp-bridge ./cmd/acp-bridge + $(GO) build -trimpath -ldflags="-s -w" -o bin/anf-mcp ./cmd/anf-mcp generate: @if [ ! -x $(MOCKGEN) ]; then \ diff --git a/cmd/anf-mcp/FORMAT.md b/cmd/anf-mcp/FORMAT.md new file mode 100644 index 0000000..1e18c95 --- /dev/null +++ b/cmd/anf-mcp/FORMAT.md @@ -0,0 +1,380 @@ +# Agent Native Format (ANF) Specification + +**Version:** 0.1.0-draft +**Status:** Draft +**License:** CC BY 4.0 +**Authors:** Shreyansh Sancheti (NineVigil / Clawdlinux) +**Date:** 2026-05-03 + +--- + +## Abstract + +Agent Native Format (ANF) is a line-oriented, token-minimal representation +language designed for AI agent consumption. Where JSON, YAML, and HTML are +built for human readability, ANF is built for LLM token efficiency — +encoding maximum decision-relevant information in minimum context window +space. + +ANF is not a general-purpose data format. It is a **view format**: a +translated, compressed representation of system state that an AI agent +needs to make decisions and take actions. The source systems (Kubernetes +APIs, SaaS platforms, databases) remain unchanged. ANF is the translation +layer between them and the agents that consume them. + +## 1. Design Principles + +1. **Token density over human readability.** Every token must carry + decision-relevant information. No boilerplate, no decorative syntax. +2. **Self-describing without schemas.** An LLM can parse and reason about + ANF without a separate schema definition. Field names are chosen to be + unambiguous single tokens. +3. **Decision-oriented structure.** Health status, alerts, and available + actions surface first — not buried in nested data structures. +4. **Line-oriented.** One logical unit per line. Agents can scan, skip, + and focus on relevant lines without parsing nested structures. +5. **Domain-portable.** The same syntax works for Kubernetes clusters, + SaaS dashboards, database state, CI/CD pipelines — any operational + domain. + +## 2. Token Efficiency Rationale + +LLM tokenizers (BPE-based: cl100k_base, o200k_base) assign single tokens +to common English words, short punctuation, and whitespace. ANF exploits +this: + +- **No quotes.** JSON `"key": "value"` costs 5 tokens. ANF `key:value` + costs 2-3 tokens. +- **No braces or brackets.** Hierarchy via indentation (spaces are cheap: + 4 spaces = 1 token). +- **No commas.** Line breaks are delimiters (newline = 1 token). +- **Semantic prefixes.** `@`, `!`, `?`, `>` are single tokens that encode + meaning (metadata, alert, action, output). +- **Compact values.** `3/3` not `{"desired": 3, "ready": 3}`. `42%` not + `{"percentage": 42}`. + +Measured: a Kubernetes namespace view that costs **~12,000 tokens** as raw +API JSON and **~2,400 tokens** as filtered JSON costs **~350 tokens** in +ANF. + +## 3. Syntax + +### 3.1 Document Structure + +An ANF document has four sections in fixed order. All are optional except +at least one entity. + +``` +[headers] @-prefixed metadata lines +[entities] Indented hierarchical state +[alerts] !-prefixed warnings/issues +[actions] ?-prefixed available operations +``` + +### 3.2 Headers + +Headers provide scope and context. Prefix: `@` + +``` +@source kubernetes/prod-east +@scope namespace:payments +@time 2026-05-03T10:30:00Z +@ttl 60s +@translator ninevigil/k8s-translator:0.3.0 +``` + +| Header | Required | Description | +|--------|----------|-------------| +| `@source` | yes | Origin system and identifier | +| `@scope` | no | Narrowing filter applied | +| `@time` | yes | Snapshot timestamp (ISO 8601) | +| `@ttl` | no | Seconds until this view is stale | +| `@translator` | no | Translator that produced this view | + +### 3.3 Entities + +Entities are the core data. They follow this grammar: + +``` + [] [] + + + [] [] +``` + +**Types** are lowercase singular nouns: `deployment`, `pod`, `service`, +`node`, `job`, `table`, `channel`, `pipeline`. + +**Status markers** are bracketed keywords: + +| Marker | Meaning | +|--------|---------| +| `[healthy]` | All checks passing | +| `[degraded]` | Partially functional | +| `[failing]` | Critical failure | +| `[pending]` | Waiting / not yet ready | +| `[terminated]` | Stopped / completed | +| `[unknown]` | Status cannot be determined | + +**Inline properties** appear on the same line as the entity, space-separated: + +``` +deployment payment-api [healthy] replicas:3/3 age:14d +``` + +**Indented properties** appear on subsequent lines, 2-space indent: + +``` +deployment payment-api [healthy] replicas:3/3 age:14d + image registry.io/payment-api:v2.4.1 + strategy rolling maxSurge:1 + cpu 42% mem 61% +``` + +**Child entities** are indented under their parent: + +``` +deployment payment-api [healthy] replicas:3/3 + pod payment-api-7f8d [running] cpu:42% mem:61% restarts:0 + pod payment-api-9a2b [running] cpu:38% mem:55% restarts:0 +``` + +### 3.4 Property Values + +Values use compact notation: + +| Pattern | Meaning | Example | +|---------|---------|---------| +| `N/M` | Ratio (ready/desired) | `replicas:3/3` | +| `N%` | Percentage | `cpu:42%` | +| `Ns`, `Nm`, `Nh`, `Nd` | Duration | `age:14d`, `timeout:30s` | +| `N` | Integer | `restarts:3` | +| `N.N` | Float | `cost:0.042` | +| `key:value` | Labeled value | `image:v2.4.1` | +| Bare word | String value | `strategy rolling` | + +### 3.5 Alerts + +Alerts surface issues that need attention. Prefix: `!` + +``` +!critical pod payment-worker-9a2b OOMKilled restarts:5/1h +!warning deployment payment-worker mem:89% threshold:80% +!info deployment payment-api image-outdated latest:v2.5.0 +``` + +Severity levels: `!critical`, `!warning`, `!info`. + +Alerts always reference an entity by ` `. + +### 3.6 Actions + +Actions declare what operations the agent can perform. Prefix: `?` + +``` +?scale deployment payment-api range:1-10 current:3 +?scale deployment payment-worker range:1-5 current:2 +?rollout deployment payment-api to:v2.5.0 strategy:rolling +?restart deployment payment-worker +?logs pod payment-worker-9a2b lines:100 since:1h +?exec pod payment-worker-9a2b shell:/bin/sh +?describe any +``` + +Grammar: `? []` + +Actions are the critical differentiator from data formats. ANF doesn't +just describe state — it tells the agent what it **can do** in response +to that state. The translation layer computes available actions from the +agent's permissions, the current state, and operational policies. + +### 3.7 Comments + +Lines starting with `#` are comments. They are stripped before token +counting and should not be included in production output. + +``` +# This is for debugging only +``` + +### 3.8 Multi-View Documents + +Multiple views can be concatenated with `---` separators: + +``` +@source kubernetes/prod-east +@scope namespace:payments +... +--- +@source slack/workspace +@scope channel:#payments-alerts +... +``` + +## 4. Full Example + +### 4.1 Kubernetes Namespace View + +``` +@source kubernetes/prod-east +@scope namespace:payments +@time 2026-05-03T10:30:00Z +@ttl 60s + +deployment payment-api [healthy] replicas:3/3 age:14d + image registry.io/payments/api:v2.4.1 + strategy rolling maxSurge:1 maxUnavail:0 + cpu 42% mem 61% requests:1.2k/s errors:0.02% + pod payment-api-7f8d [running] node:worker-01 cpu:45% mem:63% restarts:0 + pod payment-api-9a2b [running] node:worker-02 cpu:38% mem:55% restarts:0 + pod payment-api-1c4e [running] node:worker-01 cpu:44% mem:64% restarts:0 + +deployment payment-worker [degraded] replicas:2/2 age:8d + image registry.io/payments/worker:v1.8.0 + cpu 87% mem 78% + pod payment-worker-a3f1 [running] node:worker-03 cpu:87% mem:78% restarts:3/24h + pod payment-worker-b7e2 [running] node:worker-03 cpu:82% mem:74% restarts:1/24h + +service payment-api ClusterIP 8080>8080 endpoints:3/3 +service payment-grpc ClusterIP 9090>9090 endpoints:2/2 + +job daily-reconciliation [completed] last:2026-05-03T06:00:00Z duration:4m success:true +cronjob hourly-sync schedule:0_*_*_*_* last-run:2026-05-03T10:00:00Z next:2026-05-03T11:00:00Z + +!warning deployment payment-worker mem:89% threshold:80% +!warning pod payment-worker-a3f1 restarts:3/24h threshold:2 +!info deployment payment-api image-outdated latest:v2.5.0 current:v2.4.1 + +?scale deployment payment-api range:1-10 current:3 +?scale deployment payment-worker range:1-5 current:2 +?rollout deployment payment-api to:v2.5.0 strategy:rolling +?restart deployment payment-worker +?logs pod payment-worker-a3f1 since:1h +?exec pod payment-worker-a3f1 shell:/bin/sh +``` + +### 4.2 Token Comparison + +The same Kubernetes namespace state represented three ways: + +| Format | Tokens (cl100k_base) | Reduction vs JSON | +|--------|---------------------|-------------------| +| Raw Kubernetes API JSON | ~12,000 | — | +| Filtered JSON (relevant fields only) | ~2,400 | 80% | +| ANF (above) | ~350 | **97%** | + +The 80% reduction from JSON filtering is what existing MCP optimization +tools achieve. The additional 85% reduction from filtered JSON to ANF +is what the agent-native format provides — encoding the same information +in a syntax designed for how LLMs tokenize. + +## 5. Translation Architecture + +ANF is produced by **translators** — domain-specific components that +convert raw system state into ANF views. + +``` +┌──────────┐ ┌──────────────┐ ┌──────────┐ +│ K8s API │────>│ K8s │────>│ │ +└──────────┘ │ Translator │ │ │ + └──────────────┘ │ │ +┌──────────┐ ┌──────────────┐ │ ANF │ +│ Slack API│────>│ Slack │────>│ View │──> Agent +└──────────┘ │ Translator │ │ │ + └──────────────┘ │ │ +┌──────────┐ ┌──────────────┐ │ │ +│ PG/MySQL │────>│ Database │────>│ │ +└──────────┘ │ Translator │ └──────────┘ + └──────────────┘ +``` + +A translator MUST: +1. Fetch current state from the source system +2. Filter to decision-relevant information +3. Compute health status from raw metrics +4. Determine available actions from permissions + state +5. Emit valid ANF + +A translator SHOULD: +- Cache source responses within the TTL +- Log which fields were accessed by agents (for optimization) +- Strip fields that agents consistently ignore + +## 6. Parsing + +ANF is intentionally simple to parse. The grammar is: + +``` +document = (header | entity | alert | action | separator | comment)* +header = "@" key SP value NL +entity = INDENT type SP name (SP status)? (SP prop)* NL (child)* +child = INDENT INDENT (entity | prop-line) +prop-line = INDENT INDENT key (SP value)* NL +alert = "!" severity SP type SP name SP message (SP prop)* NL +action = "?" verb SP type SP name (SP param)* NL +separator = "---" NL +comment = "#" text NL +status = "[" word "]" +prop = key ":" value +``` + +Reference parsers are provided in Go and Python in the agent-contract-protocol +repository. + +## 7. Extending ANF for New Domains + +To add a new domain (e.g., CI/CD pipelines): + +1. Define entity types: `pipeline`, `stage`, `step`, `artifact` +2. Define status markers (same vocabulary: healthy/degraded/failing/...) +3. Define action verbs: `?trigger`, `?cancel`, `?retry`, `?approve` +4. Write a translator that maps the source API → ANF + +The core syntax does not change. Domain vocabulary is additive. + +### 7.1 SaaS Example + +``` +@source salesforce/prod +@scope opportunity pipeline:enterprise +@time 2026-05-03T10:30:00Z + +opportunity Acme-Corp-Renewal [at-risk] value:$450k stage:negotiation age:62d + owner Sarah Chen + next-step "Send revised proposal" due:2026-05-05 + competitor Datadog mentioned:3x + +opportunity BigBank-Expansion [healthy] value:$1.2M stage:evaluation age:28d + owner James Wu + next-step "Technical deep-dive scheduled" due:2026-05-07 + +!warning opportunity Acme-Corp-Renewal stale:14d no-activity-since:2026-04-19 +!info pipeline close-rate:32% target:40% gap:-8pp + +?update opportunity Acme-Corp-Renewal stage:proposal +?create task owner:Sarah-Chen "Follow up on revised pricing" +?forecast pipeline method:weighted +``` + +## 8. Security + +- ANF views MUST NOT contain credentials, tokens, or secrets. +- Translators MUST filter sensitive fields (env vars with secret values, + annotations with tokens) before emitting ANF. +- The `@scope` header declares what the view covers — agents should not + assume they have visibility beyond the stated scope. +- Action availability (`?` lines) reflects the agent's actual permissions. + If the agent cannot scale a deployment, that action does not appear. + +## 9. Versioning + +The format version is implicit in the translator version (`@translator` +header). The core syntax (prefixes, indentation, status markers) is +stable across versions. Domain vocabulary may expand but never removes +existing entity types or status markers. + +--- + +*This specification is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). +You are free to share and adapt this material for any purpose, including +commercial, with attribution.* diff --git a/cmd/anf-mcp/main.go b/cmd/anf-mcp/main.go new file mode 100644 index 0000000..e9602a8 --- /dev/null +++ b/cmd/anf-mcp/main.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Command anf-mcp runs a stateless MCP stdio server that exposes the Agent +// Native Format (ANF) encoder as tools. Point any MCP client at it to turn +// verbose system state into token-minimal ANF for context engineering. +// +// Tools: +// +// anf_encode generic, lossless JSON -> ANF +// anf_encode_kubernetes Kubernetes namespace view -> ANF (domain translator) +// +// Resource: +// +// anf://spec/format the ANF format specification (FORMAT.md) +// +// Usage in an MCP client config (Claude Desktop, Cursor, VS Code, Codex): +// +// { +// "mcpServers": { +// "anf": { +// "command": "anf-mcp" +// } +// } +// } +// +// VS Code uses the "servers" key with an explicit stdio type: +// +// { +// "servers": { +// "anf": { "type": "stdio", "command": "anf-mcp" } +// } +// } +// +// Install with: go install github.com/Clawdlinux/agent-native-format/cmd/anf-mcp@latest +package main + +import ( + "context" + _ "embed" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/Clawdlinux/agent-native-format/pkg/anfmcp" + "github.com/Clawdlinux/agent-native-format/pkg/anftools" +) + +// version is set at build time via -ldflags "-X main.version=...". +var version = "0.1.0-dev" + +// specMarkdown is the ANF specification, embedded so the binary is +// self-contained. A test verifies it matches the canonical FORMAT.md. +// +//go:embed FORMAT.md +var specMarkdown string + +const instructions = "anf_encode turns any JSON value into Agent Native Format: a line-oriented, " + + "token-minimal representation that keeps the same facts in far fewer tokens. Use it to " + + "compress verbose tool output or system state before adding it to context. " + + "anf_encode_kubernetes does the same for a Kubernetes namespace view and surfaces health first. " + + "Read the anf://spec/format resource for the exact format." + +// buildServer wires the ANF tools and spec resource onto a new server. It is +// separated from main so tests can exercise the same configuration. +func buildServer(logger *slog.Logger) (*anfmcp.Server, error) { + s := anfmcp.NewServer("anf-mcp", version, + anfmcp.WithLogger(logger), + anfmcp.WithInstructions(instructions), + ) + if err := anftools.Register(s); err != nil { + return nil, err + } + err := s.RegisterResource(anfmcp.Resource{ + URI: "anf://spec/format", + Name: "Agent Native Format specification", + Description: "The ANF format definition (FORMAT.md).", + MimeType: "text/markdown", + Read: func(context.Context) (string, error) { + return specMarkdown, nil + }, + }) + if err != nil { + return nil, err + } + return s, nil +} + +func main() { + // Logs go to stderr so they never corrupt the JSON-RPC stream on stdout. + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + s, err := buildServer(logger) + if err != nil { + logger.Error("build server", "error", err) + os.Exit(1) + } + + if err := s.Serve(ctx, os.Stdin, os.Stdout); err != nil && ctx.Err() == nil { + logger.Error("serve", "error", err) + os.Exit(1) + } +} diff --git a/cmd/anf-mcp/main_test.go b/cmd/anf-mcp/main_test.go new file mode 100644 index 0000000..e4772fe --- /dev/null +++ b/cmd/anf-mcp/main_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "log/slog" + "os" + "strings" + "testing" +) + +// TestSpecEmbedMatchesRoot fails if the embedded FORMAT.md drifts from the +// canonical spec at the repository root. +func TestSpecEmbedMatchesRoot(t *testing.T) { + t.Parallel() + + root, err := os.ReadFile("../../FORMAT.md") + if err != nil { + t.Fatalf("read canonical FORMAT.md: %v", err) + } + if specMarkdown != string(root) { + t.Errorf("embedded spec drifted from ../../FORMAT.md; re-copy it into cmd/anf-mcp/FORMAT.md") + } +} + +// TestBuildServerSmoke drives the fully wired server end-to-end over stdio: it +// lists tools, reads the spec resource, and encodes JSON via anf_encode. +func TestBuildServerSmoke(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil)) + s, err := buildServer(logger) + if err != nil { + t.Fatalf("buildServer: %v", err) + } + + requests := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"anf_encode","arguments":{"data":{"name":"api","replicas":3},"scope":"svc"}}}`, + `{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"anf://spec/format"}}`, + }, "\n") + "\n" + + var out bytes.Buffer + if err := s.Serve(context.Background(), strings.NewReader(requests), &out); err != nil { + t.Fatalf("Serve: %v", err) + } + + var responses []map[string]any + sc := bufio.NewScanner(&out) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + if len(sc.Bytes()) == 0 { + continue + } + var m map[string]any + if err := json.Unmarshal(sc.Bytes(), &m); err != nil { + t.Fatalf("decode: %v", err) + } + responses = append(responses, m) + } + if len(responses) != 3 { + t.Fatalf("want 3 responses, got %d", len(responses)) + } + + // tools/list must advertise both tools. + toolsResult := responses[0]["result"].(map[string]any) + tools := toolsResult["tools"].([]any) + names := map[string]bool{} + for _, tv := range tools { + names[tv.(map[string]any)["name"].(string)] = true + } + if !names["anf_encode"] || !names["anf_encode_kubernetes"] { + t.Errorf("missing expected tools, got %v", names) + } + + // anf_encode must return ANF text containing the replicas property. + callResult := responses[1]["result"].(map[string]any) + content := callResult["content"].([]any) + text := content[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "replicas 3") { + t.Errorf("anf_encode output missing replicas:\n%s", text) + } + + // resources/read must return the spec markdown. + readResult := responses[2]["result"].(map[string]any) + contents := readResult["contents"].([]any) + specText := contents[0].(map[string]any)["text"].(string) + if !strings.Contains(specText, "Agent Native Format") { + t.Errorf("spec resource content unexpected:\n%s", specText[:min(80, len(specText))]) + } +} From 6adedaf40fb980e28bab3ddf15ccbe18cb5840ec Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:22:22 +0530 Subject: [PATCH 5/6] docs(anf-mcp): client configs, skill packaging, README entry cmd/anf-mcp/README.md with Claude/Cursor/VS Code/Codex configs and honest token caveats. examples/anf-skill/SKILL.md packages ANF as an agent skill. README lists the MCP server under what ships today. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- README.md | 3 ++ cmd/anf-mcp/README.md | 83 +++++++++++++++++++++++++++++++++++++ examples/anf-skill/SKILL.md | 41 ++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 cmd/anf-mcp/README.md create mode 100644 examples/anf-skill/SKILL.md diff --git a/README.md b/README.md index 80cad79..86bb1f5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ about 350 tokens as ANF. The full spec is in [`FORMAT.md`](FORMAT.md). - **Go encoder.** [`pkg/anf`](pkg/anf), Apache 2.0. Build and emit ANF documents. - **Kubernetes translator.** [`translators/kubernetes`](translators/kubernetes). Live cluster state to ANF. +- **MCP server.** [`cmd/anf-mcp`](cmd/anf-mcp), Apache 2.0. A stateless stdio MCP + server exposing ANF encoding tools to any MCP client (Claude, Cursor, VS Code, + Codex). `go install github.com/Clawdlinux/agent-native-format/cmd/anf-mcp@latest`. - **Benchmarks.** Reproducible token measurements against raw and filtered JSON. ## Governed execution runtime diff --git a/cmd/anf-mcp/README.md b/cmd/anf-mcp/README.md new file mode 100644 index 0000000..605d2d4 --- /dev/null +++ b/cmd/anf-mcp/README.md @@ -0,0 +1,83 @@ +# anf-mcp + +A stateless MCP stdio server that exposes the Agent Native Format (ANF) encoder +as tools. Point any MCP client at it to turn verbose JSON state into +token-minimal ANF for context engineering and token reduction. + +Apache-2.0. No third-party dependencies. + +## Install + +```sh +go install github.com/Clawdlinux/agent-native-format/cmd/anf-mcp@latest +``` + +The binary is `anf-mcp` on your `PATH`. It speaks JSON-RPC 2.0 over stdin and +stdout. It holds no session state, so any request can be handled in isolation. + +## Configure your client + +Claude Desktop, Cursor, and Codex use the `mcpServers` key: + +```json +{ + "mcpServers": { + "anf": { + "command": "anf-mcp" + } + } +} +``` + +VS Code uses the `servers` key with an explicit stdio type: + +```json +{ + "servers": { + "anf": { "type": "stdio", "command": "anf-mcp" } + } +} +``` + +If `anf-mcp` is not on your `PATH`, use the absolute path from +`go env GOPATH`/bin. + +## What it exposes + +Tools: + +- `anf_encode` takes any JSON value and returns ANF. The mapping is lossless and + deterministic. Objects become entities, scalars become properties, arrays + become child entities. Keys are sorted. Nothing is dropped and nothing is + invented. +- `anf_encode_kubernetes` takes a Kubernetes namespace view and returns ANF via + the domain translator, which surfaces health, alerts, and available actions + first. + +Resource: + +- `anf://spec/format` returns the ANF specification (`FORMAT.md`), embedded in + the binary so it works offline. + +## Where the tokens go, honestly + +`anf_encode` strips the syntactic overhead of JSON: braces, quotes, and commas. +Same facts, fewer tokens. It does not infer health, status, or alerts, so it +will not mislead an agent about state. + +Most of the large reductions in our benchmarks come from two things, not from +clever notation: + +1. Scoping. Feed the model only the state a task needs. +2. Domain translators. `anf_encode_kubernetes` knows what matters in a namespace + and puts it first. + +Recent research on compact agent formats (arXiv 2605.29676, 2606.01326) shows +notation tricks save less than people expect and can hurt model accuracy. So +this server does not sell a magic format. It scopes and compacts. Measure it on +your own data before you trust a number. + +## Stateless by design + +The server follows the stateless-first direction of MCP SEP-2575. It implements +`server/discover` and keeps `initialize` for clients that still send it. diff --git a/examples/anf-skill/SKILL.md b/examples/anf-skill/SKILL.md new file mode 100644 index 0000000..3eb7035 --- /dev/null +++ b/examples/anf-skill/SKILL.md @@ -0,0 +1,41 @@ +--- +name: agent-native-format +description: Use when tool output or system state is large and eating context. Turns verbose JSON (Kubernetes state, API responses, dashboards, config dumps) into Agent Native Format (ANF), a line-oriented, token-minimal representation, via the anf-mcp server. Same facts, fewer tokens. Use before pasting big JSON into context, or when an agent's context window is filling with structured state. +--- + +# Agent Native Format (ANF) + +ANF is a line-oriented, token-minimal way to represent structured state for an +LLM. JSON, YAML, and HTML are built for humans and machines. Agents pay for the +braces, quotes, and commas in tokens. ANF keeps the same facts with far less +syntax. + +This skill uses the `anf-mcp` MCP server. Install it once: + +```sh +go install github.com/Clawdlinux/agent-native-format/cmd/anf-mcp@latest +``` + +Then register it in your MCP client (see cmd/anf-mcp/README.md for configs). + +## When to use it + +- A tool returned a large JSON blob and you only need the facts, not the syntax. +- The context window is filling with structured state. +- You are about to paste a Kubernetes namespace, an API response, or a config + dump into context. + +## How to use it + +- `anf_encode` with `{"data": }` returns ANF for arbitrary JSON. It is + lossless and deterministic. Pass an optional `scope` to label the root. +- `anf_encode_kubernetes` with `{"view": }` returns ANF for a + Kubernetes namespace and surfaces health first. +- Read the `anf://spec/format` resource to learn the exact format. + +## What it will not do + +It will not invent status, health, or alerts for generic JSON. It only removes +syntax. The big token savings come from scoping (only encode what the task +needs) and from domain translators, not from the notation itself. Measure on +your own data before trusting a specific reduction number. From ff5b049f37eeb01d400d63662ccf17dc1d06c3f1 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:38:02 +0530 Subject: [PATCH 6/6] fix(anf-mcp): address peer review (precision, notifications, honesty) Independent review found real issues; fixes: - preserve large integers: decode tool args with json.Number, render verbatim (float64 was silently corrupting integers above 2^53) - never reply to JSON-RPC notifications (no-id requests) for any method - reword docs from 'lossless' to 'structure-preserving; no fields dropped' and document the newline caveat (ANF is line-oriented and unescaped) - guard generic translator recursion depth (reject pathological nesting) - scalarString handles json.Number and stringifies unexpected types - sort alert/action map keys so anf_encode_kubernetes output is deterministic - LICENSE marks all of translators/ Apache-2.0 - tests for big-int precision, depth limit, empty containers, notifications Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- LICENSE | 2 +- cmd/anf-mcp/README.md | 13 +++-- examples/anf-skill/SKILL.md | 3 +- pkg/anf/encoder.go | 20 +++++-- pkg/anfmcp/server.go | 20 ++++--- pkg/anfmcp/server_test.go | 18 ++++++ pkg/anftools/tools.go | 6 +- translators/generic/translate.go | 81 ++++++++++++++++++--------- translators/generic/translate_test.go | 50 +++++++++++++++++ 9 files changed, 165 insertions(+), 48 deletions(-) diff --git a/LICENSE b/LICENSE index bdbd3ba..16cac44 100644 --- a/LICENSE +++ b/LICENSE @@ -15,7 +15,7 @@ This repository also contains materials under separate licenses: - SPEC.md and docs/protocol.md: Creative Commons Attribution 4.0 International (CC BY 4.0) - pkg/ and adapters/: Apache License, Version 2.0 -- cmd/anf-mcp/ and translators/generic/: Apache License, Version 2.0 +- cmd/anf-mcp/ and translators/: Apache License, Version 2.0 - benchmark methodology and scenario definitions: Apache License, Version 2.0 unless otherwise noted If any file contains an explicit license notice, that notice controls for that file. diff --git a/cmd/anf-mcp/README.md b/cmd/anf-mcp/README.md index 605d2d4..6626150 100644 --- a/cmd/anf-mcp/README.md +++ b/cmd/anf-mcp/README.md @@ -46,10 +46,10 @@ If `anf-mcp` is not on your `PATH`, use the absolute path from Tools: -- `anf_encode` takes any JSON value and returns ANF. The mapping is lossless and - deterministic. Objects become entities, scalars become properties, arrays - become child entities. Keys are sorted. Nothing is dropped and nothing is - invented. +- `anf_encode` takes any JSON value and returns ANF. The mapping is deterministic + and structure-preserving. Objects become entities, scalars become properties, + arrays become child entities. Keys are sorted. Nothing is dropped and nothing + is invented. - `anf_encode_kubernetes` takes a Kubernetes namespace view and returns ANF via the domain translator, which surfaces health, alerts, and available actions first. @@ -65,6 +65,11 @@ Resource: Same facts, fewer tokens. It does not infer health, status, or alerts, so it will not mislead an agent about state. +One caveat: ANF is line-oriented and does not escape values. A string value that +contains a newline is not byte-exact round-trippable. The facts survive; the +framing of a multi-line value does not. Very large integers are preserved (the +server decodes numbers without float rounding). + Most of the large reductions in our benchmarks come from two things, not from clever notation: diff --git a/examples/anf-skill/SKILL.md b/examples/anf-skill/SKILL.md index 3eb7035..5b03cca 100644 --- a/examples/anf-skill/SKILL.md +++ b/examples/anf-skill/SKILL.md @@ -28,7 +28,8 @@ Then register it in your MCP client (see cmd/anf-mcp/README.md for configs). ## How to use it - `anf_encode` with `{"data": }` returns ANF for arbitrary JSON. It is - lossless and deterministic. Pass an optional `scope` to label the root. + deterministic and structure-preserving; nothing is dropped. Pass an optional + `scope` to label the root. - `anf_encode_kubernetes` with `{"view": }` returns ANF for a Kubernetes namespace and surfaces health first. - Read the `anf://spec/format` resource to learn the exact format. diff --git a/pkg/anf/encoder.go b/pkg/anf/encoder.go index 2e907ef..ae202d9 100644 --- a/pkg/anf/encoder.go +++ b/pkg/anf/encoder.go @@ -3,6 +3,7 @@ package anf import ( "io" + "sort" "strings" ) @@ -99,11 +100,11 @@ func writeAlert(b *strings.Builder, a Alert) { b.WriteString(a.Name) b.WriteByte(' ') b.WriteString(a.Message) - for k, v := range a.Props { + for _, k := range sortedMapKeys(a.Props) { b.WriteByte(' ') b.WriteString(k) b.WriteByte(':') - b.WriteString(v) + b.WriteString(a.Props[k]) } b.WriteByte('\n') } @@ -115,11 +116,11 @@ func writeAction(b *strings.Builder, a Action) { b.WriteString(a.Type) b.WriteByte(' ') b.WriteString(a.Name) - for k, v := range a.Params { + for _, k := range sortedMapKeys(a.Params) { b.WriteByte(' ') b.WriteString(k) b.WriteByte(':') - b.WriteString(v) + b.WriteString(a.Params[k]) } b.WriteByte('\n') } @@ -129,3 +130,14 @@ func writeIndent(b *strings.Builder, depth int) { b.WriteString(" ") } } + +// sortedMapKeys returns a map's keys in lexicographic order so alert and action +// property output is deterministic. +func sortedMapKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/pkg/anfmcp/server.go b/pkg/anfmcp/server.go index 0e13d3f..84f6627 100644 --- a/pkg/anfmcp/server.go +++ b/pkg/anfmcp/server.go @@ -21,6 +21,7 @@ package anfmcp import ( "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -228,7 +229,12 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error { // handle dispatches one request. The second return reports whether a response // should be written (false for notifications). func (s *Server) handle(ctx context.Context, req rpcRequest) (rpcResponse, bool) { - isNotification := len(req.ID) == 0 + // A request without an id is a notification. JSON-RPC 2.0 forbids replying + // to notifications. Every method here is side-effect free, so dropping a + // notification loses nothing. + if len(req.ID) == 0 { + return rpcResponse{}, false + } switch req.Method { case "server/discover": @@ -237,8 +243,6 @@ func (s *Server) handle(ctx context.Context, req rpcRequest) (rpcResponse, bool) case "initialize": // Legacy handshake, retained for current clients that still send it. return s.ok(req.ID, s.initializeResult(req.Params)), true - case "notifications/initialized", "notifications/cancelled": - return rpcResponse{}, false case "ping": return s.ok(req.ID, map[string]any{}), true case "tools/list": @@ -250,10 +254,6 @@ func (s *Server) handle(ctx context.Context, req rpcRequest) (rpcResponse, bool) case "resources/read": return s.resourcesRead(ctx, req), true default: - if isNotification { - // Unknown notification: ignore silently per JSON-RPC. - return rpcResponse{}, false - } return s.fail(req.ID, codeMethodNotFound, "method not found: "+req.Method), true } } @@ -329,7 +329,11 @@ type toolCallParams struct { func (s *Server) toolsCall(ctx context.Context, req rpcRequest) rpcResponse { var p toolCallParams - if err := json.Unmarshal(req.Params, &p); err != nil { + // Decode with UseNumber so large integers survive as json.Number rather + // than being rounded through float64. + dec := json.NewDecoder(bytes.NewReader(req.Params)) + dec.UseNumber() + if err := dec.Decode(&p); err != nil { return s.fail(req.ID, codeInvalidParams, "invalid params: "+err.Error()) } if p.Name == "" { diff --git a/pkg/anfmcp/server_test.go b/pkg/anfmcp/server_test.go index b0f9f37..8986a39 100644 --- a/pkg/anfmcp/server_test.go +++ b/pkg/anfmcp/server_test.go @@ -266,6 +266,24 @@ func TestServerNotificationHasNoResponse(t *testing.T) { } } +// TestServerKnownMethodNotification verifies that a known method (ping) sent +// without an id is treated as a notification and receives no response. +func TestServerKnownMethodNotification(t *testing.T) { + t.Parallel() + + resp := runServer(t, nil, + `{"jsonrpc":"2.0","method":"ping"}`, + `{"jsonrpc":"2.0","id":9,"method":"ping"}`, + ) + + if len(resp) != 1 { + t.Fatalf("want 1 response (only the id'd ping), got %d", len(resp)) + } + if string(resp[0].ID) != "9" { + t.Errorf("response id = %s, want 9", resp[0].ID) + } +} + func TestServerParseError(t *testing.T) { t.Parallel() diff --git a/pkg/anftools/tools.go b/pkg/anftools/tools.go index 180b424..38ca208 100644 --- a/pkg/anftools/tools.go +++ b/pkg/anftools/tools.go @@ -32,9 +32,9 @@ func encodeTool() anfmcp.Tool { return anfmcp.Tool{ Name: "anf_encode", Description: "Encode an arbitrary JSON value as Agent Native Format (ANF), a " + - "line-oriented, token-minimal representation. The mapping is lossless and " + - "deterministic: same facts, far fewer tokens. Use it to compress verbose JSON " + - "state before putting it in the context window.", + "line-oriented, token-minimal representation. The mapping is deterministic and " + + "structure-preserving: no fields are dropped and no semantics are inferred. Use it " + + "to compress verbose JSON state before putting it in the context window.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ diff --git a/translators/generic/translate.go b/translators/generic/translate.go index 2a05f60..f574b1e 100644 --- a/translators/generic/translate.go +++ b/translators/generic/translate.go @@ -1,12 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 -// Package generic provides a deterministic, lossless translator that converts -// arbitrary decoded JSON into an ANF document. It performs a purely structural -// mapping: every key and scalar in the input appears exactly once in the output -// and no semantics (health, status, alerts, or actions) are inferred. +// Package generic provides a deterministic, structure-preserving translator +// that converts arbitrary decoded JSON into an ANF document. It performs a +// purely structural mapping: no fields are dropped and no semantics (health, +// status, alerts, or actions) are inferred. It only removes the syntactic +// overhead of JSON. +// +// Caveat: ANF is line-oriented and does not escape values. A string value that +// contains a newline is emitted verbatim, so byte-exact round-tripping of such +// values is not guaranteed. The facts are preserved; the exact framing of a +// multi-line value is not. package generic import ( + "encoding/json" + "fmt" "math" "sort" "strconv" @@ -15,14 +23,18 @@ import ( "github.com/Clawdlinux/agent-native-format/pkg/anf" ) +// maxDepth bounds recursion so pathologically nested input cannot overflow the +// stack. Inputs nested deeper than this are rejected with an error. +const maxDepth = 512 + // translatorName identifies this translator in the document header. const translatorName = "clawdlinux/generic-translator" // Translate converts an arbitrary decoded JSON value (the result of // json.Unmarshal into any) into an ANF Document. It is deterministic and -// lossless: every key and scalar in the input appears exactly once in the -// output, and no semantics are inferred. source and scope populate the document -// header; now sets the timestamp. +// structure-preserving: no fields are dropped and no semantics are inferred. +// source and scope populate the document header; now sets the timestamp. It +// returns an error if the input nests deeper than maxDepth. func Translate(input any, source, scope string, now time.Time) (*anf.Document, error) { doc := anf.NewDocument(source, scope, now) doc.SetTranslator(translatorName) @@ -38,12 +50,20 @@ func Translate(input any, source, scope string, now time.Time) (*anf.Document, e doc.AddEntity(e) } for _, k := range containers { - doc.AddEntity(entityFromValue(k, v[k])) + child, err := entityFromValue(k, v[k], 1) + if err != nil { + return nil, err + } + doc.AddEntity(child) } case []any: e := anf.Entity{Type: "array", Name: scope, Status: anf.StatusEmpty} for _, elem := range v { - e.Children = append(e.Children, entityFromValue("item", elem)) + child, err := entityFromValue("item", elem, 1) + if err != nil { + return nil, err + } + e.Children = append(e.Children, child) } doc.AddEntity(e) default: @@ -58,39 +78,43 @@ func Translate(input any, source, scope string, now time.Time) (*anf.Document, e return doc, nil } -// entityFromValue recursively maps a keyed JSON value into an ANF entity. -func entityFromValue(key string, value any) anf.Entity { +// entityFromValue recursively maps a keyed JSON value into an ANF entity. depth +// tracks recursion; it returns an error if the input nests beyond maxDepth. +func entityFromValue(key string, value any, depth int) (anf.Entity, error) { + if depth > maxDepth { + return anf.Entity{}, fmt.Errorf("generic: input nesting exceeds %d levels", maxDepth) + } switch v := value.(type) { case map[string]any: e := anf.Entity{Type: key, Name: objectName(v), Status: anf.StatusEmpty} for _, k := range sortedKeys(v) { if isContainer(v[k]) { - e.Children = append(e.Children, entityFromValue(k, v[k])) + child, err := entityFromValue(k, v[k], depth+1) + if err != nil { + return anf.Entity{}, err + } + e.Children = append(e.Children, child) } else { e.Props = append(e.Props, anf.Property{Key: k, Value: scalarString(v[k])}) } } - return e + return e, nil case []any: e := anf.Entity{Type: key, Status: anf.StatusEmpty} for _, elem := range v { - if isContainer(elem) { - e.Children = append(e.Children, entityFromValue(key, elem)) - } else { - e.Children = append(e.Children, anf.Entity{ - Type: key, - Status: anf.StatusEmpty, - Props: []anf.Property{{Key: "value", Value: scalarString(elem)}}, - }) + child, err := entityFromValue(key, elem, depth+1) + if err != nil { + return anf.Entity{}, err } + e.Children = append(e.Children, child) } - return e + return e, nil default: return anf.Entity{ Type: key, Status: anf.StatusEmpty, Props: []anf.Property{{Key: "value", Value: scalarString(value)}}, - } + }, nil } } @@ -130,8 +154,8 @@ func isContainer(v any) bool { } // objectName returns a human name for an object: the "name" string if present, -// else the "id" string if present, else "". The chosen key remains a property -// and is not consumed here (losslessness is preserved by the caller). +// else the "id" string if present, else "". The chosen key is still emitted as +// a property, so nothing is dropped. func objectName(m map[string]any) string { if s, ok := m["name"].(string); ok { return s @@ -142,7 +166,8 @@ func objectName(m map[string]any) string { return "" } -// scalarString renders a JSON scalar as its ANF string form. +// scalarString renders a JSON scalar as its ANF string form. Numbers decoded +// with json.Number are rendered verbatim so large integers are not corrupted. func scalarString(v any) string { switch s := v.(type) { case string: @@ -152,6 +177,8 @@ func scalarString(v any) string { return "true" } return "false" + case json.Number: + return s.String() case float64: if !math.IsInf(s, 0) && !math.IsNaN(s) && s == math.Trunc(s) { return strconv.FormatFloat(s, 'f', -1, 64) @@ -160,6 +187,6 @@ func scalarString(v any) string { case nil: return "null" default: - return "null" + return fmt.Sprintf("%v", v) } } diff --git a/translators/generic/translate_test.go b/translators/generic/translate_test.go index f94a208..d0c1213 100644 --- a/translators/generic/translate_test.go +++ b/translators/generic/translate_test.go @@ -4,6 +4,7 @@ package generic import ( "encoding/json" "reflect" + "strings" "testing" "time" @@ -272,3 +273,52 @@ func TestTranslateDeterministic(t *testing.T) { anf.EncodeToString(doc1), anf.EncodeToString(doc2)) } } + +// TestTranslateBigIntPreserved verifies that a large integer decoded as +// json.Number keeps full precision, not the float64 rounding it would suffer. +func TestTranslateBigIntPreserved(t *testing.T) { + t.Parallel() + + doc, err := Translate(map[string]any{"id": json.Number("12345678901234567890")}, "test", "root", fixedTime) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if out := anf.EncodeToString(doc); !strings.Contains(out, "12345678901234567890") { + t.Errorf("big integer lost precision:\n%s", out) + } +} + +// TestTranslateDepthLimit verifies that pathologically nested input is rejected +// instead of overflowing the stack. +func TestTranslateDepthLimit(t *testing.T) { + t.Parallel() + + var v any = "leaf" + for i := 0; i < maxDepth+5; i++ { + v = map[string]any{"child": v} + } + if _, err := Translate(v, "test", "root", fixedTime); err == nil { + t.Error("expected error for input nested beyond maxDepth") + } +} + +// TestTranslateEmptyContainers checks empty object and array mappings. +func TestTranslateEmptyContainers(t *testing.T) { + t.Parallel() + + doc, err := Translate(map[string]any{}, "test", "root", fixedTime) + if err != nil { + t.Fatalf("empty object: %v", err) + } + if len(doc.Entities) != 0 { + t.Errorf("empty object should yield no entities, got %d", len(doc.Entities)) + } + + doc2, err := Translate([]any{}, "test", "root", fixedTime) + if err != nil { + t.Fatalf("empty array: %v", err) + } + if len(doc2.Entities) != 1 || doc2.Entities[0].Type != "array" || len(doc2.Entities[0].Children) != 0 { + t.Errorf("empty array mapping unexpected: %#v", doc2.Entities) + } +}