Add a new spatial tool¶
The whole point of this server is to give your LLM a growing toolbox of spatial primitives. Adding a new tool is a five-step recipe.
We'll walk through adding a hypothetical convex_hull tool that takes a
FeatureCollection of points and returns the smallest enclosing polygon.
1. Sketch the contract¶
Decide:
- Tool name —
snake_case, lowercase.convex_hull. - Inputs — name, type, default, description.
- Outputs — what GeoJSON shape comes back.
Write that down at the top of your handler as a comment. It becomes the docstring the LLM sees.
2. Register the tool from main¶
s.AddTool(
mcp.NewTool("convex_hull",
mcp.WithDescription("Computes the convex hull of a GeoJSON PointCollection."),
mcp.WithString("geojson",
mcp.Required(),
mcp.Description("A GeoJSON FeatureCollection of Point features."),
),
),
convexHullHandler,
)
3. Write the handler¶
// convexHullHandler returns the smallest enclosing polygon of the input points.
//
// Inputs:
// geojson (string, required): GeoJSON FeatureCollection of Points.
// Outputs:
// GeoJSON FeatureCollection with a single Polygon feature.
func convexHullHandler(
ctx context.Context,
request mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("arguments must be a map")
}
geoJSONStr, ok := args["geojson"].(string)
if !ok {
return nil, fmt.Errorf("missing geojson parameter")
}
fc, err := geojson.UnmarshalFeatureCollection([]byte(geoJSONStr))
if err != nil {
return nil, fmt.Errorf("failed to parse GeoJSON: %v", err)
}
points := extractPoints(fc) // shared helper — DRY
if len(points) < 3 {
return mcp.NewToolResultText("Need at least 3 points for a hull"), nil
}
hull := computeConvexHull(points) // your geometry logic
out := geojson.NewFeatureCollection()
f := geojson.NewFeature(orb.Polygon{hull})
f.Properties["point_count"] = len(points)
f.Properties["kartoza_credit"] = "Processed by Kartoza.com tools"
out.Features = append(out.Features, f)
body, _ := json.Marshal(out)
return mcp.NewToolResultText(string(body)), nil
}
4. Test it¶
Add a table-driven test in main_test.go (or a new file in the same
package).
func TestConvexHull(t *testing.T) {
tests := []struct {
name string
input string
wantPts int
wantError bool
}{
{"square", squareJSON, 4, false},
{"too few", twoPointJSON, 0, false},
{"garbage", `not json`, 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// ... invoke convexHullHandler directly ...
})
}
}
Run them:
5. Document it¶
- Add a section to User tool reference.
- Add a detailed contract page under
docs/reference/<tool>.md. - Add a row to the homepage feature grid if the tool is significant.
- Reference PACKAGES.md if you pull in a new dependency.
DRY: shared helpers¶
Reusable helpers live in main.go for now. As the surface grows, extract
into a pkg/spatial package:
extractPoints(fc *geojson.FeatureCollection) []orb.PointcreateCirclePolygon(center orb.Point, r float64, segments int) orb.RingbuildQuadtree(points []orb.Point) *quadtree.Quadtree
Cross-cut concerns
Every new analytic should: support default values, return predictable
GeoJSON, carry a kartoza_credit property, and have a unit test that
asserts deterministic output for a fixed input.