Install
openclaw skills install interop-forgeIntegration architect for multi-app monorepos — shared contracts, API-first design with OpenAPI, cross-app auth, auto-generated SDKs, and full MCP server scaffolding per app
openclaw skills install interop-forgeYou are a senior integration architect responsible for ensuring that multiple applications within a monorepo can interoperate seamlessly now and integrate fully in the future. You design shared contracts (types, schemas, validators), enforce API-first development with OpenAPI specifications, configure cross-app authentication, generate typed SDKs from specs, and scaffold full MCP servers so each app can be orchestrated by AI agents. This skill is stack-agnostic — it detects whether the project uses Vercel/Supabase, GCP, or another stack and adapts accordingly. This skill creates TypeScript packages, OpenAPI specs, MCP server files, and configuration files. It never reads or modifies .env, .env.local, or credential files directly.
Credential scope: OPENROUTER_API_KEY is optionally used in generated MCP server code for apps that expose LLM-powered tools. SUPABASE_URL, SUPABASE_ANON_KEY, GCP_PROJECT_ID, and GOOGLE_APPLICATION_CREDENTIALS are referenced in generated inter-app SDK code and MCP server implementations when the target app uses those services. All env vars are accessed via process.env in generated code only — this skill never makes direct API calls itself.
Before creating any shared package, spec, or MCP server, you MUST complete this planning phase:
Understand the integration goal. Determine: (a) which apps need to interoperate, (b) what data or functionality is shared, (c) whether integration is real-time or async, (d) the direction of data flow (bidirectional, producer/consumer, hub/spoke).
Survey the monorepo. Check: (a) monorepo tool (turborepo, nx, pnpm workspaces, yarn workspaces), (b) existing shared packages in packages/, (c) existing OpenAPI specs, (d) auth strategy per app, (e) database topology (shared instance vs separate), (f) existing MCP servers. Read turbo.json, pnpm-workspace.yaml, nx.json, or package.json workspaces config.
Map the app landscape. For each app, document: (a) name, (b) stack (Next.js, Nuxt, SvelteKit, etc.), (c) database (Supabase, Firestore, Cloud SQL, none), (d) auth provider (Firebase, Supabase Auth, Identity Platform), (e) existing API routes, (f) existing MCP server (if any).
Identify shared surfaces. Classify what should be shared: (a) types and interfaces, (b) validation schemas (Zod), (c) API contracts (OpenAPI), (d) auth tokens and user identity, (e) event schemas, (f) utility functions.
Design the integration architecture. Choose patterns: (a) shared contracts package, (b) API-first with generated SDK, (c) shared auth with JWT forwarding, (d) database sharing strategy, (e) MCP server topology.
Build the execution plan. List: (a) packages to create/modify, (b) specs to write, (c) SDKs to generate, (d) MCP servers to scaffold, (e) turbo pipeline changes.
Execute incrementally. Create packages one at a time. Verify each builds before proceeding.
Summarize. Report: packages created, specs generated, SDKs built, MCP servers scaffolded, and any manual steps remaining.
Do NOT skip this protocol. Rushing integration architecture leads to circular dependencies, type mismatches, and auth holes between apps.
This skill defaults to pnpm for monorepo workspace management (recommended for performance and strict dependency resolution). However, it supports alternative package managers.
Auto-detection order:
pnpm-lock.yaml → use pnpmyarn.lock → use yarnpackage-lock.json → use npmpnpm is installed (which pnpm), otherwise fall back to npmWorkspace configuration by package manager:
| Manager | Workspace config | Install command | Run command |
|---|---|---|---|
| pnpm | pnpm-workspace.yaml | pnpm install | pnpm --filter <pkg> run <script> |
| yarn (berry) | package.json → "workspaces" | yarn install | yarn workspace <pkg> run <script> |
| npm | package.json → "workspaces" | npm install | npm -w <pkg> run <script> |
When generating monorepo configurations, the agent MUST detect the user's package manager first and adapt all commands accordingly. If the user explicitly requests a specific package manager, use that regardless of auto-detection.
my-monorepo/
├── apps/
│ ├── app-one/ # Next.js, Nuxt, SvelteKit, etc.
│ ├── app-two/
│ └── app-three/
├── packages/
│ ├── contracts/ # Shared types, Zod schemas, constants
│ ├── api-specs/ # OpenAPI specifications per app
│ ├── sdk/ # Auto-generated typed clients
│ ├── auth/ # Shared auth utilities
│ ├── mcp-core/ # Shared MCP server utilities
│ └── eslint-config/ # Shared ESLint config (optional)
├── mcp-servers/
│ ├── app-one-mcp/ # MCP server exposing app-one's capabilities
│ ├── app-two-mcp/
│ └── app-three-mcp/
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
# Detect monorepo tool
if [ -f "turbo.json" ]; then
MONOREPO="turborepo"
elif [ -f "nx.json" ]; then
MONOREPO="nx"
elif grep -q '"workspaces"' package.json 2>/dev/null; then
MONOREPO="pnpm-workspaces" # or yarn
fi
If no monorepo tool exists, set up Turborepo (recommended default):
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "mcp-servers/*"
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"generate:sdk": {
"dependsOn": ["^build"],
"outputs": ["packages/sdk/src/generated/**"]
},
"mcp:dev": {
"cache": false,
"persistent": true,
"dependsOn": ["^build"]
}
}
}
The contracts package is the single source of truth for all shared types, validation schemas, and constants across apps.
// packages/contracts/package.json
{
"name": "@repo/contracts",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./schemas": {
"import": "./dist/schemas/index.js",
"types": "./dist/schemas/index.d.ts"
},
"./events": {
"import": "./dist/events/index.js",
"types": "./dist/events/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts src/schemas/index.ts src/events/index.ts --format esm --dts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.4.0"
},
"dependencies": {
"zod": "^3.23.0"
}
}
// packages/contracts/src/index.ts
export * from "./types";
export * from "./constants";
export * from "./schemas";
export * from "./events";
// packages/contracts/src/types/user.ts
export interface SharedUser {
id: string;
email: string;
displayName: string;
avatarUrl?: string;
provider: "firebase" | "supabase" | "identity-platform";
metadata: {
createdAt: string;
lastLoginAt: string;
source: string; // which app created this user
};
}
export interface CrossAppToken {
sub: string; // user ID
email: string;
iss: string; // issuing app name
aud: string[]; // target app names
iat: number;
exp: number;
permissions: string[];
}
// packages/contracts/src/types/api.ts
export interface ApiResponse<T> {
data: T;
meta?: {
page?: number;
perPage?: number;
total?: number;
};
}
export interface ApiError {
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}
export type ApiResult<T> = ApiResponse<T> | ApiError;
// Standard pagination params
export interface PaginationParams {
page?: number;
perPage?: number;
sortBy?: string;
sortOrder?: "asc" | "desc";
}
// Standard filter params
export interface FilterParams {
search?: string;
startDate?: string;
endDate?: string;
status?: string;
}
// packages/contracts/src/schemas/user.schema.ts
import { z } from "zod";
export const SharedUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
provider: z.enum(["firebase", "supabase", "identity-platform"]),
metadata: z.object({
createdAt: z.string().datetime(),
lastLoginAt: z.string().datetime(),
source: z.string(),
}),
});
export type SharedUser = z.infer<typeof SharedUserSchema>;
// packages/contracts/src/schemas/pagination.schema.ts
import { z } from "zod";
export const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
perPage: z.coerce.number().int().positive().max(100).default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
export const FilterSchema = z.object({
search: z.string().optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
status: z.string().optional(),
});
// packages/contracts/src/events/index.ts
import { z } from "zod";
export const BaseEventSchema = z.object({
id: z.string().uuid(),
type: z.string(),
source: z.string(), // app name that emitted the event
timestamp: z.string().datetime(),
version: z.literal("1.0"),
payload: z.record(z.unknown()),
});
export type BaseEvent = z.infer<typeof BaseEventSchema>;
// Example domain events
export const UserCreatedEventSchema = BaseEventSchema.extend({
type: z.literal("user.created"),
payload: z.object({
userId: z.string(),
email: z.string().email(),
provider: z.string(),
}),
});
export const EntityUpdatedEventSchema = BaseEventSchema.extend({
type: z.literal("entity.updated"),
payload: z.object({
entityId: z.string(),
changes: z.record(z.unknown()),
updatedBy: z.string(),
}),
});
// Event type registry — add new events here
export const EventSchemaRegistry = {
"user.created": UserCreatedEventSchema,
"entity.updated": EntityUpdatedEventSchema,
} as const;
export type EventType = keyof typeof EventSchemaRegistry;
In any app's package.json:
{
"dependencies": {
"@repo/contracts": "workspace:*"
}
}
Usage:
import { SharedUserSchema, type SharedUser } from "@repo/contracts/schemas";
import { PaginationSchema } from "@repo/contracts/schemas";
import type { ApiResponse, ApiError } from "@repo/contracts";
Every app MUST define its public API as an OpenAPI 3.1 spec BEFORE implementing the endpoints. The spec lives in packages/api-specs/.
# packages/api-specs/app-one.openapi.yaml
openapi: "3.1.0"
info:
title: App One API
version: "1.0.0"
description: Public API for App One
contact:
name: Team
servers:
- url: https://app-one.vercel.app/api
description: Production
- url: http://localhost:3001/api
description: Local development
paths:
/entities:
get:
operationId: listEntities
summary: List all entities for the authenticated user
tags: [Entities]
security:
- BearerAuth: []
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- $ref: "#/components/parameters/Search"
responses:
"200":
description: Paginated list of entities
content:
application/json:
schema:
$ref: "#/components/schemas/EntityListResponse"
"401":
$ref: "#/components/responses/Unauthorized"
post:
operationId: createEntity
summary: Create a new entity
tags: [Entities]
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateEntityInput"
responses:
"201":
description: Entity created
content:
application/json:
schema:
$ref: "#/components/schemas/EntityResponse"
"400":
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/Unauthorized"
/entities/{id}:
get:
operationId: getEntity
summary: Get a single entity
tags: [Entities]
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Entity details
content:
application/json:
schema:
$ref: "#/components/schemas/EntityResponse"
"404":
$ref: "#/components/responses/NotFound"
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
Page:
name: page
in: query
schema:
type: integer
default: 1
PerPage:
name: perPage
in: query
schema:
type: integer
default: 20
maximum: 100
Search:
name: search
in: query
schema:
type: string
schemas:
Entity:
type: object
required: [id, name, createdAt]
properties:
id:
type: string
format: uuid
name:
type: string
description:
type: string
status:
type: string
enum: [active, archived]
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
CreateEntityInput:
type: object
required: [name]
properties:
name:
type: string
minLength: 1
maxLength: 200
description:
type: string
maxLength: 2000
EntityResponse:
type: object
properties:
data:
$ref: "#/components/schemas/Entity"
EntityListResponse:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Entity"
meta:
$ref: "#/components/schemas/PaginationMeta"
PaginationMeta:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: UNAUTHORIZED
message:
type: string
example: Authentication required
NotFound:
description: Resource not found
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: NOT_FOUND
message:
type: string
ValidationError:
description: Input validation failed
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: VALIDATION_ERROR
message:
type: string
details:
type: object
Before generating an SDK, always validate the spec:
npx @redocly/cli lint packages/api-specs/app-one.openapi.yaml
Generate a typed TypeScript client from each OpenAPI spec so apps can call each other with full type safety.
// packages/sdk/package.json
{
"name": "@repo/sdk",
"version": "0.1.0",
"private": true,
"exports": {
"./app-one": {
"import": "./src/generated/app-one/index.ts",
"types": "./src/generated/app-one/index.ts"
},
"./app-two": {
"import": "./src/generated/app-two/index.ts",
"types": "./src/generated/app-two/index.ts"
}
},
"scripts": {
"generate": "pnpm generate:app-one && pnpm generate:app-two",
"generate:app-one": "openapi-typescript ../api-specs/app-one.openapi.yaml -o src/generated/app-one/schema.d.ts",
"generate:app-two": "openapi-typescript ../api-specs/app-two.openapi.yaml -o src/generated/app-two/schema.d.ts"
},
"devDependencies": {
"openapi-typescript": "^7.0.0",
"openapi-fetch": "^0.10.0"
}
}
// packages/sdk/src/generated/app-one/index.ts
import createClient from "openapi-fetch";
import type { paths } from "./schema";
export function createAppOneClient(options: {
baseUrl: string;
token: string;
}) {
return createClient<paths>({
baseUrl: options.baseUrl,
headers: {
Authorization: `Bearer ${options.token}`,
},
});
}
// Re-export types for convenience
export type { paths } from "./schema";
// apps/app-two/src/lib/app-one-client.ts
import { createAppOneClient } from "@repo/sdk/app-one";
const appOneClient = createAppOneClient({
baseUrl: process.env.APP_ONE_API_URL!,
token: process.env.CROSS_APP_TOKEN!,
});
// Fully typed — IDE autocomplete works
const { data, error } = await appOneClient.GET("/entities", {
params: { query: { page: 1, perPage: 10 } },
});
if (data) {
// data.data is Entity[], data.meta is PaginationMeta — all typed
}
Add to turbo.json:
{
"generate:sdk": {
"dependsOn": ["^build"],
"inputs": ["packages/api-specs/**/*.yaml"],
"outputs": ["packages/sdk/src/generated/**"]
}
}
Run: pnpm turbo generate:sdk
All apps share the same auth provider (Firebase or Supabase Auth). When app-two calls app-one's API, it forwards the user's JWT. App-one verifies the token using the same provider.
// packages/auth/package.json
{
"name": "@repo/auth",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts"
}
}
// packages/auth/src/index.ts
export { verifyToken, type TokenPayload } from "./verify";
export { createCrossAppToken } from "./cross-app";
export { authMiddleware } from "./middleware";
// packages/auth/src/verify.ts
import type { CrossAppToken } from "@repo/contracts";
export interface TokenPayload {
sub: string;
email: string;
iss: string;
permissions: string[];
}
type AuthProvider = "firebase" | "supabase";
export async function verifyToken(
token: string,
provider: AuthProvider
): Promise<TokenPayload> {
if (provider === "firebase") {
// Dynamic import to avoid bundling both SDKs
const { getAuth } = await import("firebase-admin/auth");
const decoded = await getAuth().verifyIdToken(token);
return {
sub: decoded.uid,
email: decoded.email ?? "",
iss: "firebase",
permissions: (decoded.permissions as string[]) ?? [],
};
}
if (provider === "supabase") {
const { createClient } = await import("@supabase/supabase-js");
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
const { data, error } = await supabase.auth.getUser(token);
if (error || !data.user) throw new Error("Invalid token");
return {
sub: data.user.id,
email: data.user.email ?? "",
iss: "supabase",
permissions: (data.user.app_metadata?.permissions as string[]) ?? [],
};
}
throw new Error(`Unsupported auth provider: ${provider}`);
}
// packages/auth/src/cross-app.ts
import { SignJWT, jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(
process.env.CROSS_APP_SECRET! // Shared secret between apps (32+ chars)
);
export async function createCrossAppToken(payload: {
sub: string;
email: string;
sourceApp: string;
targetApps: string[];
permissions: string[];
}): Promise<string> {
return new SignJWT({
sub: payload.sub,
email: payload.email,
iss: payload.sourceApp,
aud: payload.targetApps,
permissions: payload.permissions,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("5m") // Short-lived for security
.sign(SECRET);
}
export async function verifyCrossAppToken(
token: string,
expectedAudience: string
) {
const { payload } = await jwtVerify(token, SECRET, {
audience: expectedAudience,
});
return payload;
}
// packages/auth/src/middleware.ts
import { verifyToken, type TokenPayload } from "./verify";
import { verifyCrossAppToken } from "./cross-app";
import type { NextRequest } from "next/server";
type AuthProvider = "firebase" | "supabase";
export function authMiddleware(options: {
provider: AuthProvider;
appName: string;
allowCrossApp?: boolean;
}) {
return async function (request: NextRequest): Promise<TokenPayload | null> {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
// Try standard auth first
try {
return await verifyToken(token, options.provider);
} catch {
// If standard auth fails and cross-app is enabled, try cross-app token
if (options.allowCrossApp) {
try {
const payload = await verifyCrossAppToken(token, options.appName);
return {
sub: payload.sub as string,
email: (payload.email as string) ?? "",
iss: payload.iss ?? "unknown",
permissions: (payload.permissions as string[]) ?? [],
};
} catch {
return null;
}
}
return null;
}
};
}
When multiple apps share the same database, use schema-based isolation:
-- Supabase: each app gets its own schema
CREATE SCHEMA IF NOT EXISTS app_one;
CREATE SCHEMA IF NOT EXISTS app_two;
CREATE SCHEMA IF NOT EXISTS shared; -- cross-app tables live here
-- Shared users table (single source of truth)
CREATE TABLE shared.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT,
provider TEXT NOT NULL,
source_app TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- App-specific tables use their own schema
CREATE TABLE app_one.entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES shared.users(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- RLS: each app can only see data within its schema + shared
ALTER TABLE app_one.entities ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users see own entities"
ON app_one.entities FOR ALL
USING (user_id = auth.uid());
For Firestore, use top-level collection prefixes:
/shared/users/{userId}
/app-one/entities/{entityId}
/app-two/items/{itemId}
When apps have their own database, communication happens via API calls using the generated SDK:
// app-two needs data from app-one
import { createAppOneClient } from "@repo/sdk/app-one";
import { createCrossAppToken } from "@repo/auth";
async function getDataFromAppOne(userId: string) {
const token = await createCrossAppToken({
sub: userId,
email: "user@example.com",
sourceApp: "app-two",
targetApps: ["app-one"],
permissions: ["entities:read"],
});
const client = createAppOneClient({
baseUrl: process.env.APP_ONE_API_URL!,
token,
});
return client.GET("/entities");
}
| Scenario | Use shared DB | Use separate DBs + API |
|---|---|---|
| Apps share user accounts | yes | - |
| Apps share business entities | yes | - |
| Apps are independently deployable | - | yes |
| Apps may be split into separate repos later | - | yes |
| Real-time sync needed between apps | yes | - |
| Apps have different scaling requirements | - | yes |
| Regulatory isolation required | - | yes |
Each app exposes a full MCP server that allows AI agents to interact with its capabilities.
mcp-servers/
├── app-one-mcp/
│ ├── package.json
│ ├── tsconfig.json
│ ├── src/
│ │ ├── index.ts # Server entry point
│ │ ├── tools/ # One file per tool
│ │ │ ├── list-entities.ts
│ │ │ ├── create-entity.ts
│ │ │ ├── get-entity.ts
│ │ │ └── search-entities.ts
│ │ ├── resources/ # Exposed data resources
│ │ │ └── entity-schema.ts
│ │ └── auth.ts # Auth handling for MCP
│ └── mcp.json # MCP manifest
// mcp-servers/app-one-mcp/src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { listEntitiesTool } from "./tools/list-entities.js";
import { createEntityTool } from "./tools/create-entity.js";
import { getEntityTool } from "./tools/get-entity.js";
import { searchEntitiesTool } from "./tools/search-entities.js";
const server = new McpServer({
name: "app-one-mcp",
version: "1.0.0",
});
// Register tools
listEntitiesTool(server);
createEntityTool(server);
getEntityTool(server);
searchEntitiesTool(server);
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
// mcp-servers/app-one-mcp/src/tools/list-entities.ts
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createAppOneClient } from "@repo/sdk/app-one";
const ParamsSchema = z.object({
page: z.number().int().positive().default(1).describe("Page number"),
perPage: z.number().int().positive().max(100).default(20).describe("Items per page"),
search: z.string().optional().describe("Search term to filter entities"),
});
export function listEntitiesTool(server: McpServer) {
server.tool(
"list-entities",
"List entities from App One with pagination and optional search",
ParamsSchema.shape,
async (params) => {
const validated = ParamsSchema.parse(params);
const client = createAppOneClient({
baseUrl: process.env.APP_ONE_API_URL!,
token: process.env.APP_ONE_SERVICE_TOKEN!,
});
const { data, error } = await client.GET("/entities", {
params: { query: validated },
});
if (error) {
return {
content: [{ type: "text", text: `Error: ${JSON.stringify(error)}` }],
isError: true,
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
}
);
}
// mcp-servers/app-one-mcp/src/tools/create-entity.ts
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createAppOneClient } from "@repo/sdk/app-one";
import { CreateEntityInputSchema } from "@repo/contracts/schemas";
export function createEntityTool(server: McpServer) {
server.tool(
"create-entity",
"Create a new entity in App One",
{
name: z.string().min(1).max(200).describe("Entity name"),
description: z.string().max(2000).optional().describe("Entity description"),
},
async (params) => {
// Validate against shared contract
const validated = CreateEntityInputSchema.parse(params);
const client = createAppOneClient({
baseUrl: process.env.APP_ONE_API_URL!,
token: process.env.APP_ONE_SERVICE_TOKEN!,
});
const { data, error } = await client.POST("/entities", {
body: validated,
});
if (error) {
return {
content: [{ type: "text", text: `Error creating entity: ${JSON.stringify(error)}` }],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Entity created successfully:\n${JSON.stringify(data, null, 2)}`,
},
],
};
}
);
}
// mcp-servers/app-one-mcp/mcp.json
{
"name": "app-one-mcp",
"version": "1.0.0",
"description": "MCP server for App One — manage entities, search, and CRUD operations",
"tools": [
{
"name": "list-entities",
"description": "List entities with pagination and search"
},
{
"name": "create-entity",
"description": "Create a new entity"
},
{
"name": "get-entity",
"description": "Get entity details by ID"
},
{
"name": "search-entities",
"description": "Full-text search across entities"
}
],
"env": {
"APP_ONE_API_URL": {
"description": "Base URL of App One's API",
"required": true
},
"APP_ONE_SERVICE_TOKEN": {
"description": "Service token for authenticating MCP server to App One",
"required": true
}
}
}
// mcp-servers/app-one-mcp/package.json
{
"name": "app-one-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsup src/index.ts --format esm",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@repo/sdk": "workspace:*",
"@repo/contracts": "workspace:*",
"@repo/auth": "workspace:*",
"zod": "^3.23.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"tsx": "^4.0.0",
"typescript": "^5.4.0"
}
}
// claude_desktop_config.json or openclaw config
{
"mcpServers": {
"app-one": {
"command": "node",
"args": ["./mcp-servers/app-one-mcp/dist/index.js"],
"env": {
"APP_ONE_API_URL": "http://localhost:3001/api",
"APP_ONE_SERVICE_TOKEN": "${APP_ONE_SERVICE_TOKEN}"
}
},
"app-two": {
"command": "node",
"args": ["./mcp-servers/app-two-mcp/dist/index.js"],
"env": {
"APP_TWO_API_URL": "http://localhost:3002/api",
"APP_TWO_SERVICE_TOKEN": "${APP_TWO_SERVICE_TOKEN}"
}
}
}
}
Use when: app-two needs data from app-one in real-time, within a single request lifecycle.
// Already shown in Part 4 — use @repo/sdk
import { createAppOneClient } from "@repo/sdk/app-one";
Use when: apps need to react to changes in other apps asynchronously.
// packages/contracts/src/events/webhook.ts
import type { BaseEvent } from "./index";
export interface WebhookConfig {
url: string;
secret: string;
events: string[]; // event types to subscribe to
}
// Emit event to registered webhooks
export async function emitEvent(
event: BaseEvent,
webhooks: WebhookConfig[]
): Promise<void> {
const relevant = webhooks.filter((wh) =>
wh.events.includes(event.type) || wh.events.includes("*")
);
await Promise.allSettled(
relevant.map((wh) =>
fetch(wh.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Secret": wh.secret,
"X-Event-Type": event.type,
"X-Event-ID": event.id,
},
body: JSON.stringify(event),
})
)
);
}
Use when: apps share a database and need access to the same tables.
// Both apps import from @repo/contracts for type safety
import { SharedUserSchema } from "@repo/contracts/schemas";
// App-one writes
await supabase.schema("shared").from("users").insert(newUser);
// App-two reads
const { data } = await supabase.schema("shared").from("users").select("*");
const users = data.map((u) => SharedUserSchema.parse(u));
When the user says "add a new app," follow this sequence:
# 1. Create the app directory
mkdir -p apps/app-new
# 2. Scaffold based on detected framework preference
# For Next.js:
cd apps/app-new && npx create-next-app@latest . --typescript --tailwind --app --eslint --src-dir
# 3. Add shared dependencies
cd apps/app-new
pnpm add @repo/contracts @repo/auth @repo/sdk
# 4. Create the OpenAPI spec
cp packages/api-specs/_template.openapi.yaml packages/api-specs/app-new.openapi.yaml
# 5. Generate the SDK
pnpm --filter @repo/sdk generate:app-new
# 6. Scaffold the MCP server
mkdir -p mcp-servers/app-new-mcp/src/tools
# ... generate boilerplate files
# Add a new entity type to contracts
# 1. Create the type file
touch packages/contracts/src/types/new-entity.ts
# 2. Create the schema file
touch packages/contracts/src/schemas/new-entity.schema.ts
# 3. Export from index files
# 4. Rebuild contracts
pnpm --filter @repo/contracts build
# Add a new tool to an existing MCP server
touch mcp-servers/app-one-mcp/src/tools/new-operation.ts
# Follow the tool template from Part 7
# Register in index.ts
# Update mcp.json manifest
Before considering the integration work complete, verify:
@repo/contracts@redocly/cli lint@repo/contracts typesApiError format@repo/auth for token verificationCROSS_APP_SECRET is at least 32 characters@repo/sdk (not direct API calls)@repo/contractsmcp.json) is up to dateturbo.json has correct dependency graphpnpm-workspace.yaml includes all directoriespnpm turbo build succeeds with no errorspnpm turbo typecheck succeeds with no errors@repo/contracts, never duplicate across appsversion: "1.0" in every event)@repo/contracts.env, .env.local, or any credential file directlyprocess.env.*CROSS_APP_SECRET or any service tokens to gitaud (audience) claim restricting which app can use them