Build a Connector MCP Server with ADK
Wrap API connectors as MCP tools and demo them with an ADK agent.
Introduction
30 Second Summary
Businesses run on multiple platforms. ServiceTitan for field service, QuickBooks for accounting, and more. AI agents need a standard way to access all of them without custom code for every client.
In this project, you will build an MCP server that wraps your connector library into standard tools, then demo it with a Google ADK agent that calls your connectors through natural language.
What You'll Build
A two-layer system: an MCP server that exposes your connector operations as standard tools callable by any MCP client, and an ADK demo agent that connects to that server for interactive showcases.
By the end of this project, you'll have:
- A Connector interface and registry that manages your library of integrations, with ServiceTitan live and QuickBooks mocked.
- An MCP server that dynamically registers tools from all connectors, ready to be called by any MCP client.
- An ADK demo agent using MCPToolset to connect to your MCP server, letting you showcase connectors through natural language in the ADK dev UI.
- Secret Mission: Deploy your MCP server as a Streamable HTTP endpoint so it's ready for remote access from Gemini Enterprise agents.
Are there any prerequisites?
You should have a working ServiceTitan connector, a GCP account with Gemini API access, and familiarity with TypeScript. You'll also need Node.js 24.13+ installed on your machine.
Set Up the Project
Before you can build the MCP server and ADK agent, you need a properly structured TypeScript project with all the right dependencies.
In this step, you'll initialize two linked packages. The root package holds the MCP server and connector code. A nested agent/ package holds the ADK agent with its own dependencies.
In this step, get ready to:
- Initialize the project as an ES module and install MCP server dependencies.
- Configure environment variables for the Gemini API and ServiceTitan credentials.
- Set up the directory structure and agent subdirectory.
Initialize the project and install dependencies
Your project needs a root package.json configured as an ES module. This file declares the MCP server SDK and Zod (for schema validation) as dependencies.
- Create a file called package.json in your project folder with the following content:
{
"name": "connector-mcp-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"mcp-server": "npx tsx mcp-server/index.ts",
"demo": "cd agent && npx adk web"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0-alpha.3",
"zod": "^3.25.0"
},
"devDependencies": {
"tsx": "^4.19.0"
}
}
What does this file do?
This package.json sets "type": "module" so Node.js treats all .ts and .js files as ES modules (enabling import syntax).
The two scripts give you shortcuts: mcp-server runs your MCP server via tsx (a TypeScript executor), and demo launches the ADK dev UI from the agent subdirectory.
- Install the dependencies by running:
npm install
- Confirm the install succeeded by running:
ls node_modules
You should see folders including @modelcontextprotocol and zod in the output.
Seeing install errors?
Make sure you're running Node.js 24.13.0+ by checking node --version. The MCP SDK v2 alpha requires a recent Node.js version.
If you see permission errors on macOS, try prefixing with sudo or fix your npm permissions. Help me fix npm install errors
Configure your environment variables
Your MCP server needs API credentials to authenticate with ServiceTitan. The ADK agent needs a Gemini API key to power its language model. You'll store both in .env files that stay out of version control.
- Create a file called .env in your project root with the following content:
GEMINI_API_KEY=your-gemini-api-key-here
SERVICETITAN_CLIENT_ID=your-client-id-here
SERVICETITAN_CLIENT_SECRET=your-client-secret-here
SERVICETITAN_TENANT_ID=your-tenant-id-here
SERVICETITAN_APP_KEY=your-app-key-here
- Replace each placeholder with your real credentials from the Gemini API console and your ServiceTitan developer portal.
Where do I find these credentials?
Your GEMINI_API_KEY comes from the Google AI Studio (aistudio.google.com). Create one under API keys if you don't have one yet.
The ServiceTitan credentials (CLIENT_ID, CLIENT_SECRET, TENANT_ID, APP_KEY) come from your existing ServiceTitan connector setup. These are the same credentials your connector already uses.
Don't have ServiceTitan credentials?
If you haven't set up your ServiceTitan connector yet, you can still proceed. The mock QuickBooks connector (added in a later step) works without any credentials, so you can demo the full architecture without live ServiceTitan access.
Help me figure out which credentials I need
Set up the directory structure
Your project splits into three directories. connectors/ holds your connector implementations. mcp-server/ holds the MCP server. agent/ holds the ADK demo agent as a separate npm package.
- Create the three directories by running:
mkdir connectors mcp-server agent
The agent/ directory needs its own package.json because ADK expects to run inside a standalone package with "main": "agent.ts" declared.
- Create agent/package.json with the following content:
{
"name": "connector-demo-agent",
"version": "1.0.0",
"type": "module",
"main": "agent.ts",
"scripts": {
"dev": "npx adk web",
"start": "npx adk run agent.ts"
},
"dependencies": {
"@google/adk": "^0.6.0"
},
"devDependencies": {
"@google/adk-devtools": "^0.6.0"
}
}
Why a separate package?
The npx adk web command looks for a package.json with a main field pointing to your agent file. Keeping it in a subdirectory also cleanly separates the demo layer (ADK agent) from the production artifact (MCP server).
- Install the agent dependencies by running:
cd agent && npm install
- Move back to the project root by running:
cd ..
The agent also needs the same environment variables to access the Gemini API and your connectors.
- Create agent/.env with the same content as your root .env:
GEMINI_API_KEY=your-gemini-api-key-here
SERVICETITAN_CLIENT_ID=your-client-id-here
SERVICETITAN_CLIENT_SECRET=your-client-secret-here
SERVICETITAN_TENANT_ID=your-tenant-id-here
SERVICETITAN_APP_KEY=your-app-key-here
- Use the same real credentials you entered in the root .env file.
- Verify your project structure by running:
ls
You should see agent, connectors, mcp-server, node_modules, package.json, and .env in the output.
Missing directories or packages?
If agent/node_modules is missing, make sure you ran npm install from inside the agent/ directory, not the root.
If ls doesn't show .env, that's expected. Dotfiles are hidden by default. Run ls -a to see them. Help me verify my project structure is correct
✔️ Awesome, I've got everything!
Great. Double check you've saved both .env files with your real credentials before moving on.
ⓧ I'd like to double check the full code
Here's what each file should look like at this point:
{
"name": "connector-mcp-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"mcp-server": "npx tsx mcp-server/index.ts",
"demo": "cd agent && npx adk web"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0-alpha.3",
"zod": "^3.25.0"
},
"devDependencies": {
"tsx": "^4.19.0"
}
}
{
"name": "connector-demo-agent",
"version": "1.0.0",
"type": "module",
"main": "agent.ts",
"scripts": {
"dev": "npx adk web",
"start": "npx adk run agent.ts"
},
"dependencies": {
"@google/adk": "^0.6.0"
},
"devDependencies": {
"@google/adk-devtools": "^0.6.0"
}
}
GEMINI_API_KEY=your-gemini-api-key-here
SERVICETITAN_CLIENT_ID=your-client-id-here
SERVICETITAN_CLIENT_SECRET=your-client-secret-here
SERVICETITAN_TENANT_ID=your-tenant-id-here
SERVICETITAN_APP_KEY=your-app-key-here
GEMINI_API_KEY=your-gemini-api-key-here
SERVICETITAN_CLIENT_ID=your-client-id-here
SERVICETITAN_CLIENT_SECRET=your-client-secret-here
SERVICETITAN_TENANT_ID=your-tenant-id-here
SERVICETITAN_APP_KEY=your-app-key-here
Your project skeleton is ready. Next up, you'll define the Connector interface and registry that turns your integrations into a pluggable library.
Define the Connector Interface
Your project structure is set up and dependencies are installed. Now it's time to define what a "connector" actually looks like in code.
Your MCP server will need to turn connector operations into standard tools. For that to work, every connector needs to follow the same shape. In this step, you'll define TypeScript interfaces for your connector system, then wrap your existing ServiceTitan code to fit that interface.
In this step, get ready to:
- Define the Connector and Operation TypeScript interfaces.
- Build a ConnectorRegistry class to manage all connectors.
- Wrap your ServiceTitan connector to implement the interface.
Create the Connector and Operation interfaces
Every connector in your library will need a name, a description, a way to authenticate, and a list of operations it provides. Each operation needs its own name, description, parameter schema, and execution function. Defining these as TypeScript interfaces creates a contract that all connectors must follow.
- In your Claude Code session, give it this prompt to create the interfaces file:
Create connectors/types.ts with the following TypeScript interfaces:
1. An Operation interface with:
- name: string
- description: string
- parameters: z.ZodObject<any> (import z from 'zod')
- execute: a function taking Record<string, unknown> and returning Promise<unknown>
2. A Connector interface with:
- name: string
- description: string
- authenticate(): Promise<void>
- getOperations(): Operation[]
Export both interfaces.
What does this code do?
The Operation interface defines a single action a connector can perform. It uses a zod schema for parameters so the MCP server can later convert it directly into an MCP input schema.
The Connector interface defines the contract every integration must follow. The authenticate() method handles credentials, and getOperations() returns all available actions.
- Verify that Claude Code created connectors/types.ts with both the Operation and Connector interfaces exported.
File not created?
If Claude Code asks for permission to create files, approve the request. Make sure your Claude Code session is running from the connector-mcp-server project root so it creates the file in the right location.
ask
Build the ConnectorRegistry class
A single connector is useful, but you need a way to manage multiple connectors and aggregate all their operations. The registry pattern gives the MCP server one place to look up every available operation across all connectors.
- Give Claude Code this prompt to add the registry class to the same file:
In connectors/types.ts, add a ConnectorRegistry class below the interfaces. It should have:
- A private connectors Map<string, Connector>
- register(connector: Connector): void - stores by connector.name
- get(name: string): Connector | undefined - lookup by name
- getAll(): Connector[] - returns all registered connectors
- getAllOperations(): { connector: string; operation: Operation }[] - aggregates operations from ALL connectors into a flat array with the connector name attached
Export the class.
What does the registry do?
The ConnectorRegistry is the central hub that the MCP server will query. When the MCP server starts up, it calls getAllOperations() to get a flat list of every operation from every registered connector. Each entry includes the connector name, so tools get namespaced automatically.
- Open connectors/types.ts and confirm it now contains the Operation interface, Connector interface, and ConnectorRegistry class.
Registry missing methods?
If getAllOperations() is missing or returns a different shape, ask Claude Code to fix it. The method must return an array of objects with connector (string) and operation (Operation) fields.
help me fix my ConnectorRegistry
✔️ Awesome, I've got everything!
Great. Make sure you've saved connectors/types.ts before moving on.
ⓧ I'd like to double check the full code
Your connectors/types.ts should look like this:
import { z } from 'zod';
export interface Operation {
name: string;
description: string;
parameters: z.ZodObject<any>;
execute: (input: Record<string, unknown>) => Promise<unknown>;
}
export interface Connector {
name: string;
description: string;
authenticate(): Promise<void>;
getOperations(): Operation[];
}
export class ConnectorRegistry {
private connectors: Map<string, Connector> = new Map();
register(connector: Connector): void {
this.connectors.set(connector.name, connector);
}
get(name: string): Connector | undefined {
return this.connectors.get(name);
}
getAll(): Connector[] {
return Array.from(this.connectors.values());
}
getAllOperations(): { connector: string; operation: Operation }[] {
const ops: { connector: string; operation: Operation }[] = [];
for (const connector of this.connectors.values()) {
for (const operation of connector.getOperations()) {
ops.push({ connector: connector.name, operation });
}
}
return ops;
}
}
Wrap ServiceTitan as a connector
Now you'll wrap your existing ServiceTitan integration into the Connector interface. This means implementing authenticate() with the OAuth client_credentials flow and exposing your API calls as Operation objects.
- Give Claude Code this prompt to create the ServiceTitan connector:
Create connectors/servicetitan/index.ts that implements the Connector interface from '../types.js'.
Class: ServiceTitanConnector
- name: 'servicetitan'
- description: 'ServiceTitan field service management platform. Access jobs, customers, invoices, and more.'
- private accessToken: string | null = null
- private baseUrl = 'https://api.servicetitan.io'
authenticate():
- POST to 'https://auth.servicetitan.io/connect/token'
- Content-Type: application/x-www-form-urlencoded
- Body: grant_type=client_credentials, client_id from process.env.SERVICETITAN_CLIENT_ID, client_secret from process.env.SERVICETITAN_CLIENT_SECRET
- Store the access_token
private apiCall(endpoint: string, params?: Record<string, string>):
- If no accessToken, call authenticate() first
- Build URL from baseUrl + endpoint
- Append query params if provided
- Headers: Authorization Bearer token, 'ST-App-Key' from process.env.SERVICETITAN_APP_KEY
- Return response.json()
getOperations() returns 3 operations:
1. servicetitan_list_jobs - params: status (optional string), pageSize (optional number). Calls /jpm/v2/tenant/${tenantId}/jobs
2. servicetitan_list_customers - params: name (optional string), pageSize (optional number). Calls /crm/v2/tenant/${tenantId}/customers
3. servicetitan_list_invoices - params: pageSize (optional number). Calls /accounting/v2/tenant/${tenantId}/invoices
All operations get tenantId from process.env.SERVICETITAN_TENANT_ID. Use zod for parameter schemas with .describe() on each field.
What does this connector do?
The ServiceTitanConnector wraps the ServiceTitan V2 API into the standard Connector shape. It handles OAuth token management in authenticate() and exposes three operations for listing jobs, customers, and invoices.
Each operation uses zod schemas with .describe() annotations on each parameter. These descriptions flow through to the MCP server, where they become tool parameter documentation that AI agents can read.
- Confirm that connectors/servicetitan/index.ts exists and exports the ServiceTitanConnector class.
- Verify it imports from '../types.js' and 'zod'.
Import path wrong?
The import must use '../types.js' with the .js extension. This is required for ES module resolution in Node.js. If Claude Code used '../types' without the extension, ask it to add .js to the import path.
fix my import paths
✔️ Awesome, I've got everything!
Great. Make sure you've saved connectors/servicetitan/index.ts before moving on.
ⓧ I'd like to double check the full code
Your connectors/servicetitan/index.ts should look like this:
import { z } from 'zod';
import { Connector, Operation } from '../types.js';
export class ServiceTitanConnector implements Connector {
name = 'servicetitan';
description = 'ServiceTitan field service management platform. Access jobs, customers, invoices, and more.';
private accessToken: string | null = null;
private baseUrl = 'https://api.servicetitan.io';
async authenticate(): Promise<void> {
const response = await fetch('https://auth.servicetitan.io/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.SERVICETITAN_CLIENT_ID!,
client_secret: process.env.SERVICETITAN_CLIENT_SECRET!,
}),
});
const data = await response.json() as { access_token: string };
this.accessToken = data.access_token;
}
private async apiCall(endpoint: string, params?: Record<string, string>): Promise<unknown> {
if (!this.accessToken) await this.authenticate();
const url = new URL(`${this.baseUrl}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${this.accessToken}`,
'ST-App-Key': process.env.SERVICETITAN_APP_KEY!,
},
});
return response.json();
}
getOperations(): Operation[] {
return [
{
name: 'servicetitan_list_jobs',
description: 'List recent jobs from ServiceTitan with optional status filter',
parameters: z.object({
status: z.string().optional().describe('Filter by job status (e.g., Completed, Scheduled, InProgress)'),
pageSize: z.number().optional().describe('Number of results to return (default 10)'),
}),
execute: async (input) => {
const tenantId = process.env.SERVICETITAN_TENANT_ID!;
const params: Record<string, string> = {};
if (input.status) params['status'] = input.status as string;
params['pageSize'] = String((input.pageSize as number) || 10);
return this.apiCall(`/jpm/v2/tenant/${tenantId}/jobs`, params);
},
},
{
name: 'servicetitan_list_customers',
description: 'List customers from ServiceTitan with optional search',
parameters: z.object({
name: z.string().optional().describe('Search customers by name'),
pageSize: z.number().optional().describe('Number of results to return (default 10)'),
}),
execute: async (input) => {
const tenantId = process.env.SERVICETITAN_TENANT_ID!;
const params: Record<string, string> = {};
if (input.name) params['name'] = input.name as string;
params['pageSize'] = String((input.pageSize as number) || 10);
return this.apiCall(`/crm/v2/tenant/${tenantId}/customers`, params);
},
},
{
name: 'servicetitan_list_invoices',
description: 'List invoices from ServiceTitan',
parameters: z.object({
pageSize: z.number().optional().describe('Number of results to return (default 10)'),
}),
execute: async (input) => {
const tenantId = process.env.SERVICETITAN_TENANT_ID!;
const params: Record<string, string> = {};
params['pageSize'] = String((input.pageSize as number) || 10);
return this.apiCall(`/accounting/v2/tenant/${tenantId}/invoices`, params);
},
},
];
}
}
Your connector interface is locked in and ServiceTitan is ready to go. Next up, you'll build the MCP server that reads from this registry and turns every operation into a standard MCP tool.
Build the MCP Server
You have a connector interface and a working ServiceTitan connector. The next challenge is making these tools accessible to any AI agent, not just your own code.
The Model Context Protocol (MCP) is the standard protocol that AI platforms use to discover and call tools. In this step, you will build an MCP server that wraps your connector operations into standard MCP tools callable by any MCP client.
In this step, get ready to:
- Create an MCP server that registers connector operations as tools.
- Wire up the stdio transport so the server runs as a local child process.
- Test the server and verify it exposes all your ServiceTitan tools.
Create the MCP server with tool registration
Your MCP server needs to read every operation from the connector registry and expose each one as a callable MCP tool. The MCP SDK's registerTool() method accepts a zod schema directly, which means your existing connector operation parameters plug straight in.
- Install dotenv so your MCP server can load credentials from the .env file by running:
- Create the mcp-server/ directory inside your connector-mcp-server project folder by running:
npm install dotenv
mkdir mcp-server
- Give Claude Code the following prompt to create mcp-server/index.ts with the server setup and tool registration loop:
Create mcp-server/index.ts that:
1. Imports dotenv/config first (to load .env), then McpServer from '@modelcontextprotocol/server' and StdioServerTransport from '@modelcontextprotocol/server/stdio'
2. Imports ConnectorRegistry from '../connectors/types.js' and ServiceTitanConnector from '../connectors/servicetitan/index.js'
3. Also imports QuickBooksConnector from '../connectors/quickbooks/index.js' (we'll create this later)
4. Creates a ConnectorRegistry, registers ServiceTitanConnector and QuickBooksConnector
5. Creates an McpServer with name 'connector-hub' and version '1.0.0'
6. Loops over registry.getAllOperations() and calls server.registerTool() for each operation, passing operation.name, { description, inputSchema: operation.parameters }, and an async handler that calls operation.execute(input) and returns { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }. Wrap in try/catch and set isError: true on failure.
7. Do NOT add transport or startup code yet - we'll add that next.
Claude Code should generate the imports, registry setup, server instantiation, and the tool registration loop. Verify the file matches the structure below.
import 'dotenv/config';
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { ConnectorRegistry } from '../connectors/types.js';
import { ServiceTitanConnector } from '../connectors/servicetitan/index.js';
import { QuickBooksConnector } from '../connectors/quickbooks/index.js';
// Initialize connectors
const registry = new ConnectorRegistry();
registry.register(new ServiceTitanConnector());
registry.register(new QuickBooksConnector());
// Create MCP server
const server = new McpServer({
name: 'connector-hub',
version: '1.0.0',
});
// Register each connector operation as an MCP tool
for (const { operation } of registry.getAllOperations()) {
server.registerTool(
operation.name,
{
description: operation.description,
inputSchema: operation.parameters,
},
async (input) => {
try {
const result = await operation.execute(input as Record<string, unknown>);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
isError: true,
};
}
},
);
}
What does this code do?
The server iterates every operation across all registered connectors and calls server.registerTool() for each one. This means adding a new connector automatically exposes its operations as MCP tools without any changes to the server code.
The handler wraps each operation.execute() call in a try/catch. On success, it returns the result as JSON text. On failure, it sets isError: true so the MCP client knows the tool call failed.
Why include QuickBooks before it exists?
The import for QuickBooksConnector is included now so the file shows the complete pattern. You will create the QuickBooks connector in Step 5. For testing in this step, you will temporarily comment out the QuickBooks lines.
Add the stdio transport
The MCP protocol defines stdio as its primary transport for local servers. The client launches your server as a subprocess and communicates over stdin/stdout using JSON-RPC messages.
- Give Claude Code this prompt to add the transport connection at the bottom of mcp-server/index.ts:
At the bottom of mcp-server/index.ts, add a main() function that:
1. Creates a new StdioServerTransport()
2. Calls await server.connect(transport)
3. Call main() immediately after defining it.
Add a comment '// Connect via stdio transport' above the function.
The bottom of your file should now look like this:
// Connect via stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main();
What does the stdio transport do?
When a client (like the ADK agent you will build next) spawns your MCP server as a child process, StdioServerTransport handles reading JSON-RPC requests from stdin and writing responses to stdout. Your server never needs to manage HTTP ports or network configuration.
- Save mcp-server/index.ts.
Seeing a TypeScript error about QuickBooksConnector?
The connectors/quickbooks/index.ts file does not exist yet. You will create it in Step 5. For now, the import will show an error in your editor, which is expected. You will comment it out before testing in the next substep.
If you see other errors, make sure your imports end with .js extensions. TypeScript ES modules require explicit file extensions in import paths.
Help me debug my MCP server imports.
Test the MCP server
Before testing, you need to temporarily comment out the QuickBooks lines since that connector does not exist yet.
- In mcp-server/index.ts, find this line:
import { QuickBooksConnector } from '../connectors/quickbooks/index.js';
- Comment it out by adding // at the beginning of the line.
- Also find and comment out this line:
registry.register(new QuickBooksConnector());
- Save mcp-server/index.ts.
- Test your MCP server using the MCP Inspector CLI by running:
npx @modelcontextprotocol/inspector --cli npx tsx mcp-server/index.ts --method tools/list
What does this command do?
The MCP Inspector is the official testing tool for MCP servers. The --cli flag runs it in command-line mode. It spawns your MCP server as a child process (via npx tsx mcp-server/index.ts), sends the initialize handshake, then calls tools/list and prints the results.
You should see output listing three tools: servicetitan_list_jobs, servicetitan_list_customers, and servicetitan_list_invoices.
- After verifying the tools appear, uncomment the two QuickBooks lines you commented out earlier.
- Save mcp-server/index.ts.
Not seeing any tools in the output?
Make sure you commented out both the QuickBooks import AND the registry.register(new QuickBooksConnector()) line. If either remains active, the server crashes on startup because the file does not exist.
Check that your .env file is in the project root (the same directory where package.json lives). The dotenv/config import looks for .env relative to the working directory.
Help me debug my MCP server test.
✔️ Awesome, I've got everything!
Great. Make sure you have uncommented the QuickBooks lines and saved mcp-server/index.ts.
ⓧ I'd like to double check the full code
Here is the complete mcp-server/index.ts file. The QuickBooks import and registration are included. They will resolve once you create the QuickBooks connector in Step 5.
import 'dotenv/config';
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { ConnectorRegistry } from '../connectors/types.js';
import { ServiceTitanConnector } from '../connectors/servicetitan/index.js';
import { QuickBooksConnector } from '../connectors/quickbooks/index.js';
// Initialize connectors
const registry = new ConnectorRegistry();
registry.register(new ServiceTitanConnector());
registry.register(new QuickBooksConnector());
// Create MCP server
const server = new McpServer({
name: 'connector-hub',
version: '1.0.0',
});
// Register each connector operation as an MCP tool
for (const { operation } of registry.getAllOperations()) {
server.registerTool(
operation.name,
{
description: operation.description,
inputSchema: operation.parameters,
},
async (input) => {
try {
const result = await operation.execute(input as Record<string, unknown>);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
isError: true,
};
}
},
);
}
// Connect via stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main();
Your MCP server is working. It dynamically exposes every connector operation as a standard MCP tool. Next up, you will build an ADK demo agent that connects to this server and lets you interact with your connectors through natural language.
Build the ADK Demo Agent
Your MCP server is running and exposing connector tools over stdio. Any MCP client can connect to it. But right now, the only way to test it is by inspecting raw JSON responses.
In this step, you'll build an ADK demo agent that connects to your MCP server and lets you interact with your connectors through natural language. The agent uses MCPToolset to discover all tools from your MCP server automatically.
In this step, get ready to:
- Create an ADK agent that connects to your MCP server via MCPToolset.
- Launch the ADK dev UI and verify tool discovery.
- Test ServiceTitan operations through natural language in the dev UI.
Create the ADK agent with MCPToolset
The ADK agent is your demo layer. It wraps your MCP server in a conversational interface powered by Gemini. When you ask a question in plain English, the agent figures out which MCP tool to call and formats the results.
- Give Claude Code this prompt to create agent/agent.ts:
Create agent/agent.ts that exports a rootAgent using LlmAgent from @google/adk. The agent should:
- Import dotenv/config at the top
- Import LlmAgent and MCPToolset from @google/adk
- Export a const rootAgent as a new LlmAgent
- Use name 'connector_demo_agent' and model 'gemini-2.5-flash'
- Set description to 'An agent that demos API connectors via MCP tools.'
- Include an instruction string that explains the agent has access to ServiceTitan (jobs, customers, invoices) and QuickBooks (customers, invoices, create invoices) via MCP. Tell it to identify the platform, choose the right tool, execute with params, and present results clearly.
- In the tools array, add a new MCPToolset with type 'StdioConnectionParams' and serverParams pointing to command 'npx' with args ['tsx', '../mcp-server/index.ts']
What does this code do?
The agent file ties together three pieces:
- LlmAgent creates a Gemini-powered agent that can reason about which tool to call based on your natural language input.
- MCPToolset with StdioConnectionParams tells ADK to spawn your MCP server as a child process and communicate over stdio. The agent automatically discovers all registered tools.
- The instruction string guides Gemini on how to route requests between platforms and format responses.
- Verify that Claude Code generated agent/agent.ts and that it exports a rootAgent with the MCPToolset in its tools array.
Claude Code generated something different?
Make sure the type field in the MCPToolset constructor is exactly StdioConnectionParams (not stdio or another variation). Also confirm the args path is '../mcp-server/index.ts' since the agent runs from the agent/ directory.
help me fix my agent.ts MCPToolset configuration
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
import 'dotenv/config';
import { LlmAgent, MCPToolset } from '@google/adk';
export const rootAgent = new LlmAgent({
name: 'connector_demo_agent',
model: 'gemini-2.5-flash',
description: 'An agent that demos API connectors via MCP tools.',
instruction: `You are a connector demo agent. You have access to tools from multiple business platforms via MCP:
- **ServiceTitan**: Field service management. List jobs, customers, and invoices.
- **QuickBooks**: Accounting platform. List customers, invoices, and create new invoices.
When a user asks about data from any of these platforms, use the appropriate tool. Always:
1. Identify which platform the user is asking about
2. Choose the right tool for their request
3. Execute it with appropriate parameters
4. Present results in a clear, readable format
If a request is ambiguous, ask which platform they mean.`,
tools: [
new MCPToolset({
type: 'StdioConnectionParams',
serverParams: {
command: 'npx',
args: ['tsx', '../mcp-server/index.ts'],
},
}),
],
});
Launch the ADK dev UI
The ADK dev UI gives you a chat interface to test your agent. When it starts, it reads your agent.ts file, connects to your MCP server, and discovers all available tools.
- Install the agent dependencies by running this from the agent/ directory:
cd agent && npm install
- Launch the ADK dev UI by running:
npx adk web
What does this command do?
npx adk web starts a local web server that serves the ADK developer interface. It reads your agent.ts export, spawns the MCP server as a child process, and presents a chat UI at http://localhost:8000.
- Open http://localhost:8000 in your browser.
- In the ADK dev UI, verify that the agent has discovered your MCP tools. You should see servicetitan_list_jobs, servicetitan_list_customers, and servicetitan_list_invoices listed as available tools.
Agent not discovering tools?
Check that your agent/.env file contains a valid GEMINI_API_KEY. The ADK agent needs this to initialize.
Also confirm that agent/package.json has "main": "agent.ts" set. ADK uses this field to locate your agent's entry point.
help me debug why the ADK dev UI isn't discovering my MCP tools
Test ServiceTitan operations via natural language
The real power of this setup is that you can now talk to your connectors in plain English. The agent handles tool selection and parameter mapping for you.
- In the ADK dev UI chat, type a natural language request like:
Show me the latest jobs from ServiceTitan
- Watch the agent select the servicetitan_list_jobs tool, execute it against your MCP server, and return formatted results.
- Try a few more prompts to exercise different tools:
List my ServiceTitan customers
Show me recent invoices from ServiceTitan
- Confirm that each prompt routes to the correct tool and returns data from your ServiceTitan account.
Why this architecture matters
Notice how the ADK agent knows nothing about ServiceTitan's API. It only sees MCP tools with names and descriptions. Your MCP server handles all the authentication and API details.
This separation means you can swap the demo agent for any other MCP client (Claude Desktop, Cursor, or Gemini Enterprise) without changing a single line of connector code.
Getting authentication errors from ServiceTitan?
Verify that your agent/.env contains valid values for SERVICETITAN_CLIENT_ID, SERVICETITAN_CLIENT_SECRET, SERVICETITAN_TENANT_ID, and SERVICETITAN_APP_KEY. The MCP server reads these from the environment when the agent spawns it.
help me debug ServiceTitan authentication errors in my MCP server
Your demo agent is working. You can now showcase your ServiceTitan connector through natural language conversations. Next up, you'll add a mock QuickBooks connector to prove this pattern scales across multiple integrations.
Add the Mock QuickBooks Connector
Your ADK agent is talking to the MCP server and executing ServiceTitan operations through natural language. That proves the architecture works end-to-end.
Now you need to prove it scales. A real connector library has multiple integrations, and your registry pattern should handle new connectors without touching MCP server code. Adding a second connector with mock data confirms this.
In this step, get ready to:
- Create a mock QuickBooks connector with customer, invoice, and invoice-creation operations.
- Restart the MCP server and verify it automatically picks up all 6 tools.
- Test both connectors through the ADK dev UI to confirm the agent routes correctly.
Create the QuickBooks connector with mock data
The QuickBooks connector uses hardcoded mock data so you can demo the multi-connector pattern without needing a real QuickBooks account. It implements the same Connector interface as ServiceTitan, proving the pattern works for any integration.
- Give Claude Code this prompt to create the QuickBooks connector:
Create connectors/quickbooks/index.ts that implements the Connector interface from ../types.js.
Requirements:
- name: 'quickbooks'
- description: 'QuickBooks Online accounting platform. Access customers, invoices, and payments. (Mock data for demo)'
- authenticate() is a no-op (mock)
- Use hardcoded mock data arrays at the top of the file:
- mockCustomers: 3 entries (Acme Plumbing, Johnson HVAC, Smith Electric) with id, displayName, email, balance
- mockInvoices: 3 entries (INV-001 Paid, INV-002 Overdue, INV-003 Sent) with id, customerName, amount, status, dueDate
- Three operations:
1. quickbooks_list_customers - filter by name (optional, case-insensitive includes match on displayName)
2. quickbooks_list_invoices - filter by status (optional, case-insensitive exact match)
3. quickbooks_create_invoice - requires customerName (string), amount (number), dueDate (string YYYY-MM-DD)
- All operations return { data: results, totalCount: results.length } or { success: true, invoice: newInvoice }
- Use zod for parameter schemas with .describe() on each field
What does this prompt tell Claude Code to build?
The prompt asks Claude Code to create a connector that mirrors your ServiceTitan connector's structure but uses fake data. This means you can demo multi-connector routing without any API credentials for QuickBooks.
The three operations cover reading data (list customers, list invoices) and writing data (create invoice). This gives your demo agent a range of capabilities to showcase.
- After Claude Code generates the file, open connectors/quickbooks/index.ts and verify it contains the QuickBooksConnector class with three operations and two mock data arrays.
Claude Code created the file in the wrong location?
Make sure the file is at connectors/quickbooks/index.ts. If Claude Code placed it elsewhere, move it to the correct path. The import in mcp-server/index.ts expects exactly this location.
Help me fix my QuickBooks connector file location
✔️ Awesome, I've got everything!
Great. Double check you've saved connectors/quickbooks/index.ts before moving on.
ⓧ I'd like to double check the full code
Here is the complete connectors/quickbooks/index.ts file for comparison:
import { z } from 'zod';
import { Connector, Operation } from '../types.js';
const mockCustomers = [
{ id: '1', displayName: 'Acme Plumbing', email: 'contact@acmeplumbing.com', balance: 1500.00 },
{ id: '2', displayName: 'Johnson HVAC', email: 'info@johnsonhvac.com', balance: 3200.50 },
{ id: '3', displayName: 'Smith Electric', email: 'smith@smithelectric.com', balance: 0 },
];
const mockInvoices = [
{ id: 'INV-001', customerName: 'Acme Plumbing', amount: 750.00, status: 'Paid', dueDate: '2026-04-15' },
{ id: 'INV-002', customerName: 'Johnson HVAC', amount: 1200.00, status: 'Overdue', dueDate: '2026-04-01' },
{ id: 'INV-003', customerName: 'Smith Electric', amount: 450.00, status: 'Sent', dueDate: '2026-05-20' },
];
export class QuickBooksConnector implements Connector {
name = 'quickbooks';
description = 'QuickBooks Online accounting platform. Access customers, invoices, and payments. (Mock data for demo)';
async authenticate(): Promise<void> {
// Mock authentication - in production this would use OAuth 2.0
}
getOperations(): Operation[] {
return [
{
name: 'quickbooks_list_customers',
description: 'List customers from QuickBooks Online',
parameters: z.object({
name: z.string().optional().describe('Filter customers by name'),
}),
execute: async (input) => {
let results = mockCustomers;
if (input.name) {
results = results.filter(c =>
c.displayName.toLowerCase().includes((input.name as string).toLowerCase())
);
}
return { data: results, totalCount: results.length };
},
},
{
name: 'quickbooks_list_invoices',
description: 'List invoices from QuickBooks Online with optional status filter',
parameters: z.object({
status: z.string().optional().describe('Filter by status (Paid, Overdue, Sent, Draft)'),
}),
execute: async (input) => {
let results = mockInvoices;
if (input.status) {
results = results.filter(i =>
i.status.toLowerCase() === (input.status as string).toLowerCase()
);
}
return { data: results, totalCount: results.length };
},
},
{
name: 'quickbooks_create_invoice',
description: 'Create a new invoice in QuickBooks Online',
parameters: z.object({
customerName: z.string().describe('Name of the customer to invoice'),
amount: z.number().describe('Invoice amount in dollars'),
dueDate: z.string().describe('Due date in YYYY-MM-DD format'),
}),
execute: async (input) => {
const newInvoice = {
id: `INV-${String(mockInvoices.length + 1).padStart(3, '0')}`,
customerName: input.customerName as string,
amount: input.amount as number,
status: 'Draft',
dueDate: input.dueDate as string,
};
return { success: true, invoice: newInvoice };
},
},
];
}
}
Restart the MCP server and verify tools
Your mcp-server/index.ts already imports and registers QuickBooksConnector. Now that the file exists, restarting the server picks up all 6 tools automatically.
Why does this work without changing MCP server code?
The ConnectorRegistry pattern decouples connector implementation from MCP server logic. The server iterates registry.getAllOperations() at startup and registers whatever it finds. New connector, new tools, zero changes to the server loop.
- If your ADK dev server is still running, stop it by pressing Ctrl+C in the terminal.
- Restart the ADK dev UI by running this from the agent/ directory:
npx adk web
- Open the ADK dev UI at http://localhost:8000 in your browser.
- Check the tools panel. You should see 6 tools listed: 3 from ServiceTitan (servicetitan_list_jobs, servicetitan_list_customers, servicetitan_list_invoices) and 3 from QuickBooks (quickbooks_list_customers, quickbooks_list_invoices, quickbooks_create_invoice).
Only seeing 3 tools?
If only the ServiceTitan tools appear, the MCP server likely failed to import the QuickBooks connector. Check your terminal output for import errors. The most common cause is the file path not matching the import in mcp-server/index.ts. The import expects ../connectors/quickbooks/index.js.
Help me debug why my QuickBooks tools aren't showing up
Test both connectors through the ADK dev UI
The real test is whether the agent can route between connectors based on natural language. It should pick the right tool depending on which platform you ask about.
- In the ADK dev UI chat, type a QuickBooks query like: "Show me all customers in QuickBooks".
- Verify the agent calls quickbooks_list_customers and returns the three mock customers (Acme Plumbing, Johnson HVAC, Smith Electric).
- Try a cross-platform query: "Show me overdue invoices from QuickBooks".
- Verify the agent calls quickbooks_list_invoices with the status filter and returns only INV-002 (Johnson HVAC, Overdue).
- Test the write operation: "Create a QuickBooks invoice for Acme Plumbing, $500, due 2026-06-01".
- Verify the agent calls quickbooks_create_invoice and returns a success response with the new invoice details.
- Finally, test routing by asking a ServiceTitan question: "List recent jobs from ServiceTitan". The agent should switch to servicetitan_list_jobs without confusion.
What is the agent doing behind the scenes?
The LlmAgent reads your natural language, matches it against the tool descriptions from the MCP server, and picks the right tool. The tool names and descriptions you defined in the connector operations are what the LLM uses to make routing decisions.
Good tool descriptions are critical. If the agent routes incorrectly, improving the description field on each operation is the fix.
Agent calling the wrong connector?
If the agent picks ServiceTitan tools when you ask about QuickBooks (or vice versa), be more explicit in your query by naming the platform. The agent's instruction tells it to ask which platform you mean when a request is ambiguous.
Help me fix agent routing between my connectors
Secret mission
Streamable HTTP Transport for Production
Your MCP server currently uses stdio transport, which means it only works as a local child process. Convert it to Streamable HTTP so it can serve remote clients like Gemini Enterprise agents. This is the step that makes your connector library production-ready.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The connector-mcp-server project folder on your Desktop (all source code, connectors, MCP server, and agent files).
- Two node_modules directories (root and agent/).
- Two .env files containing your Gemini API key and ServiceTitan credentials.
- Your Gemini API key in Google AI Studio.
Keep everything running
No action needed. Choose this if you're still actively building on your MCP server or planning to add more connectors.
- Your project folder stays on your Desktop ready to use.
- Your Gemini API key remains active in Google AI Studio.
- Node.js remains installed on your machine.
Pause - I'll come back to this later
Stop any running processes to free up memory. Your files stay intact for when you return.
- If your MCP HTTP server is still running, press Ctrl+C in its terminal to stop it.
- If the ADK dev UI is still running, press Ctrl+C in its terminal to stop it.
- Close any open terminal windows for this project.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your local files and optionally revokes your API key.
- Stop any running processes by pressing Ctrl+C in each terminal window.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/connector-mcp-server
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\connector-mcp-server
- To revoke your Gemini API key, go to Google AI Studio, find your key, and click the trash icon to delete it.
Nice Work!
Nice Work!
You just built a production-ready connector MCP server with an ADK demo agent on top. Your connector library is now accessible through the standard Model Context Protocol, ready for any MCP client to discover and use.
You've learned how to:
- Build a type-safe Connector interface and ConnectorRegistry that manages multiple integrations and dynamically exposes them as standard MCP tools.
- Wire up an MCP server using @modelcontextprotocol/server with StdioServerTransport so any MCP client can call your connector operations.
- Connect an ADK agent to your MCP server using MCPToolset, giving you a natural-language demo layer powered by Gemini that routes requests to the correct connector.
- Deployed your MCP server as a Streamable HTTP endpoint ready for remote access from Gemini Enterprise agents.
Ready to quiz yourself?