Write rieview✍️ Rezension schreiben✍️ Get Badge!🏷️ Abzeichen holen!🏷️ Edit entry⚙️ Eintrag bearbeiten⚙️ News📰 Neuigkeiten📰
Tags: stockapp
60313 Frankfurt am Main, DE Germany, EU Europe, latitude: 50.1169, longitude: 8.6837
Charles Feng, Mohammed Mogasbe, Justin Berman, Bruno Fantauzzi
If you're integrating AI assistants into your product, you've probably discovered that maintaining parallel implementations for REST APIs and MCP servers leads to subtle inconsistencies that only surface in production.
We found a better way at StockApp: we use our existing ts-rest API contracts to automatically expose MCP tools alongside our REST endpoints in the same server. Both interfaces directly call the same route handler functions, ensuring they share the exact same implementation, authorization logic, and validation rules.
At a startup, this means shipping AI features in minutes instead of days with the confidence they're production-ready. At scale, it helps many engineers operate more consistently and eliminates an entire category of bugs – there's no drift between your REST API and tools exposed via MCP because they literally run the same code.
🚀 Try it yourself: We've built a complete working example demonstrating this pattern. Clone the sample app to see it in action with a task management API, or keep reading to understand the architecture.
Our sample app showing the same task management API exposed through both REST (left panel) and MCP tools (right panel)
❤️ Want to work at StockApp? We're hiring for all engineering roles.
Releasing an MCP server transforms how customers interact with your product. When you expose your API through MCP, customers can use Claude Desktop, Claude Code, or any MCP-compatible assistant to work with your platform in natural language. Emergent workflows become possible when your tools are combined with others in their MCP ecosystem.
Internally, MCP standardizes your AI assistant architecture. Instead of building custom integrations for each AI framework, you have one interface that works with Claude, Mastra, LangGraph, and other tools. Your team can experiment with different AI providers without rewriting integration code.
Most MCP servers are thin wrappers that make HTTP calls to REST APIs. They receive tool calls, transform parameters, make HTTP requests, and return responses. This creates extra latency and maintenance overhead.
Teams typically take one of two approaches:
Approach 1: Duplicate Implementation
Write REST endpoints for your frontend, then write separate MCP tool handlers that reimplement the same logic. This creates obvious maintenance nightmares with diverging business logic, inconsistent validation, and security gaps.
Approach 2: Manual Wrapper Layer
Build MCP tools that call your REST API, but manually define each tool's schema, write parameter transformation code, handle authentication forwarding, maintain error mapping, and keep guidance strings updated.
You end up with hundreds of lines of boilerplate that looks like this:
// Manual MCP wrapper for every single endpoint
mcpServer.registerTool('get_tasks', {
description: 'Get list of tasks',
// Manually maintained guidance that drifts from actual API behavior
guidance: 'Use status filter for task states. Returns max 10 items.',
schema: {
// Manual schema definition that can drift from API
task_status: { type: 'string', enum: ['todo', 'doing', 'done'] },
max_results: { type: 'number', default: 10 }
},
handler: async (params) => {
// Manually reconstruct the API call
const response = await fetch(`${API_URL}/tasks`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${params.auth_token}`,
'Content-Type': 'application/json'
},
// Manual parameter mapping
query: {
status: params.task_status, // Different naming
limit: params.max_results || 20, // Wait, docs say 10?
assignee_id: params.assigned_user // More naming mismatches
}
})
// Manual error handling
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed')
}
// More manual error mapping...
}
// Manual response transformation
const data = await response.json()
return {
tasks: data.items, // Different structure
total: data.pagination.total_count
}
}
})
This wrapper layer becomes another codebase to maintain. Plus, every MCP tool call makes an HTTP request to your API, adding network latency and requiring authentication forwarding.
When you add a new parameter to your API, you have to remember to update the MCP wrapper. When you change response formats, the wrapper needs updating. When you add new validation rules, they might not make it to the MCP layer. It's essentially maintaining two API contracts that can drift apart.
We were already using ts-rest for our API contracts. ts-rest defines contracts separately from implementation (unlike tRPC where they're coupled), creating an abstraction layer that decouples your API definition from any specific framework. This separation makes it perfect for serving multiple consumers (REST clients, OpenAPI generators, and as we discovered, MCP tools) from a single source of truth.
Each contract uses Zod schemas that provide runtime validation and TypeScript types. When a request comes in – whether from a REST client or MCP tool – the same Zod schema validates it. This guarantees both interfaces have identical validation rules, not just matching type definitions.
import { initContract } from "@ts-rest/core"
import { z } from "zod"
const c = initContract()
// One schema used everywhere
const TaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
status: z.enum(["pending", "in_progress", "completed"]),
priority: z.enum(["low", "medium", "high"]).default("medium"),
assigned_to: z.string().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
completed_at: z.string().datetime().optional()
})
// TypeScript type derived from schema
type Task = z.infer<typeof TaskSchema>
// ts-rest contract leverages schemas for validation and type safety
export const tasksContract = c.router({
listTasks: {
method: "GET",
path: "/tasks",
query: z.object({
status: TaskSchema.shape.status.optional(),
assigned_to: TaskSchema.shape.assigned_to.optional(),
limit: z.coerce.number().positive().max(100).default(20),
offset: z.coerce.number().nonnegative().default(0),
}),
responses: {
200: z.object({
tasks: z.array(TaskSchema),
total: z.number(),
limit: z.number(),
offset: z.number(),
}),
401: UnauthorizedSchema,
},
summary: "List all tasks with optional filtering",
}
})
The contract serves as a single source of truth for the API's shape. Both the server implementation and client code reference this contract, ensuring they stay in sync.
This gives us a type-safe client in our React and Next.js apps:
// Frontend usage with ts-rest client
const apiClient = initClient(tasksContract, {
baseUrl: API_URL,
baseHeaders: {
Authorization: authHeader,
"Content-Type": "application/json",
},
})
// Type-safe API calls
const result = await apiClient.listTasks({
query: { status: "pending", limit: 20 }
})
if (result.status === 200) {
setTasks(result.body.tasks)
}
Plus, ts-rest generates OpenAPI specs that we feed into Stainless to auto-generate SDKs for customers who want to integrate with our platform.
When building our AI assistant, we realized we could extend this pattern further.
Traditional REST APIs often accumulate dozens of query parameters over time, many with cryptic names that made sense to the original developer but confuse both humans and AI. Consider an endpoint with parameters like flt_cst_id, inc_arch, grp_by_vnd, and agg_mode – an LLM has to guess what these mean from context, often incorrectly.
Good API design for humans turns out to be good API design for AI. When you use semantic, self-documenting parameter names (customer_id instead of cst_id), group related parameters into logical objects, and provide clear enums for valid values, you're making your API more accessible to both developers and AI assistants. The same thoughtful design that helps a new developer understand your API helps an LLM use it correctly.
This is where ts-rest and Zod shine – they encourage you to define clear schemas with proper validation, meaningful names, and good defaults. Your time_range parameter can be an enum of human-readable options like "last_30_days" rather than cryptic codes. Complex filtering can be simplified with a natural language search or query parameter that your backend interprets, rather than exposing dozens of filter flags.
By adding a simple metadata field to our contracts, we mark which endpoints should also be exposed as MCP tools:
export const tasksContract = c.router({
listTasks: {
method: "GET",
path: "/tasks",
query: z.object({
status: TaskSchema.shape.status.optional(),
priority: TaskSchema.shape.priority.optional(),
assigned_to: z.string().optional(),
limit: z.coerce.number().positive().max(100).default(20),
offset: z.coerce.number().nonnegative().default(0),
}),
responses: {
200: z.object({
tasks: z.array(TaskSchema),
total: z.number(),
limit: z.number(),
offset: z.number(),
}),
401: ErrorSchema,
},
summary: "List all tasks with optional filtering",
metadata: {
mcp: true,
guidance: `Use this to retrieve tasks.
Filter by status when user asks for specific task states (pending, in progress, completed).
Filter by priority when user asks for important/urgent tasks.
Use pagination with limit and offset for large result sets.`
}
}
})
The metadata serves two purposes. First, the mcp: true flag tells our system to expose this endpoint as an MCP tool. Second, the guidance helps AI assistants understand when and how to use the tool effectively, providing context that goes beyond what a simple function signature conveys.
When our server starts, it scans all contracts and registers each endpoint as its own MCP tool. This happens once at initialization, so there's no runtime performance impact:
class ToolCache {
private static discoverToolsFromContracts(): McpToolDefinition[] {
const tools: McpToolDefinition[] = []
// Iterate through contracts (tasks, etc.)
for (const [contractName, contractRouter] of Object.entries(contractMap)) {
// Iterate through endpoints in each contract (listTasks, createTask, etc.)
for (const [endpointName, endpoint] of Object.entries(contractRouter)) {
// Only register endpoints with mcp metadata set to true
if (!endpoint.metadata?.mcp) continue
const toolName = `${contractName}_${endpointName}`
.replace(/([A-Z])/g, "_$1")
.toLowerCase()
const inputSchema = this.createInputSchema(endpoint)
tools.push({
name: toolName,
description: endpoint.summary,
guidance: endpoint.metadata?.guidance,
inputSchema,
contractPath: [contractName, endpointName],
endpoint,
})
}
}
return tools
}
private static createInputSchema(endpoint: any) {
const schema: any = {}
// Add path parameters
if (endpoint.pathParams?.shape) {
Object.assign(schema, endpoint.pathParams.shape)
}
// Add body parameters
if (endpoint.body?.shape) {
Object.assign(schema, endpoint.body.shape)
}
// Add query parameters
if (endpoint.query?.shape) {
Object.assign(schema, endpoint.query.shape)
}
return schema
}
}
With the tools discovered, we register each one individually:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
export class ToolRegistrar {
constructor(private routeInvoker: RouteInvoker) {}
registerAllTools(server: McpServer) {
const toolDefinitions = ToolCache.getToolDefinitions()
for (const toolDef of toolDefinitions) {
server.registerTool(
toolDef.name, // e.g., "tasks_list_tasks", "tasks_create_task"
{
description: toolDef.description,
inputSchema: toolDef.inputSchema,
},
async (params, extra) =>
this.routeInvoker.invokeRoute(toolDef.contractPath, toolDef.endpoint, params, extra)
)
}
}
}
export class RouteInvoker {
async invokeRoute(contractPath: string[], endpoint: any, mcpParams: any, extra: any) {
// Get the route implementation
const routeFunction = this.getRouteFunction(contractPath)
// Transform MCP's flat parameters to REST's structured format
const params: any = {}
const query: any = {}
const body: any = {}
// Get parameter definitions from endpoint
const pathParamKeys = endpoint.pathParams?.shape ? Object.keys(endpoint.pathParams.shape) : []
const queryKeys = endpoint.query?.shape ? Object.keys(endpoint.query.shape) : []
const bodyKeys = endpoint.body?.shape ? Object.keys(endpoint.body.shape) : []
// Distribute parameters to correct locations
for (const [key, value] of Object.entries(mcpParams)) {
if (pathParamKeys.includes(key)) {
params[key] = value
} else if (queryKeys.includes(key)) {
query[key] = value
} else if (bodyKeys.includes(key)) {
body[key] = value
}
}
// Call the route function directly
const routeResponse = await routeFunction({
params,
query,
body: Object.keys(body).length > 0 ? body : undefined,
req: {
headers: extra?.requestInfo?.headers || {},
...extra?.authInfo?.req,
},
})
// Format route response to MCP format
return {
content: [{
type: "text",
text: JSON.stringify(routeResponse.body, null, 2),
}],
}
}
}
Every endpoint marked with mcp: true in your contracts is now available as its own MCP tool. In Claude or other AI assistants, you can directly call tool("tasks_list_tasks", {status: "pending"}) without needing to discover tools first.
When you have tens of endpoints, registering each one as a separate MCP tool can overwhelm AI assistants. An alternative is to use a dynamic discovery pattern with just two tools:
// Instead of registering each endpoint, provide discovery tools
mcpServer.registerTool('list_tools', {
description: 'List available API tools',
inputSchema: {
type: 'object',
properties: {
filter: { type: 'string', description: 'Optional filter for tool names' }
}
}
}, async ({ filter }) => {
const tools = ToolCache.getToolDefinitions()
const filtered = filter
? tools.filter(t => t.name.includes(filter))
: tools
return {
content: [{
type: 'text',
text: JSON.stringify(filtered.map(t => ({
name: t.name,
description: t.description
})))
}]
}
})
mcpServer.registerTool('call_tool', {
description: 'Call a specific API tool',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Tool name from list_tools' },
arguments: { type: 'object', description: 'Tool arguments' }
},
required: ['name', 'arguments']
}
}, async ({ name, arguments: args }, extra) => {
const tool = ToolCache.getToolDefinitions().find(t => t.name === name)
if (!tool) throw new Error(`Tool ${name} not found`)
return invokeRoute(tool.contractPath, tool.endpoint, args, extra)
})
This approach reduces the initial tool list but requires two calls: first to discover available tools, then to invoke them. It's better suited for APIs with hundreds of endpoints where the full list would be overwhelming.
Both the MCP server and REST API run in the same Node.js process and share route handlers:
// Single implementation handles both REST and MCP
export const listTasks: AppRouteImplementation<typeof tasksContract.listTasks> = async ({ query, req }) => {
// This auth check runs for both REST API calls AND MCP tool invocations
const authError = await checkAuth(req as any)
if (authError) return authError
const tasks = await db.findAllTasks({
status: query?.status,
assigned_to: query?.assigned_to,
limit: query?.limit || 20,
})
return {
status: 200 as const,
body: {
tasks,
total: tasks.length,
limit: query?.limit || 20,
offset: query?.offset || 0,
},
}
}
// REST wires up all routes through contractMap and routeMap
export const contractMap = {
tasks: tasksContract,
users: usersContract,
// ... other contracts
}
export const routeMap = {
tasks: tasksRoutes, // Object with getTasks, createTask, etc.
users: usersRoutes,
// ... other route modules
}
// ts-rest creates the router from contracts and routes
const s = initServer()
export const router = s.router(contract, routeMap)
// MCP automatically discovers and uses the same route implementations!
Because both REST and MCP call the same getTasks function (discovered automatically from the contract), they share all the same authorization checks, business logic, and database optimizations. No HTTP calls between MCP and your business logic means better performance when chaining multiple tools.
When building AI assistants, you can use Mastra to dynamically load MCP tools with user-specific authentication:
// app/api/chat/route.ts - AI assistant endpoint
import { openai } from "@ai-sdk/openai"
import { Agent } from "@mastra/core/agent"
import { RuntimeContext } from "@mastra/core/di"
import { MCPClient } from "@mastra/mcp"
// Create dynamic MCP client with user's auth
function createTaskServiceMCPClient(runtimeContext: RuntimeContext) {
const authHeader = runtimeContext.get("authHeader")
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:1337"
return new MCPClient({
id: "task-service-dynamic",
servers: {
tasks: {
url: new URL(`${serverUrl}/mcp`),
requestInit: {
headers: {
Authorization: authHeader,
},
},
timeout: 30000,
},
},
})
}
// Create agent with dynamic MCP tools
const createTaskAssistantAgent = () =>
new Agent({
id: "taskAssistant",
name: "Task Assistant",
instructions: `You are a helpful task management assistant.
When users ask you to perform task operations, use the available tools.
Be concise in your responses and confirm what actions you've taken.`,
model: openai("gpt-4o-mini"),
tools: async ({ runtimeContext }) => {
// Add MCP tools dynamically based on user auth
const authHeader = runtimeContext?.get("authHeader")
if (authHeader) {
try {
const mcpClient = createTaskServiceMCPClient(runtimeContext)
const mcpTools = await mcpClient.getTools()
return mcpTools
} catch (error) {
console.warn("Failed to load MCP tools:", error)
return {}
}
}
return {} // No tools without auth
},
})
// Use in your API endpoint
export async function POST(request: NextRequest) {
const { message, authHeader } = await request.json()
// Create runtime context with auth credentials
const runtimeContext = new RuntimeContext()
runtimeContext.set("authHeader", authHeader)
// Execute agent with runtime context
const agent = createTaskAssistantAgent()
const result = await agent.generate(message, { runtimeContext })
return NextResponse.json({ message: result.text })
}
This pattern allows each user's AI assistant to connect to your MCP server with their specific credentials, ensuring proper authorization while reusing all the same tool implementations.
Beyond avoiding duplicate code, this pattern prevents the subtle inconsistencies that slow down development.
Permission Drift
When you update REST API permissions, the MCP tools automatically get the same updates since both interfaces execute the same authorization checks:
const checkAuth = async (req, options) => {
const auth = await validateCredentials(req.headers.authorization)
if (!auth.valid) return { status: 401, body: { error: "Invalid credentials" } }
// Check workspace membership
if (options.workspace) {
const isMember = await db.workspaceMembers.exists({
workspace_id: options.workspace,
user_id: auth.userId
})
if (!isMember) return { status: 403, body: { error: "Not a workspace member" } }
}
req.userId = auth.userId
return null
}
Data Consistency Issues
Different interfaces often handle edge cases differently - REST might return null for missing fields while an MCP wrapper throws an error. With one implementation, you get consistent behavior across both interfaces.
Rate Limiting Gaps
AI assistants can generate hundreds of requests quickly if they get stuck in a loop. When both interfaces share the same code path, your existing rate limits apply automatically - no separate configuration needed.
Schema Evolution Overhead
Adding fields, updating validation, or changing response formats typically requires updating multiple implementations. With this pattern, you evolve your API once and both interfaces get the update.
We've built a complete working example that demonstrates this pattern. The sample app shows a task management API with both REST and MCP interfaces running from the same codebase.
The demo UI demonstrates how both interfaces work together:
The AI assistant marking tasks as completed and creating new high-priority tasks - all changes instantly reflected in the REST UI
# 1. Clone and install
git clone https://github.com/stockapp-dev/ts-rest-mcp-sample-app
cd ts-rest-mcp-sample-app
npm install
# 2. Add your OpenAI API key
cp client/.env.example client/.env.local
# Edit client/.env.local and add: OPENAI_API_KEY=sk-proj-your-key-here
# 3. Start everything
npm run dev
# 4. Open the demo
open http://localhost:1336
The sample includes:
This pattern opens up interesting architectural possibilities:
Capability-Based Tool Exposure: We're exploring using JWT scopes or permissions to dynamically determine which MCP tools are available to an AI assistant. A customer support assistant might only get read-only tools, while an admin assistant gets full CRUD operations.
Usage Analytics: Since all AI operations flow through the same code path as regular API calls, we can track how AI assistants use our APIs compared to human users.
Progressive Tool Rollout: We can gradually expose endpoints to AI assistants by adding the mcp metadata flag. This lets us test AI interactions with less critical endpoints before exposing core business operations.
Context-Aware Permissions: We're working on permission rules that distinguish between human-initiated and AI-initiated requests. For example, an AI assistant might be allowed to read sensitive data to answer questions but not include it in generated reports.
Building parallel implementations for REST and MCP is unnecessary complexity that creates real problems. By generating MCP tools from ts-rest contracts and routing both interfaces through the same implementation, we've eliminated an entire category of bugs, security issues, and maintenance overhead.
Your API contracts already describe what your endpoints do. Why not use that information to automatically build AI tool interfaces that are guaranteed to stay in sync?
Come join us in finding faster and better ways to build our enterprise commerce AI product at StockApp!
4.9.2025 18:26Ship fast to both humans and AI: Using ts-rest + MCPWaleed Kadous, Charles Feng, Justin Berman, Dennis Yilmaz, Amr Elsayed, Mohammed Mogasbe, James Feng, Bruno Fantauzzi
Creating StockApp gave us the chance to build an AI-native development culture from scratch. Our experience is that this is materially (~2.5x) more productive than manual development, and considerably more efficient (~2x) than taking an existing development culture and enhancing it with AI. AI-native development isn't about replacing engineers—it's about creating systematic human-AI collaboration through meticulously crafted shared context. To do so, we’ve had to make some changes:
StockApp started in January 2025 with a unique opportunity: building an engineering culture designed from day one around AI-native development. Rather than retrofitting AI tools into existing processes, we architected our entire development workflow to leverage human-AI collaboration systematically.
While measuring developer productivity is notoriously difficult, our subjective experience, supported by objective measurements[1], points to productivity gains of roughly 2.5x—significantly beyond what we've experienced elsewhere. Several of us have worked at companies where AI has been partially adopted, and there, the productivity gains due to AI have been in the 30 to 50 percent range. With collective experience from top-tier engineering organizations like Google, we have a firm grasp on what high-performance development looks like, and this is the most productive environment any of us have experienced. Furthermore, the productivity boost is only getting larger as the models improve (witness the release of Claude 4.1 with its improvements in coding performance) and humans and agents learning to work more closely together. The latter is significant: we iterate and experiment a lot with how to work together, and even when we’ve used the same model, better techniques like those below have boosted productivity measurably.
Our core insight: Good code is a side effect of good context. The new AI-native development process is about how humans and agents progressively build and share context together. When done effectively, superior software artifacts naturally emerge.
We want to share our development process in the hope that others will find it useful.
One thing to emphasize is that this approach requires more expertise in software engineering, not less. Defining context effectively is at least as challenging technically as writing good code: you need to consider carefully what the most critical information is, and how the agent will interpret it; two things you don’t have to worry about when it’s all in your head. Furthermore, the blast radius when agents screw up can be large (misbehaving agents have nuked our dev databases a few times, for example), and when we’re using agents, we are usually paying full attention. Agents at their current level of development can definitely lead you down the wrong path, and it requires a vigilant, attentive and experienced eye to stop them.
To put our technical decisions in context, it helps to outline the essentials of our development environment. The web front-end is written in TypeScript, and the backend services are split between Python and TypeScript, all maintained in a single monorepo. For day-to-day coding assistance we lean on Claude Code, which most teammates run inside Cursor for real-time autocompletion; Cursor's own built-in AI is seldom used. Although we have tried VS Code, Windsurf, and the Gemini CLI, this configuration has proven to be the most effective for our workflow.
All five principles stem from one idea: humans and agents must iteratively create, refine, and consume shared context. When that happens, great software emerges and the code itself becomes an intermediate artifact; much as assembly sits between high-level and machine code.
Our repository is organized for machines as well as humans because AI performance depends heavily on accessible context. This is why context engineering now matters more than prompt engineering. It is also why having a monorepo is an important part of our operational process.
Natural language is as critical as programming languages, so we treat English prose with the same care we give TypeScript or Python.
To achieve this, the state of the system must be visible to both humans and agents. We intentionally put more into the repository than a human-only team would, because our repo isn't just for humans; it's for machines, too.
Our document-driven approach treats natural language artifacts as first-class citizens. Key context is stored in:
Better context leads to better code, but creating context is non-trivial. We work top-down, with humans and agents collaborating at each level:
We use agents for almost every aspect of our work and before we undertake any task, we ask if it could be done with AI. As mentioned above, we rarely – if ever – let agents take the steering wheel unsupervised. We meticulously check what the agents do and recommend before hitting enter. We do not “vibe code”. This supervision and checking still requires our deepest technical expertise.
We wanted to enumerate some of the perhaps unconventional ways we use agents:
For every stage of development, we're basically trying to do as much as we can using agents. This level of agent autonomy is not magic; it's a direct result of our systematic investment in creating and maintaining shared context. It's because of the design documents and plans that the AI can write great commit messages and write prompts better than humans can.
We make extensive use of MCP (Model Context Protocol) servers in our system. We also give the AI access to powerful command line options to explore information itself. We have a script called install_mcp.sh that we run when we set up a new repo to install the MCP servers we use. At current count we have about 6 MCP servers we install. These include:
Most exciting is how easy MCP servers make it to "bridge gaps" that would be difficult to do in other ways. For example, you can say things like: read the bug description in Linear, and go through the last 10 commits to identify which one most likely caused the bug.
If there is one lesson old-school machine learning teaches us, it's that ensembles outperform individuals. There are a variety of techniques like random forests, stacking, bagging and boosting. In all of these techniques you build more than one classifier and then employ some kind of voting mechanism between them. As long as there is sufficient variety in the classifiers, you are likely to see considerably better results from an ensemble than any one individual.
Perhaps the most interesting MCP server we have is the Zen MCP server. This allows Claude Code to also seek feedback from other LLMs like Gemini and o3. We have used this to enhance the performance of our system and we've found that different LLMs have strengths and weaknesses. We use this combined with the development process above, so after Claude Code finishes a design, for example, we will also get it reviewed by Gemini. This diversity helps in many ways, for example, Gemini has shown significantly better results in preemptively identifying security issues. Here’s a recent example where we were trying to work out how to implement auth for MCP servers, where o3 and Gemini differed in opinions. Gemini's Perspective: Strongly recommends payload-based auth as the primary choice, calling it "the most pragmatic and scalable solution" that "strikes the best balance between performance, scalability, and maintainability."
o3's Perspective: More cautious, ranking per-user clients as #1 for production systems, citing security concerns and LLM interference. Rates payload-based auth as #2 but warns about token leakage risks and complexity.
Ultimately, the human and the agents form an ensemble. The bridge that allows this ensemble to perform better than any individual member is shared context. This is why investing in context is the most important part of building an AI-native system.
We're looking for exceptional engineers to help us push the boundaries of AI-native development and build great products at the intersection of AI and commerce. If you want to define the future of how software is built while creating transformative technology, contact us at careers@stockapp.com.
¹ Development Metrics (Q2 2025): While developer productivity metrics should be interpreted cautiously, our objective measurements include: 1,098 PRs delivered in 13 weeks (84.5 PRs/week), 10.6 PRs per developer per week vs. ~1 PR/dev/week industry standard (LinearB 2025). But we are in the "build" phase of the project, usually the fastest phase, vs the industry standard "build + maintain" average.
6.8.2025 01:20Good context leads to good code: How we built an AI-Native Eng Culture