Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,266 changes: 2,251 additions & 15 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
],
"dependencies": {
"@mcp-ui/server": "^5.13.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.25.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.56.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.56.0",
Expand All @@ -85,6 +85,7 @@
"@opentelemetry/sdk-node": "^0.56.0",
"@opentelemetry/sdk-trace-base": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.1",
"@turf/turf": "^7.3.1",
"patch-package": "^8.0.1",
"zod": "^3.25.42"
},
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ server.server.setRequestHandler(ListPromptsRequestSchema, async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(server.server as any).setRequestHandler(
GetPromptRequestSchema,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async (request: any) => {
const { name, arguments: args } = request.params;

Expand Down
8 changes: 7 additions & 1 deletion src/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ export abstract class BaseTool<
(this.outputSchema as unknown as z.ZodObject<any>).shape;
}

return server.registerTool(
// Type assertion to avoid "Type instantiation is excessively deep" error
// This is a known issue in MCP SDK 1.25.1: https://github.com/modelcontextprotocol/typescript-sdk/issues/985
// TODO: Remove this workaround when SDK fixes their type definitions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serverAny = server as any;

return serverAny.registerTool(
this.name,
config,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
37 changes: 37 additions & 0 deletions src/tools/area-tool/AreaTool.input.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import z from 'zod';

const CoordinateSchema = z
.array(z.number())
.length(2)
.describe('Coordinate as [longitude, latitude]');

/**
* Input schema for AreaTool
*/
export const AreaInputSchema = z.object({
geometry: z
.union([
// Polygon
z.array(z.array(CoordinateSchema)).min(1),
// MultiPolygon
z.array(z.array(z.array(CoordinateSchema))).min(1)
])
.describe(
'Polygon or MultiPolygon coordinates. ' +
'Polygon: array of rings (first is outer, rest are holes). ' +
'MultiPolygon: array of polygons.'
),
units: z
.enum(['meters', 'kilometers', 'feet', 'miles', 'acres', 'hectares'])
.optional()
.default('meters')
.describe('Unit of measurement for area')
});

/**
* Type inference for AreaInput
*/
export type AreaInput = z.infer<typeof AreaInputSchema>;
17 changes: 17 additions & 0 deletions src/tools/area-tool/AreaTool.output.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { z } from 'zod';

/**
* Output schema for AreaTool
*/
export const AreaOutputSchema = z.object({
area: z.number().describe('Calculated area'),
units: z.string().describe('Unit of measurement')
});

/**
* Type inference for AreaOutput
*/
export type AreaOutput = z.infer<typeof AreaOutputSchema>;
122 changes: 122 additions & 0 deletions src/tools/area-tool/AreaTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { area as turfArea, polygon, multiPolygon } from '@turf/turf';
import { context, SpanStatusCode, trace } from '@opentelemetry/api';
import { createLocalToolExecutionContext } from '../../utils/tracing.js';
import { BaseTool } from '../BaseTool.js';
import { AreaInputSchema } from './AreaTool.input.schema.js';
import { AreaOutputSchema, type AreaOutput } from './AreaTool.output.schema.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';

/**
* AreaTool - Calculate the area of a polygon or multipolygon
*/
export class AreaTool extends BaseTool<
typeof AreaInputSchema,
typeof AreaOutputSchema
> {
readonly name = 'area_tool';
readonly description =
'Calculate the area of a polygon or multipolygon. ' +
'Supports various units including square meters, kilometers, acres, and hectares. ' +
'Works offline without API calls.';

readonly annotations = {
title: 'Calculate Area',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
};

constructor() {
super({
inputSchema: AreaInputSchema,
outputSchema: AreaOutputSchema
});
}

async run(rawInput: unknown): Promise<CallToolResult> {
const toolContext = createLocalToolExecutionContext(this.name, 0);
return await context.with(
trace.setSpan(context.active(), toolContext.span),
async () => {
try {
const input = AreaInputSchema.parse(rawInput);

// Determine if it's a polygon or multipolygon based on depth
const isMultiPolygon =
Array.isArray(input.geometry[0]) &&
Array.isArray(input.geometry[0][0]) &&
Array.isArray(input.geometry[0][0][0]);

const geom = isMultiPolygon
? multiPolygon(input.geometry as number[][][][])
: polygon(input.geometry as number[][][]);

// Calculate area in square meters first
const areaInSquareMeters = turfArea(geom);

// Convert to requested units
let area: number;
switch (input.units) {
case 'meters':
area = areaInSquareMeters;
break;
case 'kilometers':
area = areaInSquareMeters / 1000000;
break;
case 'feet':
area = areaInSquareMeters * 10.7639;
break;
case 'miles':
area = areaInSquareMeters / 2589988.11;
break;
case 'acres':
area = areaInSquareMeters / 4046.86;
break;
case 'hectares':
area = areaInSquareMeters / 10000;
break;
default:
area = areaInSquareMeters;
}

const result: AreaOutput = {
area: Math.round(area * 1000) / 1000,
units: input.units
};

const validatedResult = this.validateOutput(result) as AreaOutput;

const text = `Area: ${validatedResult.area} square ${validatedResult.units}`;

toolContext.span.setStatus({ code: SpanStatusCode.OK });
toolContext.span.end();

return {
content: [{ type: 'text' as const, text }],
structuredContent: validatedResult,
isError: false
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toolContext.span.setStatus({
code: SpanStatusCode.ERROR,
message: errorMessage
});
toolContext.span.end();
this.log('error', `${this.name}: ${errorMessage}`);
return {
content: [
{ type: 'text' as const, text: `AreaTool: ${errorMessage}` }
],
isError: true
};
}
}
);
}
}
27 changes: 27 additions & 0 deletions src/tools/bearing-tool/BearingTool.input.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import z from 'zod';

/**
* Input schema for BearingTool
*/
export const BearingInputSchema = z.object({
from: z
.object({
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90)
})
.describe('Starting coordinate'),
to: z
.object({
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90)
})
.describe('Ending coordinate')
});

/**
* Type inference for BearingInput
*/
export type BearingInput = z.infer<typeof BearingInputSchema>;
24 changes: 24 additions & 0 deletions src/tools/bearing-tool/BearingTool.output.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { z } from 'zod';

/**
* Output schema for BearingTool
*/
export const BearingOutputSchema = z.object({
bearing: z.number().describe('Bearing in degrees (0-360, where 0 is North)'),
from: z.object({
longitude: z.number(),
latitude: z.number()
}),
to: z.object({
longitude: z.number(),
latitude: z.number()
})
});

/**
* Type inference for BearingOutput
*/
export type BearingOutput = z.infer<typeof BearingOutputSchema>;
107 changes: 107 additions & 0 deletions src/tools/bearing-tool/BearingTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { bearing as turfBearing } from '@turf/turf';
import { context, SpanStatusCode, trace } from '@opentelemetry/api';
import { createLocalToolExecutionContext } from '../../utils/tracing.js';
import { BaseTool } from '../BaseTool.js';
import { BearingInputSchema } from './BearingTool.input.schema.js';
import {
BearingOutputSchema,
type BearingOutput
} from './BearingTool.output.schema.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';

/**
* BearingTool - Calculate the bearing between two geographic coordinates
*/
export class BearingTool extends BaseTool<
typeof BearingInputSchema,
typeof BearingOutputSchema
> {
readonly name = 'bearing_tool';
readonly description =
'Calculate the bearing (compass direction) from one point to another. ' +
'Returns bearing in degrees where 0° is North, 90° is East, 180° is South, and 270° is West. ' +
'Works offline without API calls.';

readonly annotations = {
title: 'Calculate Bearing',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
};

constructor() {
super({
inputSchema: BearingInputSchema,
outputSchema: BearingOutputSchema
});
}

async run(rawInput: unknown): Promise<CallToolResult> {
const toolContext = createLocalToolExecutionContext(this.name, 0);
return await context.with(
trace.setSpan(context.active(), toolContext.span),
async () => {
try {
const input = BearingInputSchema.parse(rawInput);
const { from, to } = input;

const bearing = turfBearing(
[from.longitude, from.latitude],
[to.longitude, to.latitude]
);

// Convert from -180 to 180 range to 0-360 range
const normalizedBearing = bearing < 0 ? bearing + 360 : bearing;

const result: BearingOutput = {
bearing: Math.round(normalizedBearing * 100) / 100,
from,
to
};

const validatedResult = this.validateOutput(result) as BearingOutput;

const text =
`Bearing: ${validatedResult.bearing}° ` +
`(${this.bearingToCardinal(validatedResult.bearing)})\n` +
`From: [${validatedResult.from.longitude}, ${validatedResult.from.latitude}]\n` +
`To: [${validatedResult.to.longitude}, ${validatedResult.to.latitude}]`;

toolContext.span.setStatus({ code: SpanStatusCode.OK });
toolContext.span.end();

return {
content: [{ type: 'text' as const, text }],
structuredContent: validatedResult,
isError: false
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toolContext.span.setStatus({
code: SpanStatusCode.ERROR,
message: errorMessage
});
toolContext.span.end();
this.log('error', `${this.name}: ${errorMessage}`);
return {
content: [
{ type: 'text' as const, text: `BearingTool: ${errorMessage}` }
],
isError: true
};
}
}
);
}

private bearingToCardinal(bearing: number): string {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const index = Math.round(bearing / 45) % 8;
return directions[index];
}
}
Loading