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.Pack is an nl.Pack plus what only a server needs: Name and Version, which identify it to a client (jaala and 0.0.0 when empty), Instructions, and Format (below).
  • Options.Base is a func asked once per call, so a host can pick a workspace or a store per caller.
  • Options.Limit is the most rows a call returns (100 when 0) and Options.MaxWork caps every call’s work (no cap when 0). A call may ask for less of either, never more.
  • Options.Timeout is the longest a call may take, Prepare and Base included (#361). A call past it stops and answers a tool error saying how long the server gives one, and a Run’s Timeout may shorten it for one call but never lengthen it.
  • Options.Vocabulary, given in place of New’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, answers initialize at once instead of opening the workspace first, which a cold cache can take longer than a client waits to connect. schema, look, values, ask and cypher wait for it, while query and explain read the Base and never do. A failed resolve is the call’s tool error and is tried again on the next, and the checks New makes 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.Eval are options every evaluation runs with, such as Witnesses().
  • Options.Model offers the ask tool, and Options.Fallback is the model it asks after Model, as in nl.Options.
  • Options.Server are mcpkit server options, applied after the instructions New sets, so a host’s wins where both set one thing (#474). A host whose tools never change sends server.WithListTTLMs(…) so a client needn’t fetch tools/list again. They are passed through unexamined, and a server.WithInstructions among 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

  • query runs a program and answers a page of rows, each with the facts it was derived from. limit asks for fewer rows than the server’s limit, and max_work for less work than its cap. An answer longer than a page ends with a page_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: true answers with the columns, rows, witnesses, total and token as JSON instead of text. bind gives 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, through datalog.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.
  • schema answers the card with no path, a module’s members or a relation’s signature, doc and rules with one, the pack’s Card at data, and the graph the cypher tool reads at cypher.
  • look, values and find are nl.Translate’s actions as tools (#393), so an agent and a small model read the same thing. look describes a relation, module or, with language: cypher, a label, as nl.Look does. values answers up to limit (20) distinct values of a target, pkg.license or with cypher Package.license, running nl.Probe’s program through query or cypher. find, offered only for a pack with a Grounder, answers what a name may stand for.
  • sample on query and cypher is 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, or cypher.Explain’s caret under the pattern), in Answer.Why and the JSON’s why. Without sample, an empty answer only says so, unless the pack has a Hint: then every empty answer of query, cypher and explain says which literal matched nothing and adds the hint (#430).
  • explain runs a program under max_work, with bind as query takes it, and answers its Report instead of its rows: where the work went and, for an empty answer, the literal that matched nothing.
  • ask, offered only when Options.Model is set, translates a question through nl.Translate with the pack and answers the program, its rows and each try. Its mode is local (the model alone), frontier (the fallback alone) or both, the default. Its language is what the model writes: datalog, or cypher for a pack with a Cypher graph, when the model translates through cypher.NewFrontEnd and the answer is the cypher tool’s (#386). Pack.AskLanguage picks it when a call doesn’t, and Pack.CypherExamples are what the model writing Cypher is shown in place of the pack’s jaala examples; New refuses one that doesn’t compile. It takes limit, max_work and json as query does.

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, params for its $parameters (a string or a number each), and limit, page_token, max_work and json as query does, 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 as query runs 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 to explain. Its columns are named as the query named them, an alias or p.name, and Pack.Format gets the Cypher text and those names in Answer.Cypher and Answer.Columns. With json the answer carries cypher and program too.
  • 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.