Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Builtin functions

Complete reference for all built-in functions available in Harn.

Output

FunctionParametersReturnsDescription
log(msg)msg: anynilPrint with [harn] prefix and newline
print(msg)msg: anynilPrint without prefix or newline
println(msg)msg: anynilPrint with newline, no prefix

Type conversion

FunctionParametersReturnsDescription
type_of(value)value: anystringReturns type name: "int", "float", "string", "bool", "nil", "list", "dict", "closure", "taskHandle", "duration", "enum", "struct"
to_string(value)value: anystringConvert to string representation
to_int(value)value: anyint or nilParse/convert to integer. Floats truncate, bools become 0/1
to_float(value)value: anyfloat or nilParse/convert to float

Runtime shape validation

Function parameters with structural type annotations (shapes) are validated at runtime. If a dict or struct argument is missing a required field or has the wrong field type, a descriptive error is thrown before the function body executes.

fn greet(u: {name: string, age: int}) {
  println("${u.name} is ${u.age}")
}

greet({name: "Alice", age: 30})   // OK
greet({name: "Alice"})            // Error: parameter 'u': missing field 'age' (int)

See Error handling – Runtime shape validation errors for more details.

Result

Harn has a built-in Result type for representing success/failure values without exceptions. Ok and Err create Result.Ok and Result.Err enum variants respectively. When called on a non-Result value, unwrap and unwrap_or pass the value through unchanged.

FunctionParametersReturnsDescription
Ok(value)value: anyResult.OkCreate a Result.Ok value
Err(value)value: anyResult.ErrCreate a Result.Err value
is_ok(result)result: anyboolReturns true if value is Result.Ok
is_err(result)result: anyboolReturns true if value is Result.Err
unwrap(result)result: anyanyExtract Ok value. Throws on Err. Non-Result values pass through
unwrap_or(result, default)result: any, default: anyanyExtract Ok value. Returns default on Err. Non-Result values pass through
unwrap_err(result)result: anyanyExtract Err value. Throws on non-Err

Example:

let good = Ok(42)
let bad = Err("something went wrong")

println(is_ok(good))             // true
println(is_err(bad))             // true

println(unwrap(good))            // 42
println(unwrap_or(bad, 0))       // 0
println(unwrap_err(bad))         // something went wrong

JSON

FunctionParametersReturnsDescription
json_parse(str)str: stringvalueParse JSON string into Harn values. Throws on invalid JSON
json_stringify(value)value: anystringSerialize Harn value to JSON. Closures and handles become null
json_validate(data, schema)data: any, schema: dictboolValidate data against a schema. Returns true if valid, throws with details if not
json_extract(text, key?)text: string, key: string (optional)valueExtract JSON from text (strips markdown code fences). If key given, returns that key’s value

Type mapping:

JSONHarn
stringstring
integerint
decimal/exponentfloat
true/falsebool
nullnil
arraylist
objectdict

json_validate schema format

The schema is a plain Harn dict (not JSON Schema). Supported keys:

KeyTypeDescription
typestringExpected type: "string", "int", "float", "bool", "list", "dict", "any"
requiredlistList of required key names (for dicts)
propertiesdictDict mapping property names to sub-schemas (for dicts)
itemsdictSchema to validate each item against (for lists)

Example:

let schema = {
  type: "dict",
  required: ["name", "age"],
  properties: {
    name: {type: "string"},
    age: {type: "int"},
    tags: {type: "list", items: {type: "string"}}
  }
}
json_validate(data, schema)  // throws if invalid

json_extract

Extracts JSON from LLM responses that may contain markdown code fences or surrounding prose. Handles ```json ... ```, ``` ... ```, and bare JSON with surrounding text.

let response = llm_call("Return JSON with name and age")
let data = json_extract(response)         // parse, stripping fences
let name = json_extract(response, "name") // extract just one key

Math

