name: mg-connector description: Use when the user asks to add a connector, create a connector, implement a connector for a service, add a tool to a connector, update a connector tool, or asks about connector architecture. Trigger phrases include "add connector", "create connector", "new connector", "implement connector", "add tool to connector", "update connector tool", "connector architecture".
Connector Development Skill
Guide for creating, extending, and maintaining connector modules in the ModelGuide platform. Connectors integrate external services (e-commerce, helpdesk, calendars) into the platform, exposing their capabilities as tools that AI agents invoke via MCP.
When NOT to use this skill
Before designing a full TypeScript connector module, check whether a mocked connector (ADR-013) is enough. Use the mocked path instead when:
- The backend doesn't exist (demo, sales deck, POC, dry-run call).
- Tool responses can be static fixtures — no conditional logic, no response trimming, no network call.
- The connector is only meant to drive an agent evaluation / simulation flow with coherent fake data.
In those cases, skip this skill entirely. Go straight to connectors.yaml with isMocked: true and inline tool definitions (see the mg-cli or build-agent skills — both have templates). The CLI upserts a connectors_catalog entry and inserts connector_tools rows with mock_response populated; executeTool() falls back to those payloads at runtime. No code, no deploy.
Use this skill when the connector must call a real backend at runtime.
Architecture Overview
The 3-File Module Pattern
Every connector lives at modelguide-api/src/features/connectors/catalog/{slug}/ and consists of exactly three files:
| File | Purpose |
|---|---|
client.ts |
HTTP client factory bound to connector config. Custom error class + typed fetcher. |
handlers.ts |
Tool execution functions. Each handler is wrapped in an error-handling HOF. |
index.ts |
Manifest: connector metadata + tool definitions array. Default export is ConnectorManifest. |
System Flow
code manifest → registry.ts → sync.ts → DB connectors_catalog
↓
org creates connector instance (with config/secrets)
↓
AI agent calls tool via MCP → handler executes with resolved config
Key Type Contracts
All types are in modelguide-api/src/features/connectors/catalog/types.ts:
interface ToolExecutionContext {
config: Record<string, string>; // resolved config (secrets decrypted)
input: Record<string, unknown>; // validated tool input from MCP call
organizationId: string;
connectorId: string;
}
interface ToolExecutionResult {
success: boolean;
data?: Record<string, unknown>;
error?: string;
}
interface ConnectorToolDefinition {
catalog: CatalogTool; // metadata stored in DB
handler: (ctx: ToolExecutionContext) => Promise<ToolExecutionResult>;
}
interface ConfigFieldSchema {
type: "string" | "secret" | "number" | "boolean";
required: boolean;
description: string;
default?: string | number | boolean;
}
type ConnectorType = "api" | "webhook" | "database" | "messaging";
interface ConnectorManifest {
name: string; // Human-readable, e.g. "Medusa"
slug: string; // URL-safe unique ID, e.g. "medusa"
description: string;
connectorType: ConnectorType;
configSchema: Record<string, ConfigFieldSchema>;
authMethods: string[]; // e.g. ["api_key"], ["oauth2"], ["bearer_token"]
iconUrl: string;
tools: ConnectorToolDefinition[];
}
CatalogTool (from @db/schema/core):
interface CatalogTool {
name: string; // Human-readable, e.g. "List Products"
description: string;
inputSchema: Record<string, unknown>; // JSON Schema
defaultRequiresConfirmation: boolean;
defaultTimeoutSeconds: number;
}
Creating a New Connector
Step 0: Gather Requirements
If .modelguide/CONNECTOR_HANDOFF.md exists, read it first and use it as the
source of truth for the requested service, auth, operations, and requested org
connector slug. Only ask follow-up questions for missing details.
Ask the user for:
- Service name and slug (lowercase, alphanumeric + hyphens)
- Description of what the connector does
- Connector type:
api|webhook|database|messaging - Config fields: what the connector needs (base URL, API key, etc.)
- Auth method:
api_key,oauth2,bearer_token, etc. - Initial tools: list of operations to expose
Build-Agent Handoff Contract
When this skill is invoked from /build-agent, update
.modelguide/CONNECTOR_HANDOFF.md in place instead of returning the result only
in prose.
Do not delete the original request fields. Preserve serviceName,
serviceSlug, requestedConnectorSlug, authModel, baseUrl, and
operations so build-agent can resume without losing the builder's API summary.
Minimum required fields on completion:
status: completedorstatus: blockedcatalogSlug— the new catalog connector slugconnectorSlug— the org connector instance slug to use inagents.yamlandsops.yamltoolSlugs— exact tool slugs exposed by the connectorconfigFields— objects withname,description, andrequiredfor each non-secret field thatconnectors.yamlmust providesecretFields— objects withfield,name, andtypefor each secret thatmg setupwill prompt forchangedFiles— exact repo file paths you modifiedverification— commands run, or(pending)if verification was skippedblocker— only whenstatus: blocked
Important: catalogSlug and connectorSlug are different. catalogSlug
identifies the connector type in the global catalog. connectorSlug is the org
instance slug that becomes the MCP prefix at runtime. serviceSlug is only the
interview-time service identifier and must not be used as either of those final
slugs.
Step 1: Create client.ts
// modelguide-api/src/features/connectors/catalog/{slug}/client.ts
interface {Name}FetchOptions {
method?: string;
body?: Record<string, unknown>;
params?: Record<string, string | number | undefined>;
}
export class {Name}ApiError extends Error {
constructor(
public status: number,
public body: string,
) {
super(`{Name} API error ${status}: ${body}`);
this.name = "{Name}ApiError";
}
}
export type {Name}Fetcher = <T = unknown>(
path: string,
options?: {Name}FetchOptions,
) => Promise<T>;
export function create{Name}Fetcher(
config: Record<string, string>,
): {Name}Fetcher {
const baseUrl = config.baseUrl?.replace(/\/+$/, "");
if (!baseUrl) {
throw new Error("{Name} baseUrl is required");
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
// Add auth headers based on the service's auth method
if (config.apiKey) {
headers["Authorization"] = `Bearer ${config.apiKey}`;
}
return async function fetch<T = unknown>(
path: string,
options?: {Name}FetchOptions,
): Promise<T> {
const { method = "GET", body, params } = options ?? {};
let url = `${baseUrl}${path}`;
if (params) {
const entries = Object.entries(params).filter(([, v]) => v !== undefined);
if (entries.length > 0) {
const qs = new URLSearchParams(entries.map(([k, v]) => [k, String(v)]));
url += `?${qs}`;
}
}
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const text = await response.text();
throw new {Name}ApiError(response.status, text);
}
return response.json() as Promise<T>;
};
}
Important: The inner function name must NOT shadow globalThis.fetch. Use a distinct name like {slug}Fetch (e.g. medusaFetch).
Step 2: Create handlers.ts
// modelguide-api/src/features/connectors/catalog/{slug}/handlers.ts
import type { ToolExecutionContext, ToolExecutionResult } from "../types";
import {
{Name}ApiError,
type {Name}Fetcher,
create{Name}Fetcher,
} from "./client";
function errorResult(err: unknown): ToolExecutionResult {
if (err instanceof {Name}ApiError) {
return { success: false, error: `{Name} API ${err.status}: ${err.body}` };
}
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
/**
* Wraps a handler so every tool gets a fetcher and consistent error handling.
*/
function with{Name}(
fn: (
fetcher: {Name}Fetcher,
ctx: ToolExecutionContext,
) => Promise<ToolExecutionResult>,
): (ctx: ToolExecutionContext) => Promise<ToolExecutionResult> {
return async function handler(ctx) {
try {
const fetcher = create{Name}Fetcher(ctx.config);
return await fn(fetcher, ctx);
} catch (err) {
return errorResult(err);
}
};
}
// Export one handler per tool:
export const listSomething = with{Name}(async (fetcher, ctx) => {
const input = ctx.input as { /* typed input */ };
const data = await fetcher<Record<string, unknown>>("/api/endpoint", {
params: { /* query params */ },
});
return { success: true, data };
});
Rules:
- Never throw from handlers — always return
{ success: false, error }via the wrapper - Type-cast
ctx.inputto the expected shape matching the tool'sinputSchema - Use the
with{Name}()wrapper for every exported handler
Step 3: Create index.ts
// modelguide-api/src/features/connectors/catalog/{slug}/index.ts
import type { ConnectorManifest, ConnectorToolDefinition } from "../types";
import { listSomething } from "./handlers";
const tools: ConnectorToolDefinition[] = [
{
catalog: {
name: "List Something", // Human-readable
description: "Description of what this tool does",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
limit: { type: "integer", description: "Max results", minimum: 1, maximum: 100 },
},
required: [],
},
defaultRequiresConfirmation: false, // true for side effects
defaultTimeoutSeconds: 30, // 30 for reads, 60 for writes
},
handler: listSomething,
},
];
const {slug}Manifest: ConnectorManifest = {
name: "{Display Name}",
slug: "{slug}",
description: "...",
connectorType: "api",
configSchema: {
baseUrl: {
type: "string",
required: true,
description: "API base URL",
},
apiKey: {
type: "secret", // encrypted in secrets table
required: true,
description: "API key for authentication",
},
},
authMethods: ["api_key"],
iconUrl: "https://example.com/icon.svg",
tools,
};
export default {slug}Manifest;
Step 4: Register in Registry
Add the new connector import to modelguide-api/src/features/connectors/catalog/registry.ts:
export async function loadAllManifests(): Promise<ConnectorManifest[]> {
const modules = await Promise.all([
import("./medusa/index"),
import("./{slug}/index"), // ← add new import here
]);
// ...
}
Step 5: Create Unit Tests
Create modelguide-api/tests/unit/connectors/{slug}-handlers.test.ts:
import { afterAll, describe, expect, mock, test } from "bun:test";
import {
listSomething,
} from "@features/connectors/catalog/{slug}/handlers";
import type { ToolExecutionContext } from "@features/connectors/catalog/types";
const BASE_CONFIG: Record<string, string> = {
baseUrl: "https://api.test-service.com",
apiKey: "test_key_123",
};
function makeCtx(
input: Record<string, unknown> = {},
config = BASE_CONFIG,
): ToolExecutionContext {
return {
config,
input,
organizationId: "org-1",
connectorId: "conn-1",
};
}
const originalFetch = globalThis.fetch;
let fetchMock: ReturnType<typeof mock>;
function mockFetchSuccess(responseData: Record<string, unknown>) {
fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify(responseData), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
globalThis.fetch = fetchMock as typeof fetch;
}
function mockFetchError(status: number, body: string) {
fetchMock = mock(() => Promise.resolve(new Response(body, { status })));
globalThis.fetch = fetchMock as typeof fetch;
}
afterAll(() => {
globalThis.fetch = originalFetch;
});
describe("{Name} handlers", () => {
describe("listSomething", () => {
test("calls GET /api/endpoint", async () => {
mockFetchSuccess({ items: [] });
const result = await listSomething(makeCtx());
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/endpoint");
expect(opts.method).toBe("GET");
});
});
describe("error handling", () => {
test("returns error on API 404", async () => {
mockFetchError(404, '{"message":"Not found"}');
const result = await listSomething(makeCtx({ id: "bad" }));
expect(result.success).toBe(false);
expect(result.error).toContain("404");
});
test("returns error when baseUrl is missing", async () => {
const result = await listSomething(makeCtx({}, { apiKey: "key" }));
expect(result.success).toBe(false);
expect(result.error).toContain("baseUrl");
});
test("returns error on network failure", async () => {
fetchMock = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = fetchMock as typeof fetch;
const result = await listSomething(makeCtx());
expect(result.success).toBe(false);
expect(result.error).toContain("Network error");
});
});
});
Step 6: Verify
make api-typecheck # must pass
make api-test-unit # must pass
Step 7: Sync to Database
cd modelguide-api && bun run src/features/connectors/catalog/sync.ts
Adding Tools to an Existing Connector
- Read the existing manifest (
index.ts) and handlers (handlers.ts) to understand current tools - Add handler in
handlers.tsusing the existingwith{Name}()wrapper - Add tool definition to the
toolsarray inindex.tswith fullcatalogmetadata - Add tests for the new handler in the existing test file
- Run
make api-typecheck && make api-test-unit - Sync to update the DB catalog
Modifying Existing Tools
- Read the current tool definition in
index.tsand its handler inhandlers.ts - Update
inputSchemaif changing inputs (JSON Schema format) - Update handler logic in
handlers.ts - Update
defaultRequiresConfirmation/defaultTimeoutSecondsif behavior changed - Update tests in the corresponding test file
- Run
make api-typecheck && make api-test-unit - Sync to push changes to the DB
Standards & Conventions
Naming
- Tool names: Human-readable in
catalog.name(e.g. "List Products") - Tool slugs: Auto-derived from name as snake_case (e.g. "list_products")
- MCP tool names:
{connectorSlug}_{toolSlug}(e.g. "glowbox_store_list_products")
Error Handling
- Always use the
with{Name}()HOF wrapper — never throw from handlers - Return
{ success: false, error: "message" }on failure - The wrapper catches exceptions from the client and formats them consistently
Input Schemas
- JSON Schema format with
type,properties,required - Add
descriptionon every property - Use
minimum/maximumfor numeric bounds - Nested objects are supported (see Medusa's
setDeliveryAddress)
Config Schema
type: "string"— plain text config (base URLs, region codes)type: "secret"— encrypted in thesecretstable, resolved at runtimetype: "number"— numeric configtype: "boolean"— feature flags
Confirmation & Timeouts
defaultRequiresConfirmation: truefor side effects: orders, payments, deletions, state mutationsdefaultRequiresConfirmation: falsefor read-only operationsdefaultTimeoutSeconds: 30for readsdefaultTimeoutSeconds: 60for writes and complex operations
Client Pattern
- Factory function scoped to config:
create{Name}Fetcher(config) - Custom error class:
{Name}ApiErrorwithstatusandbody - Consistent headers (Content-Type, Accept, auth)
- Strip trailing slashes from base URL
- Inner fetch function name must NOT shadow
globalThis.fetch
Key Reference Files
| Purpose | Path |
|---|---|
| Type contracts | modelguide-api/src/features/connectors/catalog/types.ts |
| Registry | modelguide-api/src/features/connectors/catalog/registry.ts |
| Sync script | modelguide-api/src/features/connectors/catalog/sync.ts |
| DB schema (CatalogTool) | modelguide-api/src/db/schema/core.ts |
| Medusa manifest | modelguide-api/src/features/connectors/catalog/medusa/index.ts |
| Medusa handlers | modelguide-api/src/features/connectors/catalog/medusa/handlers.ts |
| Medusa client | modelguide-api/src/features/connectors/catalog/medusa/client.ts |
| Medusa tests | modelguide-api/tests/unit/connectors/medusa-handlers.test.ts |
| Connector service | modelguide-api/src/features/connectors/connectors.service.ts |
| MCP handler | modelguide-api/src/features/mcp/mcp.handler.ts |
| MCP service | modelguide-api/src/features/mcp/mcp.service.ts |