feat: initial MCP server template with placeholder-driven scaffolding

Template repository for creating MokoStandards-compliant MCP servers.
Includes 4-file src/ structure (index, client, config, types), setup
wizard, example tools, 12 CI/CD workflows, full docs, and {{placeholder}}
tokens for search-and-replace customization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-07 14:39:19 -05:00
commit 91b78f8da1
30 changed files with 7811 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: {{PROJECT_NAME}}.Client
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/client.ts
* VERSION: 01.00.00
* BRIEF: HTTP client for {{DISPLAY_NAME}} API
*/
import * as https from 'node:https';
import * as http from 'node:http';
import type { ApiConnection, ApiResponse } from './types.js';
// ── Customize these ─────────────────────────────────────────────────────
// API path prefix appended to baseUrl (e.g. "/api/index.php", "/api/v1")
const API_PREFIX = '/api';
const TIMEOUT_MS = 30_000;
// ────────────────────────────────────────────────────────────────────────
export class ApiClient {
private readonly base_url: string;
private readonly headers: Record<string, string>;
private readonly insecure: boolean;
constructor(conn: ApiConnection) {
this.base_url = conn.baseUrl.replace(/\/+$/, '') + API_PREFIX;
// ── Customize auth headers ──────────────────────────────────
// Examples:
// Bearer token: { 'Authorization': `Bearer ${conn.apiKey}` }
// API key header: { 'DOLAPIKEY': conn.apiKey }
// Basic auth: { 'Authorization': `Basic ${btoa(user + ':' + pass)}` }
this.headers = {
'Authorization': `Bearer ${conn.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
this.insecure = conn.insecure ?? false;
}
async get(endpoint: string, params?: Record<string, string>): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint, params), 'GET');
}
async post(endpoint: string, body?: unknown): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'POST', body);
}
async put(endpoint: string, body: unknown): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'PUT', body);
}
async patch(endpoint: string, body: unknown): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'PATCH', body);
}
async delete(endpoint: string): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'DELETE');
}
private buildUrl(endpoint: string, params?: Record<string, string>): string {
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = new URL(`${this.base_url}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
private request(url: string, method: string, body?: unknown): Promise<ApiResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const is_https = parsed.protocol === 'https:';
const transport = is_https ? https : http;
const options: https.RequestOptions = {
hostname: parsed.hostname,
port: parsed.port || (is_https ? 443 : 80),
path: parsed.pathname + parsed.search,
method,
headers: { ...this.headers },
timeout: TIMEOUT_MS,
};
if (this.insecure && is_https) {
options.rejectUnauthorized = false;
}
const payload = body !== undefined ? JSON.stringify(body) : undefined;
if (payload) {
(options.headers as Record<string, string>)['Content-Length'] = Buffer.byteLength(payload).toString();
}
const req = transport.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
resolve({ status: res.statusCode ?? 0, data });
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
if (payload) {
req.write(payload);
}
req.end();
});
}
}
+63
View File
@@ -0,0 +1,63 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: {{PROJECT_NAME}}.Config
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/config.ts
* VERSION: 01.00.00
* BRIEF: Configuration loader for {{DISPLAY_NAME}} MCP connections
*/
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
import type { ApiConfig, ApiConnection } from './types.js';
// ── Customize this ──────────────────────────────────────────────────────
// Change the filename to match your project (e.g. ".dolibarr-api-mcp.json")
const CONFIG_FILENAME = '.{{PROJECT_NAME}}.json';
// Change the env var name to match your project
const CONFIG_ENV_VAR = '{{ENV_PREFIX}}_CONFIG';
// ────────────────────────────────────────────────────────────────────────
export async function loadConfig(): Promise<ApiConfig> {
const config_path = process.env[CONFIG_ENV_VAR]
? resolve(process.env[CONFIG_ENV_VAR]!)
: resolve(homedir(), CONFIG_FILENAME);
try {
const raw = await readFile(config_path, 'utf-8');
const parsed = JSON.parse(raw) as Partial<ApiConfig>;
if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
throw new Error('No connections defined in config');
}
return {
connections: parsed.connections,
defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to load config from ${config_path}: ${message}\n` +
`Create ${config_path} — see config.example.json for format.`,
);
}
}
export function getConnection(config: ApiConfig, name?: string): ApiConnection {
const key = name ?? config.defaultConnection;
const conn = config.connections[key];
if (!conn) {
throw new Error(
`Connection "${key}" not found. Available: ${Object.keys(config.connections).join(', ')}`,
);
}
return conn;
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: {{PROJECT_NAME}}.Server
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/index.ts
* VERSION: 01.00.00
* BRIEF: MCP server entry point — registers all {{DISPLAY_NAME}} API tools
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { loadConfig, getConnection } from './config.js';
import { ApiClient } from './client.js';
import type { ApiConfig, ApiResponse } from './types.js';
let config: ApiConfig;
function clientFor(connection?: string): ApiClient {
return new ApiClient(getConnection(config, connection));
}
function formatResponse(res: ApiResponse): { content: Array<{ type: 'text'; text: string }> } {
if (res.status >= 400) {
return {
content: [{ type: 'text' as const, text: `Error: HTTP ${res.status}: ${JSON.stringify(res.data, null, 2)}` }],
};
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(res.data, null, 2) }],
};
}
// ── Shared parameter definitions ────────────────────────────────────────
const ConnectionParam = {
connection: z.string().optional().describe('Named connection from config (uses default if omitted)'),
};
const PaginationParams = {
limit: z.number().optional().describe('Max results'),
page: z.number().optional().describe('Page number (0-based)'),
};
function paginationQuery(params: { limit?: number; page?: number }): Record<string, string> {
const q: Record<string, string> = {};
if (params.limit !== undefined) q['limit'] = String(params.limit);
if (params.page !== undefined) q['page'] = String(params.page);
return q;
}
// ── Server ──────────────────────────────────────────────────────────────
const server = new McpServer({
name: '{{PROJECT_NAME}}',
version: '1.0.0',
});
// ════════════════════════════════════════════════════════════════════════
// ADD YOUR TOOLS BELOW
// ════════════════════════════════════════════════════════════════════════
//
// Follow this pattern for each tool:
//
// server.tool(
// 'prefix_resource_action', // tool name (snake_case)
// 'Human-readable description', // shown to the AI assistant
// { // Zod schema for parameters
// id: z.number().describe('Resource ID'),
// ...ConnectionParam,
// },
// async ({ id, connection }) => {
// const client = clientFor(connection);
// return formatResponse(await client.get(`/resources/${id}`));
// },
// );
//
// Tips:
// - Group tools by resource type with section comments
// - Use consistent naming: prefix_resource_list, prefix_resource_get,
// prefix_resource_create, prefix_resource_update, prefix_resource_delete
// - Spread ...ConnectionParam into every tool's schema
// - Spread ...PaginationParams into list tools
// - Use paginationQuery() to build query params for list endpoints
//
// ════════════════════════════════════════════════════════════════════════
// ── Example: Resources ──────────────────────────────────────────────────
server.tool(
'example_resources_list',
'List resources (EXAMPLE — replace with your API resources)',
{
search: z.string().optional().describe('Search query'),
...PaginationParams,
...ConnectionParam,
},
async ({ search, limit, page, connection }) => {
const client = clientFor(connection);
const params: Record<string, string> = { ...paginationQuery({ limit, page }) };
if (search) params['search'] = search;
return formatResponse(await client.get('/resources', params));
},
);
server.tool(
'example_resource_get',
'Get a single resource by ID (EXAMPLE — replace with your API resources)',
{
id: z.number().describe('Resource ID'),
...ConnectionParam,
},
async ({ id, connection }) => {
const client = clientFor(connection);
return formatResponse(await client.get(`/resources/${id}`));
},
);
server.tool(
'example_resource_create',
'Create a new resource (EXAMPLE — replace with your API resources)',
{
name: z.string().describe('Resource name'),
description: z.string().optional().describe('Resource description'),
...ConnectionParam,
},
async ({ name, description, connection }) => {
const client = clientFor(connection);
const body: Record<string, unknown> = { name };
if (description) body.description = description;
return formatResponse(await client.post('/resources', body));
},
);
// ── Generic API Call ────────────────────────────────────────────────────
server.tool(
'api_request',
'Make a raw API request to any endpoint',
{
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
endpoint: z.string().describe('API endpoint path (e.g. "/resources")'),
body: z.record(z.string(), z.unknown()).optional().describe('Request body for POST/PUT/PATCH'),
params: z.record(z.string(), z.string()).optional().describe('Query parameters'),
...ConnectionParam,
},
async ({ method, endpoint, body, params, connection }) => {
const client = clientFor(connection);
switch (method) {
case 'GET':
return formatResponse(await client.get(endpoint, params));
case 'POST':
return formatResponse(await client.post(endpoint, body));
case 'PUT':
return formatResponse(await client.put(endpoint, body));
case 'PATCH':
return formatResponse(await client.patch(endpoint, body));
case 'DELETE':
return formatResponse(await client.delete(endpoint));
}
},
);
// ── Connections Management ──────────────────────────────────────────────
server.tool(
'list_connections',
'List configured API connections',
{},
async () => {
const lines = Object.entries(config.connections).map(([name, conn]) => {
const is_default = name === config.defaultConnection ? ' (default)' : '';
return ` ${name}${is_default}: ${conn.baseUrl}`;
});
return {
content: [{ type: 'text' as const, text: `Configured connections:\n${lines.join('\n')}` }],
};
},
);
// ── Start Server ────────────────────────────────────────────────────────
async function main(): Promise<void> {
config = await loadConfig();
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
process.stderr.write(`Fatal: ${err}\n`);
process.exit(1);
});
+48
View File
@@ -0,0 +1,48 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: {{PROJECT_NAME}}.Types
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/types.ts
* VERSION: 01.00.00
* BRIEF: TypeScript type definitions for {{DISPLAY_NAME}} MCP server
*/
/**
* Connection configuration for a single API instance.
*
* Rename and extend these fields to match your target API's auth mechanism:
* - `apiKey` + DOLAPIKEY header (Dolibarr)
* - `apiToken` + Bearer header (Joomla, GitHub)
* - `username`/`password` (Basic auth)
* - `oauth` fields (OAuth2 flows)
*/
export interface ApiConnection {
/** Base URL of the API instance (no trailing slash) */
baseUrl: string;
/** API key or token for authentication */
apiKey: string;
/** Skip TLS certificate verification (self-signed certs) */
insecure?: boolean;
}
/**
* Top-level configuration supporting multiple named connections.
*/
export interface ApiConfig {
connections: Record<string, ApiConnection>;
defaultConnection: string;
}
/**
* Normalized API response returned by the HTTP client.
*/
export interface ApiResponse {
status: number;
data: unknown;
}