FunctionParametersReturnsDescription
abs(n)n: int or floatint or floatAbsolute value
ceil(n)n: floatintCeiling (rounds up). Ints pass through unchanged
floor(n)n: floatintFloor (rounds down). Ints pass through unchanged
round(n)n: floatintRound to nearest integer. Ints pass through unchanged
sqrt(n)n: int or floatfloatSquare root
pow(base, exp)base: number, exp: numberint or floatExponentiation. Returns int when both args are int and exp is non-negative
min(a, b)a: number, b: numberint or floatMinimum of two values. Returns float if either argument is float
max(a, b)a: number, b: numberint or floatMaximum of two values. Returns float if either argument is float
random()nonefloatRandom float in [0, 1)
random_int(min, max)min: int, max: intintRandom integer in [min, max] inclusive

Trigonometry

FunctionParametersReturnsDescription
sin(n)n: floatfloatSine (radians)
cos(n)n: floatfloatCosine (radians)
tan(n)n: floatfloatTangent (radians)
asin(n)n: floatfloatInverse sine
acos(n)n: floatfloatInverse cosine
atan(n)n: floatfloatInverse tangent
atan2(y, x)y: float, x: floatfloatTwo-argument inverse tangent

Logarithms and exponentials

FunctionParametersReturnsDescription
log2(n)n: floatfloatBase-2 logarithm
log10(n)n: floatfloatBase-10 logarithm
ln(n)n: floatfloatNatural logarithm
exp(n)n: floatfloatEuler’s number raised to the power n

Constants and utilities

FunctionParametersReturnsDescription
pi()nonefloatThe constant pi (3.14159…)
e()nonefloatEuler’s number (2.71828…)
sign(n)n: int or floatintSign of a number: -1, 0, or 1
is_nan(n)n: floatboolCheck if value is NaN
is_infinite(n)n: floatboolCheck if value is infinite

Sets

FunctionParametersReturnsDescription
set(items?)items: list (optional)setCreate a new set, optionally from a list
set_add(s, value)s: set, value: anysetAdd a value to a set, returns new set
set_remove(s, value)s: set, value: anysetRemove a value from a set, returns new set
set_contains(s, value)s: set, value: anyboolCheck if set contains a value
set_union(a, b)a: set, b: setsetUnion of two sets
set_intersect(a, b)a: set, b: setsetIntersection of two sets
set_intersection(a, b)a: set, b: setsetAlias for set_intersect
set_difference(a, b)a: set, b: setsetDifference (elements in a but not b)
set_symmetric_difference(a, b)a: set, b: setsetElements in either but not both
set_is_subset(a, b)a: set, b: setboolTrue if all elements of a are in b
set_is_superset(a, b)a: set, b: setboolTrue if a contains all elements of b
set_is_disjoint(a, b)a: set, b: setboolTrue if a and b share no elements
to_list(s)s: setlistConvert a set to a list

Set methods (dot syntax)

Sets also support method syntax: my_set.union(other).

MethodParametersReturnsDescription
.count() / .len()noneintNumber of elements
.empty()noneboolTrue if set is empty
.contains(val)val: anyboolCheck membership
.add(val)val: anysetNew set with val added
.remove(val)val: anysetNew set with val removed
.union(other)other: setsetUnion
.intersect(other)other: setsetIntersection
.difference(other)other: setsetElements in self but not other
.symmetric_difference(other)other: setsetElements in either but not both
.is_subset(other)other: setboolTrue if self is a subset of other
.is_superset(other)other: setboolTrue if self is a superset of other
.is_disjoint(other)other: setboolTrue if no shared elements
.to_list()nonelistConvert to list
.map(fn)fn: closuresetTransform elements (deduplicates)
.filter(fn)fn: closuresetKeep elements matching predicate
.any(fn)fn: closureboolTrue if any element matches
.all(fn) / .every(fn)fn: closureboolTrue if all elements match

String functions

FunctionParametersReturnsDescription
len(value)value: string, list, or dictintLength of string (chars), list (items), or dict (keys)
trim(str)str: stringstringRemove leading and trailing whitespace
lowercase(str)str: stringstringConvert to lowercase
uppercase(str)str: stringstringConvert to uppercase
split(str, sep)str: string, sep: stringlistSplit string by separator
starts_with(str, prefix)str: string, prefix: stringboolCheck if string starts with prefix
ends_with(str, suffix)str: string, suffix: stringboolCheck if string ends with suffix
contains(str, substr)str: string, substr: stringboolCheck if string contains substring. Also works on lists
replace(str, old, new)str: string, old: string, new: stringstringReplace all occurrences
join(list, sep)list: list, sep: stringstringJoin list elements with separator
substring(str, start, len?)str: string, start: int, len: intstringExtract substring from start position
format(template, ...)template: string, args: anystringFormat string with {} placeholders

