mcp

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 22, 2026 License: MIT Imports: 14 Imported by: 0

README

Arandu

arandu-io/mcp

Expose an Arandu application to an AI client, through the policies it already has.

Build Status Go Reference Latest Version License

What this is

The Model Context Protocol is how an assistant reaches a program: the program declares tools it can call, resources it can read and prompts it can use, and the client picks. This package is the Arandu side of that, speaking protocol revision 2024-11-05, over HTTP or over stdio.

Install

go get github.com/arandu-io/mcp

A tool

type ListPosts struct{ svc *services.PostService }

func (ListPosts) Name() string        { return "list_posts" }
func (ListPosts) Description() string { return "Lists the posts of this blog, newest first." }

func (ListPosts) Schema() mcp.Schema {
	return mcp.Object(
		mcp.String("status", "Which posts to list").Enum("published", "draft"),
		mcp.Int("limit", "How many to return"),
	)
}

func (t ListPosts) Handle(ctx context.Context, r mcp.Request) (mcp.Response, error) {
	limit, _ := r.Int("limit")

	// The subject the request carried, through the service, through the policy.
	found, err := t.svc.List(ctx, r.Subject(), data.Query{Limit: limit})
	if err != nil {
		return mcp.Response{}, err
	}
	return mcp.JSON(found), nil
}

A server, and where it is reachable

// routes/ai.go
server := &mcp.Server{
	Name:         "blog",
	Version:      "1.0.0",
	Instructions: "The posts and comments of this blog. Drafts are not public.",
	Tools:        []mcp.Tool{ListPosts{svc}, PublishPost{svc}},
}

// Over HTTP, for a remote client. The subject comes from the session.
r.Action("POST", "/mcp", mcp.Web(server, sessions, cfg.Auth.Tenant)).Name("mcp")

// Over stdio, for an assistant on this machine. `aru mcp:start`.
mcp.Start(ctx, server, assistant)

The part that is not a port

The shape above is the one this ecosystem's users already know, deliberately. One thing is different, and it is the reason this package exists rather than a generic Go MCP library.

A tool reaches data, and every path to data in Arandu carries a security.Grant. The Subject is on the Request, and a tool has no other way to call a service. A policy that refuses a tool refuses it for the same reason it refuses a controller — there is no second enforcement point, and no way to write one by accident.

That is not tidiness. An MCP server hands a language model the keys to an application. The version of this package where a tool queries the database directly would be the largest hole this project could ship, and it would ship quietly, because the answers would look right.

Where the subject comes from is the transport's answer, and the two are different on purpose:

mcp.Web from the session, exactly like an HTTP request. No session is a guest, and what a guest may do is the policy's answer
mcp.Local from configuration, over a pipe. There is no session on stdio, so the identity is declared where the server is registered and is visible in routes/ai.go

The local one takes a Subject rather than defaulting to one, so an application that lets an assistant act as an administrator has written that down somewhere a reviewer reads.

Three smaller decisions

A refusal is an error, not an empty result. A model handed an empty list concludes there is nothing there and tells somebody. A model told it may not, stops. It is one boolean and it is the difference between "you have no invoices" and "you cannot see them".

An argument nobody declared is refused. A model that invents a parameter and is not told keeps inventing it — and a tool that reads only what it declared acts on a call it half understood.

A tool with no description does not boot. The description is what the model reads to decide whether to call it: it is the highest-leverage string in this package, and a tool without one is called at random. It is a mistake in a declaration, so it belongs at boot rather than at the first call.

What is deliberately absent

  • No attributes, because Go has none. A tool's name and description are methods: more characters, one less mechanism.
  • No facade, and no dynamic registration. A server declares its tools, resources and prompts in three slices, so one that exists and is not reachable is visible in one file.
  • No sampling and no roots. They are in the protocol and nothing in an Arandu application needs them yet; a capability declared and not served is one a client reports as the server being broken.

Learning Arandu

The API reference is generated from the doc comments and lives on pkg.go.dev. Every exported symbol carries one, and that is deliberate: it is the documentation that cannot drift from the code, because it sits in the same file.

The CLI documents itself. aru help lists every command, and each one explains what it writes and what to do with it. aru doctor explains what it found and what breaks, not which rule was violated.

A guide and a website do not exist yet, and that is a decision rather than a gap: a guide written against an API that still moves is work done twice, and the second time is worse — there is wrong documentation published. The site is the next phase, and it will be an Arandu application.

Contributing

See CONTRIBUTING.md.

Security Vulnerabilities

Please review our security policy on how to report a vulnerability. Never open a public issue for one.

License

Open-sourced software licensed under the MIT license.

Documentation

Overview

Package mcp exposes an Arandu application to an AI client.

