Skip to content

NativeTool.of

Java · package io.github.muthuishere:toolnexus · SPEC §6 · NativeTool.java

public static NativeTool of(String name, String description, Map<String, Object> inputSchema,
Function<Map<String, Object>, Object> fn)
public static NativeTool of(String name, String description, Map<String, Object> inputSchema,
BiFunction<Map<String, Object>, ToolContext, Object> fn)

Wraps a lambda as a Tool with source() = "native". Two overloads, picked by arity: take just the args, or take the args and the ToolContext.

Whenever the thing the model should be able to do is already a method you have — a repository lookup, a price calculation, a call into your service layer. This is the shortest path from that method to something an LLM can call, and it needs no MCP server, no HTTP hop, no annotations.

Against implementing Tool by hand: Tool has five members and is not a functional interface, so a bare lambda cannot satisfy it. NativeTool.of is that boilerplate, already written — plus the two things people forget: a thrown exception becomes an error result rather than escaping the loop, and a non-String return is JSON-encoded for you.

import io.github.muthuishere.toolnexus.*;
import java.util.List;
import java.util.Map;
public class Example {
public static void main(String[] args) {
Tool greet = NativeTool.of(
"greet",
"Greet someone by name",
Map.of("type", "object",
"properties", Map.of("name", Map.of("type", "string")),
"required", List.of("name")),
(Map<String, Object> a) -> "Hello, " + a.get("name") + "!"
);
if (!greet.source().equals("native")) throw new AssertionError(greet.source());
ToolResult res = greet.execute(Map.of("name", "Muthu"), new ToolContext());
if (res.isError() || !res.output().equals("Hello, Muthu!")) throw new AssertionError(res.output());
System.out.println("ok: " + res.output());
}
}

Annotate the lambda parameter ((Map<String, Object> a) -> …) so Java picks the single-argument overload — a bare a -> … is ambiguous between the two of signatures.

Return a String and it becomes the output verbatim. Return anything else and it is JSON-encoded. Throw, and you get an error result — the loop survives and the model sees the message.

import io.github.muthuishere.toolnexus.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Example {
public static void main(String[] args) {
Tool lookup = NativeTool.of(
"lookup_order",
"Fetch an order by id",
Map.of("type", "object",
"properties", Map.of("id", Map.of("type", "string")),
"required", List.of("id")),
(Map<String, Object> a) -> {
String id = String.valueOf(a.get("id"));
if (!id.equals("A1")) throw new IllegalArgumentException("no such order: " + id);
Map<String, Object> order = new LinkedHashMap<>();
order.put("id", id);
order.put("total", 42);
return order; // not a String — JSON-encoded on the way out
}
);
ToolResult ok = lookup.execute(Map.of("id", "A1"), new ToolContext());
if (ok.isError()) throw new AssertionError(ok.output());
if (!ok.output().contains("\"total\"")) throw new AssertionError(ok.output());
// A thrown exception NEVER escapes — it lands as an error result.
ToolResult bad = lookup.execute(Map.of("id", "Z9"), new ToolContext());
if (!bad.isError()) throw new AssertionError("expected isError");
if (!bad.output().equals("no such order: Z9")) throw new AssertionError(bad.output());
System.out.println("ok: " + ok.output() + " | error path: " + bad.output());
}
}

3. The context overload, metadata, and a null schema

Section titled “3. The context overload, metadata, and a null schema”

The two-argument overload receives the ToolContext, which is where cancellation and the per-call timeout live. Return a ToolResult yourself when you want to control isError or attach metadata.

import io.github.muthuishere.toolnexus.*;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
public class Example {
public static void main(String[] args) {
BiFunction<Map<String, Object>, ToolContext, Object> body = (a, ctx) -> {
// ctx may be null on a direct call — always guard it.
if (ctx != null && ctx.isCancelled()) return ToolResult.error("cancelled");
long n = ((Number) a.get("n")).longValue();
if (n < 0) return ToolResult.error("n must be >= 0");
return ToolResult.ok(String.valueOf(n * 2), Map.of("title", "double", "input", n));
};
Tool doubler = NativeTool.of(
"double",
"Double a number",
Map.of("type", "object",
"properties", Map.of("n", Map.of("type", "integer")),
"required", List.of("n")),
body
);
ToolResult res = doubler.execute(Map.of("n", 21), new ToolContext());
if (!res.output().equals("42")) throw new AssertionError(res.output());
if (!res.metadata().get("title").equals("double")) throw new AssertionError("metadata");
// Cancellation is cooperative — the tool decides what to do about it.
ToolContext cancelled = new ToolContext();
cancelled.cancel();
if (!doubler.execute(Map.of("n", 1), cancelled).isError()) throw new AssertionError("expected cancel");
// A null schema falls back to the empty-object schema; null args become {}.
Tool now = NativeTool.of("now", "Server time", null, (Map<String, Object> a) -> "2026-01-01");
if (!now.inputSchema().get("type").equals("object")) throw new AssertionError("schema type");
if (!now.inputSchema().get("additionalProperties").equals(false)) throw new AssertionError("schema strictness");
if (!now.execute(null, null).output().equals("2026-01-01")) throw new AssertionError("null args");
// Ready for any adapter.
List<Map<String, Object>> schema = Adapters.toOpenAI(List.of(doubler, now));
if (schema.size() != 2) throw new AssertionError("adapter");
System.out.println("ok: " + res.output() + ", " + now.execute(null, null).output());
}
}
Parameter Type Meaning
name String The name the model calls. Must match [a-zA-Z0-9_-]Tool.sanitize if it is derived from data.
description String What the model reads to decide whether to call it.
inputSchema Map<String, Object> JSON-Schema object. Null{"type":"object","properties":{},"additionalProperties":false}.
fn Function | BiFunction The body. One arg ⇒ args only; two ⇒ args + ToolContext.
Return Becomes
String ToolResult.ok(value)
ToolResult passed through unchanged — your isError and metadata survive
anything else ToolResult.ok(json(value))
throws ToolResult.error(message) — the cause’s message when the exception is wrapped

source() is always "native", and args is never null inside fn — a null map is replaced with an empty one.