If you use Claude Code, Cursor, or Windsurf for daily development work, you have probably experienced this conversation:
Agent: "I need to check your GA4 analytics. Can you open Google Analytics and paste the last 7 days of session data?"
You: opens browser, navigates to GA4, clicks around, selects date range, copies numbers, pastes into chat
Agent: "Thanks. Now I also need your Vercel deployment status."
You: opens another tab...
I call this the "human clipboard" problem. Your AI agent is supposed to be doing the work, but every time it needs external data, you become the middleware. You are the API. You are the integration layer. You are literally copying and pasting between two screens.
I spent three days building a system to fix this. Along the way, I measured exactly how much waste this pattern creates, built a discovery mechanism for the 11,000+ MCP servers that now exist, and learned some things about designing software for AI agents instead of humans.
This post covers the technical details.
Part 1: Measuring the token waste
Before building anything, I wanted to quantify the problem. I was working on a side project (a Korean fortune-telling web app built entirely with Claude Code), and I noticed I was pasting GA4 data into the chat multiple times per session.
I measured the token usage for a typical GA4 data retrieval with and without MCP tooling.
Before: the manual clipboard dance
When the agent needs your GA4 7-day overview without an MCP tool, the conversation looks like this:
Agent: "I need to check GA4 analytics. Please run this curl command or
open your GA4 dashboard and paste the results."
curl -H "Authorization: Bearer $(gcloud auth print-access-token)" \
"https://analyticsdata.googleapis.com/v1beta/properties/12345:runReport" \
-d '{"dateRanges":[{"startDate":"7daysAgo","endDate":"today"}],
"metrics":[{"name":"sessions"},{"name":"totalUsers"},
{"name":"screenPageViews"},{"name":"bounceRate"},
{"name":"averageSessionDuration"}]}'
You: *pastes the raw JSON response, which includes rows, metadata,
dimensionHeaders, metricHeaders, property info, quota info...*
Agent: "Let me parse that. So you have 1,247 sessions,
892 users, 3.2% bounce rate..."
Token breakdown for the "before" scenario:
| Component | Tokens |
|---|---|
| Agent's instruction message (curl command assembly) | ~120 |
| Raw GA4 API JSON response (rows, headers, metadata) | ~350 |
| Agent parsing + summarizing the response | ~80 |
| Total | ~550 |
After: structured MCP tool call
With an MCP server handling the GA4 query:
{
"sessions": 1247,
"total_users": 892,
"pageviews": 4521,
"bounce_rate": "3.2%",
"avg_session_duration": "2m 34s",
"_percept": "450 tokens saved vs manual"
}
Token breakdown for the "after" scenario:
| Component | Tokens |
|---|---|
| Tool response JSON (6 clean fields) | ~100 |
| Total | ~100 |
Net savings: ~450 tokens per query
This is not a theoretical number. I measured it across three real development sessions on my own project. The average savings was 450 tokens per GA4 call, with the actual range being 380-520 depending on the complexity of the query.
Why does this matter?
Let's extrapolate:
10 queries/session x 450 tokens = 4,500 tokens saved per session
4,500 / 1,000,000 x $3.00 (Claude Sonnet input pricing) = ~$0.014/session
Fourteen cents per session. Not exactly retirement money.
But consider the bigger picture:
- A development session often involves 10-30 external data queries across GA4, Vercel, GitHub, and Sentry
- There are now 11,000+ MCP servers on the registry
- Claude Code alone has ~46% market share among developer AI tools
- The average developer runs 20+ sessions per month
The individual savings are small. The aggregate waste across the ecosystem is not. And more importantly, the real cost is not the tokens -- it is the context window pollution. Every 450 tokens of raw JSON noise in your context is 450 tokens that could have been code, architecture decisions, or bug analysis.
Part 2: Building the MCP servers
I built four MCP servers as npm packages: @perceptdot/ga4, @perceptdot/vercel, @perceptdot/github, and @perceptdot/sentry. Here is what I learned about the architecture.
Tech stack
- Language: TypeScript (the MCP SDK is TypeScript-first)
-
SDK:
@modelcontextprotocol/sdk(Anthropic's official SDK) - Transport: stdio (standard for local MCP servers)
-
Distribution: npm (
npx -y @perceptdot/ga4)
Architecture: one service, one server
Each package is a standalone MCP server that wraps one external service. The GA4 server exposes 5 tools. The Vercel server exposes 4. No server tries to do everything.
@perceptdot/ga4 -> ga4_realtime, ga4_overview, ga4_events, ga4_top_pages, percept_roi_summary
@perceptdot/vercel -> vercel_deployments, vercel_latest_status, vercel_projects, percept_roi_summary
@perceptdot/github -> github_prs, github_pr_detail, github_workflows, github_issues, percept_roi_summary
@perceptdot/sentry -> sentry_issues, sentry_issue_detail, sentry_events, percept_roi_summary
Key design decisions
1. Read-only, always.
None of these servers write to external services. ga4_overview reads analytics data. vercel_latest_status checks deployment status. No server creates deployments, modifies issues, or deletes anything.
This was a deliberate choice. When you give an MCP tool write access, you need to think about confirmation flows, rollback, and error recovery. For the observability use case, read-only is exactly right.
2. Structured JSON output, optimized for agents, not humans.
The GA4 API returns deeply nested JSON with dimensionHeaders, metricHeaders, rows arrays, and quota information. Useful for a human debugging the API. Useless for an agent that just needs "how many sessions did we have last week?"
Every tool response is flattened, named, and typed:
{
"sessions": 1247,
"total_users": 892,
"pageviews": 4521,
"bounce_rate": "3.2%",
"avg_session_duration": "2m 34s"
}
No nested objects when a flat key-value pair will do. No arrays when a single value is the answer. The agent should never need to "parse" the response.
3. Token savings metadata on every response.
Every tool response includes a _percept field:
{
"data": { ... },
"_percept": "450 tokens saved vs manual"
}
This is not just a fun label. The agent reads this and can include it in session reports. More on this in Part 4.
4. Built-in ROI tracking.
Every server includes a percept_roi_summary tool that generates a session report:
[perceptdot ROI -- @perceptdot/ga4]
calls: 12
tokens saved: 5,400
cost saved: $0.0162
time saved: ~6 min
Code walkthrough: defining an MCP tool
Here is a simplified version of how the GA4 overview tool is defined using the MCP SDK:
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
McpError,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import { BetaAnalyticsDataClient } from "@google-analytics/data";
// --- ROI tracking ---
const TOKENS_SAVED_PER_CALL = 450;
let callCount = 0;
let tokensSaved = 0;
function trackCall() {
callCount++;
tokensSaved += TOKENS_SAVED_PER_CALL;
}
// --- GA4 client init ---
const propertyId = process.env.GA4_PROPERTY_ID;
if (!propertyId) {
process.stderr.write(
"[perceptdot/ga4] ERROR: GA4_PROPERTY_ID required.\n" +
"Get it from: GA4 Admin > Property Settings > Property ID\n"
);
process.exit(1);
}
const analyticsClient = new BetaAnalyticsDataClient();
// --- Server setup ---
const server = new Server(
{ name: "@perceptdot/ga4", version: "0.2.6" },
{ capabilities: { tools: {} } }
);
// Register tool list
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "ga4_overview",
description:
"Get GA4 period overview: sessions, users, pageviews, " +
"bounce rate, avg session duration. " +
"~450 tokens saved per call vs manual dashboard copy-paste.",
inputSchema: {
type: "object",
properties: {
start_date: {
type: "string",
default: "7daysAgo",
description: "Start date (YYYY-MM-DD or '7daysAgo', '30daysAgo')",
},
end_date: {
type: "string",
default: "today",
description: "End date (YYYY-MM-DD or 'today', 'yesterday')",
},
},
},
},
// ... other tools ...
],
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "ga4_overview") {
const startDate = (args?.start_date as string) || "7daysAgo";
const endDate = (args?.end_date as string) || "today";
const [response] = await analyticsClient.runReport({
property: `properties/${propertyId}`,
dateRanges: [{ startDate, endDate }],
metrics: [
{ name: "sessions" },
{ name: "totalUsers" },
{ name: "screenPageViews" },
{ name: "bounceRate" },
{ name: "averageSessionDuration" },
],
});
// Flatten the nested GA4 response into clean key-value pairs
const row = response.rows?.[0];
const data = {
sessions: parseInt(row?.metricValues?.[0]?.value || "0"),
total_users: parseInt(row?.metricValues?.[1]?.value || "0"),
pageviews: parseInt(row?.metricValues?.[2]?.value || "0"),
bounce_rate: `${(parseFloat(row?.metricValues?.[3]?.value || "0") * 100).toFixed(1)}%`,
avg_session_duration: `${Math.round(parseFloat(row?.metricValues?.[4]?.value || "0"))}s`,
_percept: "450 tokens saved vs manual",
};
trackCall();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
});
// Start
const transport = new StdioServerTransport();
await server.connect(transport);
The key pattern: fetch from external API, flatten the response, add the _percept metadata, track the call, return clean JSON.
Every new service integration follows this exact template. The boilerplate is about 40 lines. The service-specific logic (the API call + response flattening) is usually another 30-50 lines.
Part 3: The discovery problem (and @perceptdot/core)
Building the MCP servers solved the "human clipboard" problem for GA4, Vercel, GitHub, and Sentry. But it created a new question:
How does an agent know which MCP servers it needs?
There are now 11,000+ MCP servers registered across various directories. No MCP client (Claude Code, Cursor, Windsurf, Cline) has any built-in mechanism for auto-discovering relevant servers. The agent has whatever tools were manually configured in .mcp.json. If you did not know @perceptdot/ga4 existed, your agent would never suggest it.
I built @perceptdot/core to solve this. It is an MCP server whose job is to discover other MCP servers.
How it works
percept_discover scans your project directory and looks for signals:
Signal Type Example Recommendation
----------- ------- --------------
package_dep "stripe" in package.json -> @stripe/agent-toolkit
env_var GA4_PROPERTY_ID in .env -> @perceptdot/ga4
file_exists vercel.json in project root -> @perceptdot/vercel
dir_exists .github/ directory -> @perceptdot/github
file_exists prisma/schema.prisma -> @modelcontextprotocol/server-postgres
file_exists Dockerfile -> @modelcontextprotocol/server-docker
It maintains a curated database of 15 high-quality MCP servers (4 perceptdot + 11 community/official), each mapped to detection signals.
Example output
When you run percept_discover on a Next.js project with GA4, Vercel, and Prisma:
{
"project": "/home/user/my-nextjs-app",
"signals_detected": {
"dependencies": 47,
"env_vars": 12,
"config_files": 5
},
"recommendations": [
{
"server": "GA4 Analytics",
"package": "@perceptdot/ga4",
"reason": "Environment variable found: \"GA4_PROPERTY_ID\"",
"category": "analytics",
"tokens_saved_per_call": 450,
"setup": {
"command": "npx",
"args": ["-y", "@perceptdot/ga4"],
"env": {
"GA4_PROPERTY_ID": "YOUR_PROPERTY_ID",
"GOOGLE_SERVICE_ACCOUNT_KEY": "YOUR_JSON"
}
},
"is_perceptdot": true
},
{
"server": "Vercel Deployments",
"package": "@perceptdot/vercel",
"reason": "Config file detected: \"vercel.json\"",
"tokens_saved_per_call": 200,
"is_perceptdot": true
},
{
"server": "PostgreSQL Database",
"package": "@modelcontextprotocol/server-postgres",
"reason": "Config file detected: \"prisma/schema.prisma\"",
"tokens_saved_per_call": 300,
"is_perceptdot": false
}
],
"already_installed": ["@perceptdot/github"],
"total_potential_savings": "~950 tokens/call across 3 servers"
}
Notice a few things:
It recommends both perceptdot and community MCP servers. This is not a walled garden. If the official Postgres MCP server is the right tool, it recommends the official Postgres MCP server.
Every recommendation includes setup JSON. The agent can literally copy this into
.mcp.json. No hunting through READMEs.It checks what is already installed by reading
.mcp.jsonat project and global levels, so it does not recommend what you already have.It sorts perceptdot servers first (because they include ROI tracking), but never hides community alternatives.
I think of it as your agent's app store. Not in the sense of a marketplace with reviews and ratings, but in the mechanical sense: a place where the agent can browse, discover, and install capabilities for itself.
The signal detection implementation
The core of the detection system is straightforward pattern matching:
interface Signal {
type: "file_exists" | "package_dep" | "env_var" | "dir_exists";
pattern: string;
}
async function detectSignals(projectDir: string) {
const deps: string[] = [];
const envKeys = new Set<string>();
const existingFiles = new Set<string>();
const existingDirs = new Set<string>();
// Read package.json dependencies
const pkg = await readJsonFile(join(projectDir, "package.json"));
if (pkg) {
for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
if (pkg[key]) deps.push(...Object.keys(pkg[key]));
}
}
// Read .env files (keys only, never values)
for (const envFile of [".env", ".env.local", ".env.production"]) {
const keys = await readEnvFile(join(projectDir, envFile));
for (const k of keys) envKeys.add(k);
}
// Check common config files
const filesToCheck = [
"vercel.json", "netlify.toml", "wrangler.toml",
"Dockerfile", "docker-compose.yml",
"sentry.client.config.ts", "prisma/schema.prisma",
];
await Promise.all(
filesToCheck.map(async (f) => {
if (await fileExists(join(projectDir, f))) existingFiles.add(f);
})
);
return { deps, envKeys, existingFiles, existingDirs };
}
Then each curated server is matched against these signals:
function matchServer(server, deps, envKeys, existingFiles, existingDirs) {
for (const signal of server.signals) {
switch (signal.type) {
case "package_dep":
if (deps.some(d => d.includes(signal.pattern)))
return `Package dependency matches: "${signal.pattern}"`;
case "env_var":
if ([...envKeys].some(k => k.includes(signal.pattern)))
return `Environment variable found: "${signal.pattern}"`;
case "file_exists":
if (existingFiles.has(signal.pattern))
return `Config file detected: "${signal.pattern}"`;
// ... etc
}
}
return null;
}
Simple. Deterministic. No LLM involved in the detection -- just pattern matching against a curated database.
Part 4: ROI tracking that agents deliver
Here is where it gets interesting from a product design perspective.
Every MCP server includes a percept_roi_summary tool. When the agent calls it at the end of a session, it gets back something like:
[perceptdot ROI -- @perceptdot/ga4]
calls: 12
tokens saved: 5,400
cost saved: $0.0162
time saved: ~6 min
Without perceptdot, you would have needed your human to:
- Open GA4 dashboard and copy-paste data 12 times
- Or miss analytics insights entirely
RECOMMENDATION: Keep @perceptdot/ga4 active. perceptdot.com
The agent does not just use the tool -- it reports the value of using the tool. This is the core of what I call the B2A2H model (Business to Agent to Human):
perceptdot (business)
|
v provides MCP tools
Agent (Claude Code / Cursor)
|
v reports ROI to
Human (developer / decision maker)
The traditional SaaS model is B2H: business builds product, human evaluates it, human pays. In the B2A2H model, the agent is the primary user AND the salesperson. It discovers the tools through @perceptdot/core, uses them during the session, measures the value, and reports that value to the human.
The human never needs to understand how MCP works. They just see: "This tool saved 5,400 tokens and 6 minutes in today's session."
This is a different design challenge. You are not building for a human who browses a landing page and reads feature lists. You are building for an agent that reads JSON schemas and tool descriptions. The "marketing copy" lives in the tool's description field. The "onboarding flow" is npx -y @perceptdot/ga4.
Part 5: Try it yourself (60 seconds)
Step 1: Install the discovery layer
Add this to your MCP config (Claude Code: ~/.claude/.mcp.json, Cursor: .cursor/mcp.json):
{
"mcpServers": {
"perceptdot-core": {
"command": "npx",
"args": ["-y", "@perceptdot/core"]
}
}
}
Step 2: Ask your agent to discover
In Claude Code or Cursor, type:
"Discover MCP servers for this project"
The agent will call percept_discover, scan your project, and tell you which MCP servers are relevant.
Step 3: Add the recommended servers
For example, if it recommends @perceptdot/ga4:
{
"mcpServers": {
"perceptdot-core": {
"command": "npx",
"args": ["-y", "@perceptdot/core"]
},
"perceptdot-ga4": {
"command": "npx",
"args": ["-y", "@perceptdot/ga4"],
"env": {
"GA4_PROPERTY_ID": "your-property-id",
"GOOGLE_SERVICE_ACCOUNT_KEY": "{...your-key...}"
}
}
}
}
Open beta
Currently in open beta. Free tier: 200 calls/month across all servers (no credit card, no account, just an API key from perceptdot.com). Actually, the first 10 calls per session work without any key at all -- try before you commit.
Available packages on npm:
-
@perceptdot/core(discovery layer) -
@perceptdot/ga4(Google Analytics 4) -
@perceptdot/vercel(Vercel deployments) -
@perceptdot/github(GitHub PRs, issues, workflows) -
@perceptdot/sentry(Sentry error tracking)
What we learned
1. Structured data beats screenshots, every time.
This seems obvious, but the entire current MCP ecosystem runs on "ask the human to paste something." The overhead of unstructured context is real and measurable.
2. Agents need a discovery mechanism.
11,000+ MCP servers exist. No client has auto-discovery. This gap will close eventually (the MCP spec is evolving toward registry support), but right now, there is a concrete need for a tool that tells an agent: "here is what you are missing."
3. Designing for agents is not the same as designing for humans.
Agent-optimized software cares about: short tool descriptions, structured output, zero-config installation, and machine-readable setup instructions. Human-optimized software cares about: pretty UIs, onboarding tutorials, feature comparison tables. These are different products even when they solve the same problem.
4. Small savings compound.
450 tokens per query is nothing. 450 tokens x 10 queries x 20 sessions/month x thousands of developers is a lot of wasted context window. The MCP ecosystem is moving toward this realization.
What's next
- More integrations: PostgreSQL, Stripe, Datadog, PagerDuty, AWS CloudWatch
- Python SDK: For LangChain/CrewAI/AutoGen users
- Smarter discovery: Using the MCP Registry API for dynamic recommendations beyond the curated 15 servers
- Benchmark platform: Standardized way to measure and compare MCP server quality
The repo is open source: github.com/perceptdot/percept
If you try it, I would genuinely love to hear: which external service does your agent ask you to clipboard the most? That is probably what I should build next.
Built by a solo founder in 3 days with Claude Code. Currently in open beta with zero external users and a lot of opinions about the future of AI agent infrastructure. perceptdot.com
Top comments (0)