The Model Context Protocol is how an assistant reaches a program: the program declares tools it can call, resources it can read and prompts it can use, and the client picks. This package is the Arandu side of that.

Every tool carries a Grant

A tool reaches data, and every path to data in this framework carries a security.Grant. So does this one.

func (t Invoices) Handle(ctx context.Context, r mcp.Request) (mcp.Response, error) {
	found, err := t.svc.List(ctx, r.Subject(), data.Query{Limit: 20})
	…
}

The Subject is on the Request and there is no way to call a service without one. That is not politeness -- an MCP server is a program that hands a language model the keys to an application, and the version of this package where a tool queries the database directly would be the largest hole this project could ship. A policy that refuses a tool refuses it for the same reason it refuses a controller.

Where the Subject comes from is the transport's answer, and the two are deliberately different:

Web    from the session, exactly like an HTTP request. A remote client is
       somebody signed in, or it is nobody.
Local  from configuration, over stdio. There is no session on a pipe, so the
       identity is declared when the server is registered and is visible in
       routes/ai.go rather than assumed.

What is deliberately absent

No attributes, because Go has none: a tool's name and description are methods, which is more characters and one less mechanism. No facade. No dynamic registration -- a server declares its tools in a slice, so a tool that exists and is not reachable is visible in one file.

Index

Constants

View Source
const MaxMessage = 1 << 20

MaxMessage is the largest message either transport reads, in bytes.

A message is held whole before it can be parsed, so without a bound the process holds whatever the other end sends -- and sending is the cheap half of that exchange. The number is the same on both transports: a message one accepts and the other refuses is a message whose fate depends on how it arrived, which is the hardest kind of report to act on.

View Source
const Version = "2024-11-05"

Version is the protocol revision this package speaks.

Variables

This section is empty.

Functions

func Describe

func Describe(s *Server, out io.Writer)

Describe prints what a server offers, for `aru mcp:list`.

It exists because the alternative is connecting a client to find out, and the question "what can this thing do" is asked far more often than it is answered by an assistant.

func Local

func Local(ctx context.Context, s *Server, subject security.Subject, in io.Reader, out io.Writer) error

Local serves the server over stdin and stdout.

It is what a client on the same machine starts, and it is `aru mcp:start`. One message per line, which is how the protocol frames itself on a pipe, and no message longer than MaxMessage.

Nothing is ever written to stdout except an answer. A log line there is a parse error at the client, and it is the most common way a stdio server appears broken while working -- so the logger is the framework's, which writes to stderr.

func Start

func Start(ctx context.Context, s *Server, subject security.Subject) error

Start is Local over the process's own stdin and stdout.

func Web

func Web(s *Server, sessions *security.SessionStore, tenant string) func(*fhttp.Context) error

Web mounts the server on a route.

The subject comes from the session. A client with none is a guest, and what a guest may do is the policy's answer -- the same answer a browser would get.

Types

type Argument

type Argument struct {
	Name        string
	Description string
	Required    bool
}

Argument is one input a prompt takes.

type Field

type Field struct {
	// contains filtered or unexported fields
}

Field is one declared argument.

func Bool

func Bool(name, description string) Field

func Int

func Int(name, description string) Field

func String

func String(name, description string) Field

String, Int and Bool declare an argument of that type.

func (Field) Enum

func (f Field) Enum(values ...string) Field

Enum limits a string to a set. It is worth reaching for: a model given a closed list picks from it, and a model given "the status" invents one.

func (Field) Required

func (f Field) Required() Field

Required marks the argument as mandatory. A call without it is refused before the tool runs.

type Message

type Message struct {
	// Role is "user" or "assistant".
	Role string
	Text string
}

Message is one turn of a prompt.

func Assistant

func Assistant(text string) Message

func User

func User(text string) Message

User and Assistant build a message.

type Prompt

type Prompt interface {
	Name() string
	Description() string
	// Arguments are what the client fills in before the prompt is useful.
	Arguments() []Argument
	// Render builds the messages.
	Render(ctx context.Context, r Request) ([]Message, error)
}

Prompt is a conversation an application knows how to start.

type Request

type Request struct {
	// Arguments are what the client sent, already checked against the tool's
	// schema. Read them with String, Int and Bool rather than by indexing: a
	// missing key is a zero value, and a tool that cannot tell "0" from "absent"
	// is a tool that acts on an argument nobody passed.
	Arguments map[string]any
	// contains filtered or unexported fields
}

Request is one call from a client.

func (Request) Bool

func (r Request) Bool(name string) (bool, bool)

Bool reads a boolean.

func (Request) Int

func (r Request) Int(name string) (int, bool)

Int reads a number. JSON has one numeric type and it decodes as float64, so this is where that stops being the caller's problem.

func (Request) String

func (r Request) String(name string) (string, bool)