String methods (dot syntax)

These are called on string values with dot notation: "hello".uppercase().

MethodParametersReturnsDescription
.trim()nonestringRemove leading/trailing whitespace
.trim_start()nonestringRemove leading whitespace only
.trim_end()nonestringRemove trailing whitespace only
.lines()nonelistSplit string by newlines
.char_at(index)index: intstring or nilCharacter at index (nil if out of bounds)
.index_of(substr)substr: stringintFirst character offset of substring (-1 if not found)
.last_index_of(substr)substr: stringintLast character offset of substring (-1 if not found)
.len()noneintCharacter count
.chars()nonelistList of single-character strings
.reverse()nonestringReversed string
.repeat(n)n: intstringRepeat n times
.pad_left(width, char?)width: int, char: stringstringPad to width with char (default space)
.pad_right(width, char?)width: int, char: stringstringPad to width with char (default space)

List methods (dot syntax)

MethodParametersReturnsDescription
.map(fn)fn: closurelistTransform each element
.filter(fn)fn: closurelistKeep elements where fn returns truthy
.reduce(init, fn)init: any, fn: closureanyFold with accumulator
.find(fn)fn: closureany or nilFirst element matching predicate
.find_index(fn)fn: closureintIndex of first match (-1 if not found)
.any(fn)fn: closureboolTrue if any element matches
.all(fn) / .every(fn)fn: closureboolTrue if all elements match
.none(fn?)fn: closureboolTrue if no elements match (no arg: checks emptiness)
.first(n?)n: int (optional)any or listFirst element, or first n elements
.last(n?)n: int (optional)any or listLast element, or last n elements
.partition(fn)fn: closurelistSplit into [[truthy], [falsy]]
.group_by(fn)fn: closuredictGroup into dict keyed by fn result
.sort() / .sort_by(fn)fn: closure (optional)listSort (natural or by key function)
.min() / .max()noneanyMinimum/maximum value
.min_by(fn) / .max_by(fn)fn: closureanyMin/max by key function
.chunk(size)size: intlistSplit into chunks of size
.each_cons(size)size: intlistSliding windows of size
.compact()nonelistRemove nil values
.unique()nonelistRemove duplicates
.flatten()nonelistFlatten one level of nesting
.flat_map(fn)fn: closurelistMap then flatten
.tally()nonedictFrequency count: {value: count}
.zip(other)other: listlistPair elements from two lists
.enumerate()nonelistList of {index, value} dicts
.take(n) / .skip(n)n: intlistFirst/remaining n elements
.sum()noneint or floatSum of numeric values
.join(sep?)sep: stringstringJoin to string
.reverse()nonelistReversed list
.push(item) / .pop()item: anylistNew list with item added/removed (immutable)
.contains(item)item: anyboolCheck if list contains item
.index_of(item)item: anyintIndex of item (-1 if not found)
.slice(start, end?)start: int, end: intlistSlice with negative index support

Path functions

FunctionParametersReturnsDescription
dirname(path)path: stringstringDirectory component of path
basename(path)path: stringstringFile name component of path
extname(path)path: stringstringFile extension including dot (e.g., .harn)
path_join(parts...)parts: stringsstringJoin path components

File I/O

FunctionParametersReturnsDescription
read_file(path)path: stringstringRead entire file as UTF-8 string. Throws on failure
write_file(path, content)path: string, content: stringnilWrite string to file. Throws on failure
append_file(path, content)path: string, content: stringnilAppend string to file, creating it if it doesn’t exist. Throws on failure
copy_file(src, dst)src: string, dst: stringnilCopy a file. Throws on failure
delete_file(path)path: stringnilDelete a file or directory (recursive). Throws on failure
file_exists(path)path: stringboolCheck if a file or directory exists
list_dir(path?)path: string (default ".")listList directory contents as sorted list of file names. Throws on failure
mkdir(path)path: stringnilCreate directory and all parent directories. Throws on failure
stat(path)path: stringdictFile metadata: {size, is_file, is_dir, readonly, modified}. Throws on failure
temp_dir()nonestringSystem temporary directory path
render(path, bindings?)path: string, bindings: dictstringRead a template file and replace {{key}} placeholders with values from bindings dict. Without bindings, just reads the file

Environment and system

FunctionParametersReturnsDescription
env(name)name: stringstring or nilRead environment variable
timestamp()nonefloatUnix timestamp in seconds with sub-second precision
elapsed()noneintMilliseconds since VM startup
exec(cmd, args...)cmd: string, args: stringsdictExecute external command. Returns {stdout, stderr, status, success}
shell(cmd)cmd: stringdictExecute command via shell. Returns {stdout, stderr, status, success}
exit(code)code: int (default 0)neverTerminate the process
username()nonestringCurrent OS username
hostname()nonestringMachine hostname
platform()nonestringOS name: "darwin", "linux", or "windows"
arch()nonestringCPU architecture (e.g., "aarch64", "x86_64")
home_dir()nonestringUser’s home directory path
pid()noneintCurrent process ID
cwd()nonestringCurrent working directory
source_dir()nonestringDirectory of the currently-executing .harn file (falls back to cwd)
project_root()nonestring or nilNearest ancestor directory containing harn.toml
date_iso()nonestringCurrent UTC time in ISO 8601 format (e.g., "2026-03-29T14:30:00.123Z")

Regular expressions

FunctionParametersReturnsDescription
regex_match(pattern, text)pattern: string, text: stringlist or nilFind all non-overlapping matches. Returns nil if no matches
regex_replace(pattern, replacement, text)pattern: string, replacement: string, text: stringstringReplace all matches. Throws on invalid regex
regex_captures(pattern, text)pattern: string, text: stringlistFind all matches with capture group details

regex_captures

Returns a list of dicts, one per match. Each dict contains:

  • match – the full matched string
  • groups – a list of positional capture group values (from (...))
  • Named capture groups (from (?P<name>...)) appear as additional keys
let results = regex_captures("(\\w+)@(\\w+)", "alice@example bob@test")
// [
//   {match: "alice@example", groups: ["alice", "example"]},
//   {match: "bob@test", groups: ["bob", "test"]}
// ]

Named capture groups are added as top-level keys on each result dict:

let named = regex_captures("(?P<user>\\w+):(?P<role>\\w+)", "alice:admin")
// [{match: "alice:admin", groups: ["alice", "admin"], user: "alice", role: "admin"}]

Returns an empty list if there are no matches. Throws on invalid regex.

Encoding

FunctionParametersReturnsDescription
base64_encode(string)string: stringstringBase64 encode a string (standard alphabet with padding)
base64_decode(string)string: stringstringBase64 decode a string. Throws on invalid input

Example:

let encoded = base64_encode("Hello, World!")
println(encoded)                  // SGVsbG8sIFdvcmxkIQ==
println(base64_decode(encoded))   // Hello, World!

Hashing

FunctionParametersReturnsDescription
sha256(string)string: stringstringSHA-256 hash, returned as a lowercase hex-encoded string
md5(string)string: stringstringMD5 hash, returned as a lowercase hex-encoded string

Example:

println(sha256("hello"))  // 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
println(md5("hello"))     // 5d41402abc4b2a76b9719d911017c592

Date/Time

FunctionParametersReturnsDescription
date_now()nonedictCurrent UTC datetime as dict with year, month, day, hour, minute, second, weekday, and timestamp fields
date_parse(str)str: stringfloatParse a datetime string (e.g., "2024-01-15 10:30:00") into a Unix timestamp. Extracts numeric components from the string. Throws if fewer than 3 parts (year, month, day). Validates month (1-12), day (1-31), hour (0-23), minute (0-59), second (0-59)
date_format(dt, format?)dt: float, int, or dict; format: string (default "%Y-%m-%d %H:%M:%S")stringFormat a timestamp or date dict as a string. Supports %Y, %m, %d, %H, %M, %S placeholders. Throws for negative timestamps

