index.ts•14 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
// Import APRS service interfaces and types
interface APRSPosition {
name: string;
callsign: string;
lat: number;
lng: number;
altitude?: number;
timestamp: number;
comment?: string;
speed?: number;
course?: number;
symbol?: string;
path?: string;
}
interface APRSResponse {
command: string;
result: string;
what: string;
found: number;
entries: APRSPosition[];
}
class APRSError extends Error {
constructor(message: string, public status?: number) {
super(message);
this.name = 'APRSError';
}
}
class APRSMCPService {
private readonly baseUrl = 'https://api.aprs.fi/api/get';
private rateLimitDelay = 1000;
private apiKey: string | null = null;
setApiKey(apiKey: string): void {
this.apiKey = apiKey;
}
getApiKey(): string | null {
return this.apiKey;
}
clearApiKey(): void {
this.apiKey = null;
}
async getPosition(callsign: string, apiKey?: string): Promise<APRSPosition[]> {
const keyToUse = apiKey || this.apiKey;
if (!keyToUse) {
throw new APRSError('APRS API key not provided. Use /set-api-key command or provide apiKey parameter.');
}
if (!callsign?.trim()) {
throw new APRSError('Callsign is required');
}
const params = new URLSearchParams({
name: callsign.trim().toUpperCase(),
what: 'loc',
apikey: keyToUse,
format: 'json'
});
try {
const response = await fetch(`${this.baseUrl}?${params}`);
if (!response.ok) {
throw new APRSError(
`APRS API request failed: ${response.status} ${response.statusText}`,
response.status
);
}
const data: APRSResponse = await response.json();
if (data.result !== 'ok') {
throw new APRSError(`APRS API error: ${data.result}`);
}
if (data.found === 0) {
return [];
}
return data.entries.map(entry => ({
...entry,
timestamp: entry.timestamp * 1000,
}));
} catch (error) {
if (error instanceof APRSError) {
throw error;
}
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new APRSError('Network error: Unable to connect to APRS.fi API. Check your internet connection.');
}
throw new APRSError(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async getPositionHistory(
callsign: string,
apiKey?: string,
lastHours: number = 24
): Promise<APRSPosition[]> {
const keyToUse = apiKey || this.apiKey;
if (!keyToUse) {
throw new APRSError('APRS API key not provided. Use /set-api-key command or provide apiKey parameter.');
}
if (!callsign?.trim()) {
throw new APRSError('Callsign is required');
}
const params = new URLSearchParams({
name: callsign.trim().toUpperCase(),
what: 'loc',
apikey: keyToUse,
format: 'json',
last: lastHours.toString()
});
try {
const response = await fetch(`${this.baseUrl}?${params}`);
if (!response.ok) {
throw new APRSError(
`APRS API request failed: ${response.status} ${response.statusText}`,
response.status
);
}
const data: APRSResponse = await response.json();
if (data.result !== 'ok') {
throw new APRSError(`APRS API error: ${data.result}`);
}
return data.entries.map(entry => ({
...entry,
timestamp: entry.timestamp * 1000,
})).sort((a, b) => a.timestamp - b.timestamp);
} catch (error) {
if (error instanceof APRSError) {
throw error;
}
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new APRSError('Network error: Unable to connect to APRS.fi API. Check your internet connection.');
}
throw new APRSError(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async getMultiplePositions(callsigns: string[], apiKey?: string): Promise<APRSPosition[]> {
const keyToUse = apiKey || this.apiKey;
if (!keyToUse) {
throw new APRSError('APRS API key not provided. Use /set-api-key command or provide apiKey parameter.');
}
if (!callsigns?.length) {
return [];
}
const cleanCallsigns = callsigns
.map(c => c.trim().toUpperCase())
.filter(c => c.length > 0);
if (cleanCallsigns.length === 0) {
return [];
}
const params = new URLSearchParams({
name: cleanCallsigns.join(','),
what: 'loc',
apikey: keyToUse,
format: 'json'
});
try {
const response = await fetch(`${this.baseUrl}?${params}`);
if (!response.ok) {
throw new APRSError(
`APRS API request failed: ${response.status} ${response.statusText}`,
response.status
);
}
const data: APRSResponse = await response.json();
if (data.result !== 'ok') {
throw new APRSError(`APRS API error: ${data.result}`);
}
return data.entries.map(entry => ({
...entry,
timestamp: entry.timestamp * 1000,
}));
} catch (error) {
if (error instanceof APRSError) {
throw error;
}
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new APRSError('Network error: Unable to connect to APRS.fi API. Check your internet connection.');
}
throw new APRSError(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async validateApiKey(apiKey: string): Promise<boolean> {
if (!apiKey) {
return false;
}
try {
const params = new URLSearchParams({
name: 'TEST',
what: 'loc',
apikey: apiKey,
format: 'json'
});
const response = await fetch(`${this.baseUrl}?${params}`);
if (!response.ok) {
return false;
}
const data: APRSResponse = await response.json();
return data.result === 'ok';
} catch {
return false;
}
}
}
class APRSMCPServer {
private server: Server;
private aprsService: APRSMCPService;
constructor() {
this.server = new Server(
{
name: 'aprs-fi-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
prompts: {},
},
}
);
this.aprsService = new APRSMCPService();
this.setupToolHandlers();
this.setupPromptHandlers();
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'get_aprs_position',
description: 'Get current position data for a specific callsign from APRS.fi',
inputSchema: {
type: 'object',
properties: {
callsign: {
type: 'string',
description: 'The callsign to look up (e.g., "W1AW")',
},
apiKey: {
type: 'string',
description: 'APRS.fi API key (optional if set via /set-api-key)',
},
},
required: ['callsign'],
},
},
{
name: 'get_aprs_history',
description: 'Get position history for a callsign with time range',
inputSchema: {
type: 'object',
properties: {
callsign: {
type: 'string',
description: 'The callsign to look up',
},
apiKey: {
type: 'string',
description: 'APRS.fi API key (optional if set via /set-api-key)',
},
lastHours: {
type: 'number',
description: 'Number of hours to look back (default: 24)',
default: 24,
},
},
required: ['callsign'],
},
},
{
name: 'track_multiple_callsigns',
description: 'Track multiple callsigns at once',
inputSchema: {
type: 'object',
properties: {
callsigns: {
type: 'array',
items: {
type: 'string',
},
description: 'Array of callsigns to track',
},
apiKey: {
type: 'string',
description: 'APRS.fi API key (optional if set via /set-api-key)',
},
},
required: ['callsigns'],
},
},
{
name: 'validate_aprs_key',
description: 'Test if an APRS.fi API key is valid',
inputSchema: {
type: 'object',
properties: {
apiKey: {
type: 'string',
description: 'APRS.fi API key to validate',
},
},
required: ['apiKey'],
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (!args) {
throw new McpError(
ErrorCode.InvalidParams,
'Missing arguments'
);
}
try {
switch (name) {
case 'get_aprs_position':
const positions = await this.aprsService.getPosition(
args.callsign as string,
args.apiKey as string
);
return {
content: [
{
type: 'text',
text: JSON.stringify(positions, null, 2),
},
],
};
case 'get_aprs_history':
const history = await this.aprsService.getPositionHistory(
args.callsign as string,
args.apiKey as string,
args.lastHours as number
);
return {
content: [
{
type: 'text',
text: JSON.stringify(history, null, 2),
},
],
};
case 'track_multiple_callsigns':
const multiplePositions = await this.aprsService.getMultiplePositions(
args.callsigns as string[],
args.apiKey as string
);
return {
content: [
{
type: 'text',
text: JSON.stringify(multiplePositions, null, 2),
},
],
};
case 'validate_aprs_key':
const isValid = await this.aprsService.validateApiKey(
args.apiKey as string
);
return {
content: [
{
type: 'text',
text: JSON.stringify({ valid: isValid }, null, 2),
},
],
};
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
} catch (error) {
if (error instanceof APRSError) {
// Use InvalidParams for missing API key to provide better error handling
if (error.message.includes('API key not provided')) {
throw new McpError(
ErrorCode.InvalidParams,
error.message
);
}
throw new McpError(
ErrorCode.InternalError,
`APRS Error: ${error.message}`
);
}
throw new McpError(
ErrorCode.InternalError,
`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
});
}
private setupPromptHandlers() {
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
{
name: 'set-api-key',
description: 'Set the APRS.fi API key for this session',
arguments: [
{
name: 'api_key',
description: 'Your APRS.fi API key',
required: true,
},
],
},
],
};
});
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'set-api-key') {
const apiKey = args?.api_key as string;
if (!apiKey) {
throw new McpError(
ErrorCode.InvalidParams,
'API key is required'
);
}
this.aprsService.setApiKey(apiKey);
return {
description: 'Set APRS.fi API key',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `APRS.fi API key has been set successfully. You can now use APRS tools without specifying the apiKey parameter.`,
},
},
],
};
}
throw new McpError(
ErrorCode.InvalidParams,
`Unknown prompt: ${name}`
);
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('APRS.fi MCP server running on stdio');
}
}
const server = new APRSMCPServer();
server.run().catch(console.error);