String reads a string argument, and reports whether it was there at all.

func (Request) Subject

func (r Request) Subject() security.Subject

Subject is who is asking, for the service call the tool is about to make.

type Resource

type Resource interface {
	// URI addresses it, in a scheme of the application's choosing.
	URI() string
	// Name and Description are what the client lists it as.
	Name() string
	Description() string
	// MimeType is what the content is. Empty means text/plain.
	MimeType() string
	// Read returns the content.
	Read(ctx context.Context, s security.Subject) (Response, error)
}

Resource is something a client can read.

type Response

type Response struct {
	// Text is what the client shows or feeds to the model.
	Text string
	// IsError marks the answer as a failure the model should react to rather
	// than a result. A refused authorization is one of these: the model should
	// learn it may not, not be handed an empty list and conclude there is
	// nothing there.
	IsError bool
}

Response is what a tool answers with.

func Error

func Error(format string, args ...any) Response

Error is an answer the model should treat as a failure.

func JSON

func JSON(v any) Response

JSON is an answer carrying structured data.

Encoded here rather than by the tool, so every tool answers the same shape and a marshalling error is one error message instead of one per tool.

func Text

func Text(format string, args ...any) Response

Text is the ordinary answer.

type Schema

type Schema struct {
	// contains filtered or unexported fields
}

Schema declares what a tool takes.

It is a small typed builder rather than a map or a struct tag, for the reason the rest of this framework prefers a signature to a convention: a schema written as JSON in a string is a schema nothing checks, and the first time it is wrong the model sends an argument the tool ignores.

mcp.Object(
	mcp.String("slug", "The post to read").Required(),
	mcp.Int("limit", "How many to return"),
)

func Object

func Object(fields ...Field) Schema

Object builds a schema from its fields.

func (Schema) JSON

func (s Schema) JSON() map[string]any

JSON renders the schema the way the protocol carries it.

func (Schema) Validate

func (s Schema) Validate(args map[string]any) error

Validate checks a call's arguments against the schema.

It runs before Handle, so a tool never sees an argument it did not declare or a missing one it marked required -- which is what lets a tool read an argument without checking, and what stops a model's invented parameter from reaching application code.

Every problem is reported at once. A model that is told one mistake per call spends three calls on a form it could have filled in on the second.

type Server

type Server struct {
	// Name and Version identify the server to the client.
	Name    string
	Version string
	// Instructions are read by the model before anything else, and they are the
	// place to say what this application is for. A server whose instructions are
	// empty is a server the model guesses about.
	Instructions string

	Tools     []Tool
	Resources []Resource
	Prompts   []Prompt
}

Server is what a client connects to: a name, and what it can do.

The three lists are slices rather than a registry somebody appends to at boot. A tool that exists and is not reachable is then visible in one file, which is the same reason bootstrap/app.go is a list rather than a container.

func (*Server) Call

func (s *Server) Call(ctx context.Context, subject security.Subject, name string, args map[string]any) Response

Call runs a tool, as the given subject.

This is the one door. Both transports come through it, so the validation, the authorization boundary and the shape of a failure are decided once -- and a third transport cannot arrive with its own idea of any of them.

func (*Server) Handle

func (s *Server) Handle(ctx context.Context, subject security.Subject, body []byte) []byte

Handle answers one JSON-RPC message.

It returns nil for a notification -- a message with no id, which the protocol says gets no answer. Sending one anyway is what makes a client hang up.

func (*Server) Read

func (s *Server) Read(ctx context.Context, subject security.Subject, uri string) Response

Read returns a resource's content.

func (*Server) Tool

func (s *Server) Tool(name string) (Tool, bool)

Tool finds one by name.

func (*Server) Validate

func (s *Server) Validate() error

Validate reports what is wrong with the server itself.

It runs at boot rather than at the first call, because everything it checks is a mistake in a declaration: two tools with one name, a tool with no description, a schema field nobody named. A server that starts and answers nonsense is worse than one that refuses to start.

type Tool

type Tool interface {
	// Name is what the client calls it by. Lower case with underscores, because
	// that is what every client displays without quoting.
	Name() string
	// Description is what the model reads to decide whether to call it. It is
	// the single highest-leverage string in this package: a model that calls the
	// wrong tool was told the wrong thing here.
	Description() string
	// Schema declares the arguments. A call whose arguments do not match is
	// refused before Handle runs.
	Schema() Schema
	// Handle does the work. The Grant comes from the Request's Subject, through
	// the service, through the policy -- like everywhere else.
	Handle(ctx context.Context, r Request) (Response, error)
}

Tool is something a client can call.

Directories

Path Synopsis
tests
Helpers
Package helpers builds the servers, tools and readers that this module's own tests drive.
Package helpers builds the servers, tools and readers that this module's own tests drive.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL