mg-connector

star 101

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".

modelguide By modelguide schedule Updated 4/19/2026

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: completed or status: blocked
  • catalogSlug — the new catalog connector slug
  • connectorSlug — the org connector instance slug to use in agents.yaml and sops.yaml
  • toolSlugs — exact tool slugs exposed by the connector
  • configFields — objects with name, description, and required for each non-secret field that connectors.yaml must provide
  • secretFields — objects with field, name, and type for each secret that mg setup will prompt for
  • changedFiles — exact repo file paths you modified
  • verification — commands run, or (pending) if verification was skipped
  • blocker — only when status: 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.input to the expected shape matching the tool's inputSchema
  • 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

  1. Read the existing manifest (index.ts) and handlers (handlers.ts) to understand current tools
  2. Add handler in handlers.ts using the existing with{Name}() wrapper
  3. Add tool definition to the tools array in index.ts with full catalog metadata
  4. Add tests for the new handler in the existing test file
  5. Run make api-typecheck && make api-test-unit
  6. Sync to update the DB catalog

Modifying Existing Tools

  1. Read the current tool definition in index.ts and its handler in handlers.ts
  2. Update inputSchema if changing inputs (JSON Schema format)
  3. Update handler logic in handlers.ts
  4. Update defaultRequiresConfirmation / defaultTimeoutSeconds if behavior changed
  5. Update tests in the corresponding test file
  6. Run make api-typecheck && make api-test-unit
  7. 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 description on every property
  • Use minimum/maximum for 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 the secrets table, resolved at runtime
  • type: "number" — numeric config
  • type: "boolean" — feature flags

Confirmation & Timeouts

  • defaultRequiresConfirmation: true for side effects: orders, payments, deletions, state mutations
  • defaultRequiresConfirmation: false for read-only operations
  • defaultTimeoutSeconds: 30 for reads
  • defaultTimeoutSeconds: 60 for writes and complex operations

Client Pattern

  • Factory function scoped to config: create{Name}Fetcher(config)
  • Custom error class: {Name}ApiError with status and body
  • 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
Install via CLI
npx skills add https://github.com/modelguide/modelguide --skill mg-connector
Repository Details
star Stars 101
call_split Forks 4
navigation Branch main
article Path SKILL.md
More from Creator