Serving agents
An MCP server for any vocabulary: the query, schema, explain, look, values, find, ask and cypher tools, instructions written from the host's pack, a host's own arguments and tools, and how a host runs it.
An agent with its own tools reaches for a graph tool only when it knows what the tool is for. Declaire’s agent called Declaire’s server on 1 of 42 questions until one sentence told it, and then on the hard ones (declaire#288). github.com/panyam/jaala/frontends/mcp is an MCP server for any vocabulary, whose instructions and tool descriptions are written from the host’s pack (#347), so wording that works for one host’s agent works for every host’s. We keep it in a module of its own on mcpkit, so a host that doesn’t serve agents never takes mcpkit on.
The examples use the vocabulary from Host options, as the previous page does.
package guide_test
import (
"context"
"fmt"
"net/http/httptest"
"github.com/panyam/jaala/datalog"
"github.com/panyam/jaala/frontends/mcp"
"github.com/panyam/jaala/frontends/nl"
"github.com/panyam/mcpkit/client"
"github.com/panyam/mcpkit/core"
"github.com/panyam/mcpkit/server"
)
// depsPack is the server's pack: what the data is about and a worked
// example, which its instructions and tool descriptions are written from,
// and the graph a Cypher query reads (depsGraph, on the Cypher page).
var depsPack = mcp.Pack{
Pack: nl.Pack{
About: "the packages of a Go service and what each imports",
Examples: []nl.Example{
{Question: "what does api import?", Program: `imports("api", ?d) => ?d`},
},
},
Name: "deps",
Cypher: &depsGraph,
}
// The instructions an agent reads when it connects, and the line for its
// own instructions (a CLAUDE.md, say), are written from the pack.
func Example_mcpInstructions() {
fmt.Println(mcp.Instructions(depsPack, false))
fmt.Println(mcp.AgentLine(depsPack))
// Output:
// the packages of a Go service and what each imports
//
// Ask with the query tool, in jaala's Datalog. A program is rules, if any, then one goal: 'rule(?a, ?b) :- body; ... body => ?a, ?b'. Variables are ?name and strings are "quoted"; there's no "?-" before a goal, and its columns follow => without parentheses. Every row carries the facts it was derived from. A value the user gave goes in bind, by its variable's name, rather than into the program's text.
//
// Before writing a program, call schema with no path for every relation and module, and with a module's or relation's path for its members and docs.
//
// For example:
// # what does api import?
// imports("api", ?d) => ?d
//
// Or ask in Cypher with the cypher tool: MATCH, WHERE and RETURN over this graph, and it answers with the program it ran. Node labels: Package {license, name}. Relationship types: IMPORTS.
//
// Before guessing a value, look describes a relation and values shows what one of its arguments holds. To try a program, call query with sample: it runs on a few rows and says which part matched nothing. The explain tool says where a program's work went, and why its answer is empty.
//
// For questions about the packages of a Go service and what each imports, use the deps MCP server before searching files: it answers exactly from its graph. Call its schema tool, then query, or cypher to ask in Cypher.
}
// mcp.New builds an MCP server for the vocabulary; a host serves it over the
// transport it wants (here HTTP, as mcpkit's Handler serves it) and an agent
// calls its tools.
func Example_mcpServer() {
v, err := vocabulary(source())
if err != nil {
panic(err)
}
b := datalog.MustBase(v, source())
srv, err := mcp.New(v, depsPack, mcp.Options{
Base: func(context.Context) (*datalog.Base, error) { return b, nil },
Limit: 2,
})
if err != nil {
panic(err)
}
ts := httptest.NewServer(srv.Handler(server.WithStreamableHTTP(true)))
defer ts.Close()
ctx := context.Background()
c := client.NewClient(ts.URL+"/mcp", core.ClientInfo{Name: "agent", Version: "0.0.1"})
if err := c.Connect(ctx); err != nil {
panic(err)
}
defer c.Close()
call := func(tool string, args map[string]any) {
text, err := c.ToolCall(ctx, tool, args)
if err != nil {
fmt.Printf("%s: error: %v\n", tool, err)
return
}
fmt.Printf("%s:\n%s\n", tool, text)
}
call("schema", map[string]any{"path": "deps"})
call("query", map[string]any{"program": `deps.depends_on("app", ?d) => ?d`})
call("query", map[string]any{"program": `depends_on("app", ?d) => ?d`})
call("cypher", map[string]any{"query": `MATCH (p:Package)-[:IMPORTS]->(:Package {name: $to}) RETURN p.name AS importer`,
"params": map[string]any{"to": "log"}})
// Output:
// schema:
// module deps
// deps.depends_on(a, b) # Everything a package depends on, directly or through others.
//
// query:
// columns: d
// 1. api
// deps.depends_on("app", "api")
// deps._step("app", "api")
// imports("app", "api")
// 2. log
// deps.depends_on("app", "log")
// deps.depends_on("app", "api")
// deps._step("app", "api")
// imports("app", "api")
// deps._step("api", "log")
// imports("api", "log")
// rows 1 to 2 of 3; continue with page_token "2"
//
// query: error: tool error: query: unknown relation "depends_on"; did you mean "deps.depends_on"?
// line 1, column 1:
// depends_on("app", ?d) => ?d
// ^
// fix: did you mean "deps.depends_on"?
// cypher:
// ran: pkg(?p, _), pkg(?to, _), imports(?p, ?to) => ?p
// columns: importer
// 1. api
// pkg("api", absent)
// pkg("log", "")
// imports("api", "log")
// 1 row
}
Building and running it ¶
mcp.New(v, pack, opts) takes the vocabulary, an mcp.Pack and mcp.Options, and returns mcpkit’s *server.Server. It refuses a pack whose examples don’t check against the vocabulary, and options without a Base.
mcp.Packis annl.Packplus what only a server needs:NameandVersion, which identify it to a client (jaalaand0.0.0when empty),Instructions, andFormat(below).Options.Baseis a func asked once per call, so a host can pick a workspace or a store per caller.Options.Limitis the most rows a call returns (100 when 0) andOptions.MaxWorkcaps every call’s work (no cap when 0). A call may ask for less of either, never more.Options.Timeoutis the longest a call may take,PrepareandBaseincluded (#361). A call past it stops and answers a tool error saying how long the server gives one, and aRun’sTimeoutmay shorten it for one call but never lengthen it.Options.Vocabulary, given in place ofNew’s vocabulary (mcp.New(nil, pack, opts)), resolves it on the first call that reads it (#361). A host whose vocabulary comes from its data, such as relations named after the edge kinds a workspace holds, answersinitializeat once instead of opening the workspace first, which a cold cache can take longer than a client waits to connect.schema,look,values,askandcypherwait for it, whilequeryandexplainread theBaseand never do. A failed resolve is the call’s tool error and is tried again on the next, and the checksNewmakes of the pack (its examples, the card, the Cypher graph) run on the first vocabulary it returns, so a broken pack fails that call rather than startup.Options.Evalare options every evaluation runs with, such asWitnesses().Options.Modeloffers theasktool, andOptions.Fallbackis the model it asks afterModel, as innl.Options.Options.Serverare mcpkit server options, applied after the instructionsNewsets, so a host’s wins where both set one thing (#474). A host whose tools never change sendsserver.WithListTTLMs(…)so a client needn’t fetchtools/listagain. They are passed through unexamined, and aserver.WithInstructionsamong them replaces the pack’s.
Transport and sessions are mcpkit’s, so picking one is pretty much a line in the host’s main. The second example above serves over HTTP with srv.Handler(server.WithStreamableHTTP(true)), as srv.Run(addr) does, and an agent that starts the server itself, such as Claude Code from its MCP settings, talks to srv.RunStdio(ctx) instead.
The tools ¶
queryruns aprogramand answers a page of rows, each with the facts it was derived from.limitasks for fewer rows than the server’s limit, andmax_workfor less work than its cap. An answer longer than a page ends with apage_token, which the next call passes to continue; the token is the offset of the next row, and each page runs the program again, so the server keeps nothing between calls.json: trueanswers with the columns, rows, witnesses, total and token as JSON instead of text.bindgives the program’s variables their values by name ({"from": "a"}, with or without the?), each a string, a number or a list the variable ranges over, throughdatalog.Bind(#469), so a name the user typed reaches the program as a value and never as text spliced into it. A variable the goal doesn’t use is refused by the engine.schemaanswers the card with nopath, a module’s members or a relation’s signature, doc and rules with one, the pack’sCardatdata, and the graph thecyphertool reads atcypher.look,valuesandfindarenl.Translate’s actions as tools (#393), so an agent and a small model read the same thing.lookdescribes a relation, module or, withlanguage: cypher, a label, asnl.Lookdoes.valuesanswers up tolimit(20) distinct values of atarget,pkg.licenseor withcypherPackage.license, runningnl.Probe’s program throughqueryorcypher.find, offered only for a pack with aGrounder, answers what a name may stand for.sampleonqueryandcypheris a try: the program runs with its own limit lowered to that many rows, so it stops early, and when none come back the answer says which literal matched nothing (nl.Explain’s text, orcypher.Explain’s caret under the pattern), inAnswer.Whyand the JSON’swhy. Withoutsample, an empty answer only says so, unless the pack has aHint: then every empty answer ofquery,cypherandexplainsays which literal matched nothing and adds the hint (#430).explainruns aprogramundermax_work, withbindasquerytakes it, and answers itsReportinstead of its rows: where the work went and, for an empty answer, the literal that matched nothing.ask, offered only whenOptions.Modelis set, translates aquestionthroughnl.Translatewith the pack and answers the program, its rows and each try. Itsmodeislocal(the model alone),frontier(the fallback alone) orboth, the default. Itslanguageis what the model writes:datalog, orcypherfor a pack with aCyphergraph, when the model translates throughcypher.NewFrontEndand the answer is thecyphertool’s (#386).Pack.AskLanguagepicks it when a call doesn’t, andPack.CypherExamplesare what the model writing Cypher is shown in place of the pack’s jaala examples;Newrefuses one that doesn’t compile. It takeslimit,max_workandjsonasquerydoes.
A program that doesn’t parse, check or finish within its budget is a tool error carrying nl.Explain’s text, as the third call above shows, so the agent fixes the program and calls again. Every tool’s schema allows only the arguments it names, so a misspelled argument is a tool error too, never ignored.
The cypher tool ¶
Agents know Cypher far better than jaala’s Datalog. A pack whose Cypher field maps the vocabulary as a property graph (a cypher.Graph, which cypher.Store writes for a node, edge and attr store) gets the cypher tool too, so an agent can ask in the language it knows (#366). It’s a tool of its own rather than a language argument on query, so its name tells an agent Cypher works before it reads anything else, and its description names the graph’s labels and relationship types. New refuses a graph that doesn’t fit the vocabulary.
- It takes a Cypher
query,paramsfor its$parameters(a string or a number each), andlimit,page_token,max_workandjsonasquerydoes, plus the host’s own arguments. - It compiles the query over the call’s pack, so a
Run’s pack can bring a graph of its own for the call, and a pack without one refuses the call. The program it compiled to then runs exactly asqueryruns a program, with the same base, budget, witnesses and paging. - The answer opens with
ran:and the program, as the last call in the example above shows, so an agent sees what ran and can hand it toexplain. Its columns are named as the query named them, an alias orp.name, andPack.Formatgets the Cypher text and those names inAnswer.CypherandAnswer.Columns. Withjsonthe answer carriescypherandprogramtoo. - A query the compiler refuses, or whose program doesn’t check or run, is a tool error written by
cypher.Explain: the message in the query’s terms, the line of the query with a caret under the place, the fix, and for Cypher a program can’t express yet, the issue that would let it, so the agent rewrites rather than retries. An error from checking or running the program also shows the program it ran.
Instructions ¶
The instructions an agent reads when it connects open with the pack’s About, say how to write a program and to call schema first, show the pack’s first two examples, point at look, values (and find, with a Grounder) and query’s sample before guessing a value, and, with a graph, say Cypher works and name its labels and types, as the first example above prints; mcp.Instructions returns them. Pack.Instructions replaces them word for word, for a host trying wordings against its own agent (declaire#308). mcp.AgentLine is a sentence for the host’s agent instructions, such as a CLAUDE.md, saying when to use the server.
A host’s own tools and arguments ¶
A host registers its own tools on the server New returns, with mcpkit’s RegisterTool, and writes answers its own way with Pack.Format, which gets each page as an mcp.Answer: the program, the whole Table with every row’s witness, the page’s offset and length, and the next token, and Bind, what the call bound by the name it gave: a program’s variables for query and a Cypher query’s parameters for cypher (#472). Program is the text alone, so a host writing a link or a program that asks the same question again needs both.
Pack.Data gives the same page as structured content (#469), such as the host’s own entities, citations and links for each row, so a client reads them as data rather than parsing Format’s text. It replaces the JSON that json asks for and is sent whether or not the call asked; with json its JSON is the text too. It must marshal to a JSON object, since MCP’s structuredContent is one, and an error from it, or anything else, is the call’s tool error.
A host’s own arguments go on jaala’s tools through mcp.NewWith(v, pack, opts, mcp.Extension[A]{Prepare: …}) (#357). A is a plain struct tagged as a tool’s input is (json, jsonschema_description), such as the repos a query reads or how many lines of code an answer shows, and its fields sit flat in every tool’s schema beside the tool’s own program or limit. On each call Prepare gets the tool’s name and the decoded A, and returns an mcp.Run: the Base the call reads, more Eval options, a Pack that replaces the server’s for the call, so its Card is what schema says at data, its Format and Data write the answer and ask translates with it, a BaseFor, asked with each program the call runs (query’s and explain’s, values’ probe, and the program cypher compiles to) in place of Base, for a host whose Base depends on what a program reads, such as one that opens an expensive tier only when a program reads a relation in it (#472); ask, which has no program until its model writes one, reads Base. And a Vocabulary that the call’s schema, look, values, ask and cypher read in place of the server’s, such as one holding the modules of the project the call names (#469). Neither the Run’s pack nor its vocabulary is checked as New checks the server’s, so the Run’s Base should be read through that vocabulary. A field left empty is the server’s. An error from Prepare is the call’s tool error. NewWith refuses an A that isn’t a struct or whose names clash with any tool’s own; New is NewWith with no host arguments.