Skip to content

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 namesnake_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:

nix run .#test

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.Point
  • createCirclePolygon(center orb.Point, r float64, segments int) orb.Ring
  • buildQuadtree(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.