Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ apps/api/.data/

.alchemy
.mcp.json
.dev.vars
7 changes: 6 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ const HealthRouter = HttpLayerRouter.use((router) =>
router.add("GET", "/health", HttpServerResponse.text("OK")),
)

// Return 405 for GET /mcp so MCP Streamable HTTP clients skip SSE gracefully
const McpGetFallback = HttpLayerRouter.use((router) =>
router.add("GET", "/mcp", HttpServerResponse.empty({ status: 405 })),
)

const DocsRoute = HttpApiScalar.layerHttpLayerRouter({
api: MapleApi,
path: "/docs",
})

const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, AutumnRouter, McpLive).pipe(
const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, McpGetFallback, DocsRoute, AutumnRouter, McpLive).pipe(
Layer.provideMerge(
HttpLayerRouter.cors({
allowedOrigins: ["*"],
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/mcp/lib/resolve-tenant.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { timingSafeEqual } from "node:crypto"
import { ManagedRuntime, Effect, Layer } from "effect"
import type { TenantContext as McpTenantContext } from "@/lib/tenant-context"
import { AuthService } from "@/services/AuthService"
import { ApiKeysService } from "@/services/ApiKeysService"
import { Env } from "@/services/Env"
import { API_KEY_PREFIX } from "@maple/db"

const INTERNAL_SERVICE_PREFIX = "maple_svc_"

const EnvRuntime = ManagedRuntime.make(Env.Default)
const ApiKeyResolutionRuntime = ManagedRuntime.make(
ApiKeysService.Live.pipe(Layer.provide(Env.Default)),
)
Expand All @@ -31,6 +35,35 @@ const getBearerToken = (headers: Headers): string | undefined => {
export async function resolveMcpTenantContext(request: Request): Promise<McpTenantContext> {
const token = getBearerToken(request.headers)

// Internal service auth (e.g. chat agent)
if (token && token.startsWith(INTERNAL_SERVICE_PREFIX)) {
const provided = token.slice(INTERNAL_SERVICE_PREFIX.length)
const env = await EnvRuntime.runPromise(Env)
const expected = env.INTERNAL_SERVICE_TOKEN

if (
expected.length > 0 &&
provided.length === expected.length &&
timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
) {
const orgId = env.MAPLE_ORG_ID_OVERRIDE.length > 0
? env.MAPLE_ORG_ID_OVERRIDE
: request.headers.get("x-org-id")
if (!orgId) {
throw new Error("X-Org-Id header is required for internal service auth")
}

return {
orgId,
userId: "internal-service",
roles: [],
authMode: "self_hosted",
}
}

throw new Error("Invalid internal service token")
}

if (token && token.startsWith(API_KEY_PREFIX)) {
const resolved = await ApiKeyResolutionRuntime.runPromise(
ApiKeysService.resolveByKey(token),
Expand Down
15 changes: 11 additions & 4 deletions apps/api/src/mcp/tools/find-slow-traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { defaultTimeRange } from "../lib/time"
import { formatDurationMs, formatDurationFromMs, formatTable } from "../lib/format"
import { Effect } from "effect"

const SYSTEM_SPAN_PATTERNS = ["ClusterCron"]

function isSystemTrace(rootSpanName: string): boolean {
return SYSTEM_SPAN_PATTERNS.some((pattern) => rootSpanName.includes(pattern))
}

export function registerFindSlowTracesTool(server: McpToolRegistrar) {
server.tool(
"find_slow_traces",
Expand All @@ -31,7 +37,7 @@ export function registerFindSlowTracesTool(server: McpToolRegistrar) {
start_time: st,
end_time: et,
service,
limit: lim,
limit: 500,
}),
queryTinybird("traces_duration_stats", {
start_time: st,
Expand All @@ -43,9 +49,10 @@ export function registerFindSlowTracesTool(server: McpToolRegistrar) {
)

const stats = statsResult.data[0]
const traces = [...tracesResult.data].sort(
(a, b) => Number(b.durationMicros) - Number(a.durationMicros),
)
const traces = [...tracesResult.data]
.filter((t) => !isSystemTrace(t.rootSpanName))
.sort((a, b) => Number(b.durationMicros) - Number(a.durationMicros))
.slice(0, lim)

if (traces.length === 0) {
return { content: [{ type: "text", text: `No traces found in ${st} — ${et}` }] }
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/services/Env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class Env extends Effect.Service<Env>()("Env", {
MAPLE_ORG_ID_OVERRIDE: yield* Config.string("MAPLE_ORG_ID_OVERRIDE").pipe(Config.withDefault("")),
AUTUMN_SECRET_KEY: yield* Config.string("AUTUMN_SECRET_KEY").pipe(Config.withDefault("")),
SD_INTERNAL_TOKEN: yield* Config.string("SD_INTERNAL_TOKEN").pipe(Config.withDefault("")),
INTERNAL_SERVICE_TOKEN: yield* Config.string("INTERNAL_SERVICE_TOKEN").pipe(Config.withDefault("")),
} as const

if (env.MAPLE_AUTH_MODE.toLowerCase() !== "clerk" && env.MAPLE_ROOT_PASSWORD.trim().length === 0) {
Expand Down
46 changes: 46 additions & 0 deletions apps/chat-agent/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import alchemy from "alchemy"
import { Worker, DurableObjectNamespace } from "alchemy/cloudflare"
import { CloudflareStateStore } from "alchemy/state"

const app = await alchemy("maple-chat-agent", {
...(process.env.ALCHEMY_STATE_TOKEN
? { stateStore: (scope) => new CloudflareStateStore(scope) }
: {}),
})

const chatAgentDO = DurableObjectNamespace("chat-agent-do", {
className: "ChatAgent",
sqlite: true,
})

const domains =
app.stage === "prd"
? [{ domainName: "chat.maple.dev", adopt: true }]
: app.stage === "stg"
? [{ domainName: "chat-staging.maple.dev", adopt: true }]
: undefined

const workerName =
app.stage === "prd"
? "maple-chat-agent"
: app.stage === "stg"
? "maple-chat-agent-stg"
: `maple-chat-agent-${app.stage}`

export const chatWorker = await Worker("chat-agent", {
name: workerName,
entrypoint: "./src/index.ts",
compatibility: "node",
url: true,
bindings: {
ChatAgent: chatAgentDO,
MAPLE_API_URL: process.env.MAPLE_API_URL ?? "http://localhost:3472",
INTERNAL_SERVICE_TOKEN: alchemy.secret(process.env.INTERNAL_SERVICE_TOKEN),
OPENROUTER_API_KEY: alchemy.secret(process.env.OPENROUTER_API_KEY),
},
domains,
adopt: true,
})

console.log({ stage: app.stage, chatWorkerUrl: chatWorker.url })
await app.finalize()
24 changes: 24 additions & 0 deletions apps/chat-agent/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@maple/chat-agent",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy:stack": "alchemy deploy",
"destroy:stack": "alchemy destroy",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai-compatible": "^2.0.30",
"@cloudflare/ai-chat": "^0.1.3",
"agents": "^0.5.1",
"ai": "^6.0.97"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250214.0",
"alchemy": "https://pkg.pr.new/Makisuo/alchemy@e3f48848",
"typescript": "^5.9.3",
"wrangler": "^4.14.4"
}
}
93 changes: 93 additions & 0 deletions apps/chat-agent/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AIChatAgent } from "@cloudflare/ai-chat"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createMCPClient } from "@ai-sdk/mcp"
import { convertToModelMessages, streamText, stepCountIs, type StreamTextOnFinishCallback } from "ai"
import { routeAgentRequest } from "agents"
import type { Env } from "./lib/types"
import { SYSTEM_PROMPT } from "./lib/system-prompt"

export { ChatAgent }

class ChatAgent extends AIChatAgent<Env> {
async onChatMessage(
onFinish: Parameters<AIChatAgent<Env>["onChatMessage"]>[0],
options?: Parameters<AIChatAgent<Env>["onChatMessage"]>[1],
) {
const orgId = (options?.body as Record<string, unknown>)?.orgId as string | undefined
if (!orgId) {
throw new Error("orgId is required in the request body")
}

const mcpUrl = `${this.env.MAPLE_API_URL}/mcp`
console.log(`[chat-agent] Connecting to MCP server at ${mcpUrl} for org ${orgId}`)

const mcpClient = await createMCPClient({
transport: {
type: "http",
url: mcpUrl,
headers: {
Authorization: `Bearer maple_svc_${this.env.INTERNAL_SERVICE_TOKEN}`,
"X-Org-Id": orgId,
},
},
onUncaughtError: (error) => {
console.error("[chat-agent] MCP uncaught error:", error)
},
})

let tools: Awaited<ReturnType<typeof mcpClient.tools>>
try {
tools = await mcpClient.tools()
console.log(`[chat-agent] Loaded ${Object.keys(tools).length} tools from MCP server`)
} catch (error) {
await mcpClient.close()
console.error("[chat-agent] Error loading tools:", error)
throw error
}

const openrouter = createOpenAICompatible({
name: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
apiKey: this.env.OPENROUTER_API_KEY,
})

const result = streamText({
model: openrouter.chatModel("moonshotai/kimi-k2.5:nitro"),
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(this.messages),
tools,
stopWhen: stepCountIs(10),
onFinish: async (event) => {
await mcpClient.close()
;(onFinish as unknown as StreamTextOnFinishCallback<typeof tools>)(event)
},
})

return result.toUIMessageStreamResponse()
}
}

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}

export default {
async fetch(request: Request, env: Env, _ctx: ExecutionContext) {
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders })
}

const response = await routeAgentRequest(request, env)
if (response) {
const newResponse = new Response(response.body, response)
for (const [key, value] of Object.entries(corsHeaders)) {
newResponse.headers.set(key, value)
}
return newResponse
}

return new Response("Not Found", { status: 404 })
},
} satisfies ExportedHandler<Env>
31 changes: 31 additions & 0 deletions apps/chat-agent/src/lib/system-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const SYSTEM_PROMPT = `You are Maple AI, an observability debugging assistant embedded in the Maple platform.

You help users investigate and understand their distributed systems by analyzing traces, logs, metrics, and errors collected via OpenTelemetry.

## Capabilities
- Check overall system health and error rates
- List and compare services with latency/throughput metrics
- Deep-dive into individual services (errors, logs, traces, Apdex)
- Find and categorize errors across the system
- Investigate specific error types with sample traces and logs
- Search and filter traces by duration, status, service, HTTP method
- Find the slowest traces with percentile benchmarks
- Inspect individual traces with full span trees and correlated logs
- Search logs by service, severity, text content, or trace ID
- Discover available metrics with type and data point counts

## Guidelines
- When the user asks about system health or "how things are going", start with the system_health tool
- When investigating a specific service, use diagnose_service for a comprehensive view
- When the user mentions an error, use find_errors first, then error_detail for specifics
- If the user is on a specific service or trace page (indicated by pageContext), use that context automatically
- When showing trace IDs, mention the user can click them in the Maple UI for full details

## Response Style
- Be concise. Lead with findings, not preamble
- DO NOT suggest next steps or follow-up actions unless the user explicitly asks what to do
- DO NOT narrate your tool calls or explain your investigation process
- Present data with context (time ranges, percentiles, comparisons) but skip unnecessary commentary
- Use markdown formatting: tables for comparisons, bold for key metrics, code for IDs
- Highlight anomalies and issues clearly, but let the user decide what to investigate next
`
8 changes: 8 additions & 0 deletions apps/chat-agent/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ChatAgent } from "../index"

export interface Env {
ChatAgent: DurableObjectNamespace<ChatAgent>
MAPLE_API_URL: string
INTERNAL_SERVICE_TOKEN: string
OPENROUTER_API_KEY: string
}
22 changes: 22 additions & 0 deletions apps/chat-agent/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}
27 changes: 27 additions & 0 deletions apps/chat-agent/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "maple-chat-agent",
"main": "src/index.ts",
"compatibility_date": "2025-02-04",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "public"
},
"durable_objects": {
"bindings": [
{
"name": "ChatAgent",
"class_name": "ChatAgent"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["ChatAgent"]
}
],
"vars": {
"MAPLE_API_URL": "http://localhost:3472"
}
}
10 changes: 10 additions & 0 deletions apps/web/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ if (!process.env.VITE_CLERK_PUBLISHABLE_KEY) {
process.env.VITE_API_BASE_URL = railway.apiUrl
process.env.VITE_INGEST_URL = railway.ingestUrl

const chatAgentUrl =
deploymentTarget.kind === "prd"
? "https://chat.maple.dev"
: deploymentTarget.kind === "stg"
? "https://chat-staging.maple.dev"
: process.env.VITE_CHAT_AGENT_URL ?? ""
if (chatAgentUrl) {
process.env.VITE_CHAT_AGENT_URL = chatAgentUrl
}

const webDomains =
deploymentTarget.kind === "prd"
? [
Expand Down
Loading