Testing

FunctionParametersReturnsDescription
assert(condition, msg?)condition: any, msg: string (optional)nilAssert value is truthy. Throws with message on failure
assert_eq(a, b, msg?)a: any, b: any, msg: string (optional)nilAssert two values are equal. Throws with message on failure
assert_ne(a, b, msg?)a: any, b: any, msg: string (optional)nilAssert two values are not equal. Throws with message on failure

HTTP

FunctionParametersReturnsDescription
http_get(url, options?)url: string, options: dictdictGET request
http_post(url, body, options?)url: string, body: string, options: dictdictPOST request
http_put(url, body, options?)url: string, body: string, options: dictdictPUT request
http_patch(url, body, options?)url: string, body: string, options: dictdictPATCH request
http_delete(url, options?)url: string, options: dictdictDELETE request
http_request(method, url, options?)method: string, url: string, options: dictdictGeneric HTTP request

All HTTP functions return {status: int, headers: dict, body: string, ok: bool}. Options: timeout (ms), retries, backoff (ms), headers (dict), auth (string or {bearer: "token"} or {basic: {user, password}}), follow_redirects (bool), max_redirects (int), body (string). Throws on network errors.

Mock HTTP

For testing pipelines that make HTTP calls without hitting real servers.

FunctionParametersReturnsDescription
http_mock(method, url_pattern, response)method: string, url_pattern: string, response: dictnilRegister a mock. Use * in url_pattern for glob matching (supports multiple * wildcards, e.g., https://api.example.com/*/items/*)
http_mock_clear()nonenilClear all mocks and recorded calls
http_mock_calls()nonelistReturn list of {method, url, body} for all intercepted calls
http_mock("GET", "https://api.example.com/users", {
  status: 200,
  body: "{\"users\": [\"alice\"]}",
  headers: {}
})
let resp = http_get("https://api.example.com/users")
assert_eq(resp.status, 200)

Interactive input

FunctionParametersReturnsDescription
prompt_user(msg)msg: string (optional)stringDisplay message, read line from stdin

Async and timing

FunctionParametersReturnsDescription
sleep(duration)duration: int (ms) or duration literalnilPause execution

Concurrency primitives

Channels

FunctionParametersReturnsDescription
channel(name?)name: string (default "default")dictCreate a channel with name, type, and messages fields
send(ch, value)ch: dict, value: anynilSend a value to a channel
receive(ch)ch: dictanyReceive a value from a channel (blocks until data available)
close_channel(ch)ch: channelnilClose a channel, preventing further sends
try_receive(ch)ch: channelany or nilNon-blocking receive. Returns nil if no data available
select(ch1, ch2, ...)channels: channeldict or nilWait for data on any channel. Returns {index, value, channel} for the first ready channel, or nil if all closed

Atomics

FunctionParametersReturnsDescription
atomic(initial?)initial: any (default 0)dictCreate an atomic value
atomic_get(a)a: dictanyRead the current value
atomic_set(a, value)a: dict, value: anydictReturns new atomic with updated value
atomic_add(a, delta)a: dict, delta: intdictReturns new atomic with incremented value

Persistent store

FunctionParametersReturnsDescription
store_get(key)key: stringanyRetrieve value from store, nil if missing
store_set(key, value)key: string, value: anynilStore value, auto-saves to .harn/store.json
store_delete(key)key: stringnilRemove key from store
store_list()nonelistList all keys (sorted)
store_save()nonenilExplicitly flush store to disk
store_clear()nonenilRemove all keys from store

The store is backed by .harn/store.json relative to the script’s directory. The file is created lazily on first store_set. In bridge mode, the host can override these builtins.

LLM

See LLM calls and agent loops for full documentation.

FunctionParametersReturnsDescription
llm_call(prompt, system?, options?)prompt: string, system: string, options: dictstringSingle LLM request
agent_loop(prompt, system?, options?)prompt: string, system: string, options: dictdictMulti-turn agent loop with ##DONE## sentinel. Returns {status, text, iterations, duration_ms, tools_used}
llm_info()dictCurrent LLM config: {provider, model, api_key_set}
llm_usage()dictCumulative usage: {input_tokens, output_tokens, total_duration_ms, call_count}
llm_resolve_model(alias)alias: stringdictResolve model alias to {id, provider} via providers.toml
llm_infer_provider(model_id)model_id: stringstringInfer provider from model ID (e.g. "claude-*""anthropic")
llm_model_tier(model_id)model_id: stringstringGet capability tier: "small", "mid", or "frontier"
llm_healthcheck(provider?)provider: stringdictValidate API key. Returns {valid, message, metadata}
llm_providers()listList all configured provider names
llm_config(provider?)provider: stringdictGet provider config (base_url, auth_style, etc.)
llm_cost(model, input_tokens, output_tokens)model: string, input_tokens: int, output_tokens: intfloatEstimate USD cost from embedded pricing table
llm_session_cost()dictSession totals: {total_cost, input_tokens, output_tokens, call_count}
llm_budget(max_cost)max_cost: floatnilSet session budget in USD. LLM calls throw if exceeded
llm_budget_remaining()float or nilRemaining budget (nil if no budget set)

Provider configuration

LLM provider endpoints, model aliases, inference rules, and default parameters are configured via a TOML file. The VM searches for config in this order:

  1. HARN_PROVIDERS_CONFIG env var (explicit path)
  2. ~/.config/harn/providers.toml
  3. Built-in defaults (Anthropic, OpenAI, OpenRouter, HuggingFace, Ollama)

See harn init to generate a default config file, or create one manually:

[providers.anthropic]
base_url = "https://api.anthropic.com/v1"
auth_style = "header"
auth_header = "x-api-key"
auth_env = "ANTHROPIC_API_KEY"
chat_endpoint = "/messages"

[aliases]
sonnet = { id = "claude-sonnet-4-20250514", provider = "anthropic" }

[[inference_rules]]
pattern = "claude-*"
provider = "anthropic"

[[tier_rules]]
pattern = "claude-*"
tier = "frontier"

[model_defaults."qwen/*"]
temperature = 0.3

Timers

FunctionParametersReturnsDescription
timer_start(name?)name: stringdictStart a named timer
timer_end(timer)timer: dictintStop timer, prints elapsed, returns milliseconds
elapsed()intMilliseconds since process start

Structured logging

FunctionParametersReturnsDescription
log_json(key, value)key: string, value: anynilEmit a JSON log line with timestamp

Metadata

Project metadata store backed by .burin/metadata/ sharded JSON files. Supports hierarchical namespace resolution (child directories inherit from parents).

FunctionParametersReturnsDescription
metadata_get(dir, namespace?)dir: string, namespace: stringdict | nilRead metadata with inheritance
metadata_set(dir, namespace, data)dir: string, namespace: string, data: dictnilWrite metadata for directory/namespace
metadata_save()nilFlush metadata to disk
metadata_stale(project)project: stringdictCheck staleness: {any_stale, tier1, tier2}
metadata_refresh_hashes()nilRecompute content hashes
compute_content_hash(dir)dir: stringstringHash of directory contents
invalidate_facts(dir)dir: stringnilMark cached facts as stale

MCP (Model Context Protocol)

Connect to external tool servers using the Model Context Protocol. Supports stdio transport (spawns a child process).

FunctionParametersReturnsDescription
mcp_connect(command, args?)command: string, args: listmcp_clientSpawn an MCP server and perform the initialize handshake
mcp_list_tools(client)client: mcp_clientlistList available tools from the server
mcp_call(client, name, arguments?)client: mcp_client, name: string, arguments: dictstring or listCall a tool and return the result
mcp_list_resources(client)client: mcp_clientlistList available resources from the server
mcp_list_resource_templates(client)client: mcp_clientlistList resource templates (URI templates) from the server
mcp_read_resource(client, uri)client: mcp_client, uri: stringstring or listRead a resource by URI
mcp_list_prompts(client)client: mcp_clientlistList available prompts from the server
mcp_get_prompt(client, name, arguments?)client: mcp_client, name: string, arguments: dictdictGet a prompt with optional arguments
mcp_server_info(client)client: mcp_clientdictGet connection info (name, connected)
mcp_disconnect(client)client: mcp_clientnilKill the server process and release resources

Example:

let client = mcp_connect("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])
let tools = mcp_list_tools(client)
println(tools)

let result = mcp_call(client, "read_file", {"path": "/tmp/hello.txt"})
println(result)

mcp_disconnect(client)

Notes:

  • mcp_call returns a string when the tool produces a single text block, a list of content dicts for multi-block results, or nil when empty.
  • If the tool reports isError: true, mcp_call throws the error text.
  • mcp_connect throws if the command cannot be spawned or the initialize handshake fails.

Auto-connecting MCP servers via harn.toml

Instead of calling mcp_connect manually, you can declare MCP servers in harn.toml. They will be connected automatically before the pipeline executes and made available through the global mcp dict.

Add a [[mcp]] entry for each server:

[[mcp]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]

[[mcp]]
name = "github"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]

Each entry requires:

FieldTypeDescription
namestringIdentifier used to access the client (e.g., mcp.filesystem)
commandstringExecutable to spawn
argslist of stringsCommand-line arguments (default: empty)

The connected clients are available as properties on the mcp global dict:

pipeline default() {
  let tools = mcp_list_tools(mcp.filesystem)
  println(tools)

  let result = mcp_call(mcp.github, "list_issues", {repo: "harn"})
  println(result)
}

If a server fails to connect, a warning is printed to stderr and that server is omitted from the mcp dict. Other servers still connect normally. The mcp global is only defined when at least one server connects successfully.

MCP Server Mode

Harn pipelines can expose tools, resources, resource templates, and prompts as an MCP server using harn mcp-serve. The CLI serves them over stdio using the MCP protocol, making them callable by Claude Desktop, Cursor, or any MCP client.

FunctionParametersReturnsDescription
tool_registry()dictCreate an empty tool registry
tool_define(registry, name, desc, config)registry, name, desc: string, config: dictdictAdd a tool (config: {params, handler, annotations?})
mcp_tools(registry)registry: dictnilRegister tools for MCP serving
mcp_resource(config)config: dictnilRegister a static resource ({uri, name, text, description?, mime_type?})
mcp_resource_template(config)config: dictnilRegister a resource template ({uri_template, name, handler, description?, mime_type?})
mcp_prompt(config)config: dictnilRegister a prompt ({name, handler, description?, arguments?})

Tool annotations (MCP spec annotations field) can be passed in the tool_define config to describe tool behavior:

tools = tool_define(tools, "search", "Search files", {
  params: { query: "string" },
  handler: { args -> "results for " + args.query },
  annotations: {
    title: "File Search",
    readOnlyHint: true,
    destructiveHint: false
  }
})

Example (agent.harn):

pipeline main(task) {
  var tools = tool_registry()
  tools = tool_define(tools, "greet", "Greet someone", {
    params: { name: "string" },
    handler: { args -> "Hello, " + args.name + "!" }
  })
  mcp_tools(tools)

  mcp_resource({
    uri: "docs://readme",
    name: "README",
    text: "# My Agent\nA demo MCP server."
  })

  mcp_resource_template({
    uri_template: "config://{key}",
    name: "Config Values",
    handler: { args -> "value for " + args.key }
  })

  mcp_prompt({
    name: "review",
    description: "Code review prompt",
    arguments: [{ name: "code", required: true }],
    handler: { args -> "Please review:\n" + args.code }
  })
}

Run as an MCP server:

harn mcp-serve agent.harn

Configure in Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "my-agent": {
      "command": "harn",
      "args": ["mcp-serve", "agent.harn"]
    }
  }
}

Notes:

  • mcp_tools(registry) (or the alias mcp_serve) must be called to register tools.
  • Resources, resource templates, and prompts are registered individually.
  • All print/println output goes to stderr (stdout is the MCP transport).
  • The server supports the 2024-11-05 MCP protocol version over stdio.
  • Tool handlers receive arguments as a dict and should return a string result.
  • Prompt handlers receive arguments as a dict and return a string (single user message) or a list of {role, content} dicts.
  • Resource template handlers receive URI template variables as a dict and return the resource text.