Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d0a8300
feat(slack-agent): eve-based Slack agent with Railway self-deploy
JeremyFunk Jul 21, 2026
a30abe8
fix(slack-agent): use a model that streams structured tool calls
JeremyFunk Jul 21, 2026
966dea7
Implement basic slack agent
JeremyFunk Jul 21, 2026
13f7f43
chore: include alerting worker in dev:essentials
JeremyFunk Jul 21, 2026
995a7db
Refactor
JeremyFunk Jul 22, 2026
0c2d931
fix(mcp): emit object-typed inputSchema for no-param tools
JeremyFunk Jul 22, 2026
7781b51
fix(web): purge foreign Clerk instance cookies to stop cross-env 403s
JeremyFunk Jul 22, 2026
8f76c0c
Merge origin/main into feat/slack-agent
JeremyFunk Jul 22, 2026
a159e67
fix(electric-sync): 503 on half-configured Electric Cloud creds
JeremyFunk Jul 22, 2026
534bf63
Merge remote-tracking branch 'origin/main' into feat/slack-agent
JeremyFunk Jul 22, 2026
1a7010c
Refactors & improvements
JeremyFunk Jul 23, 2026
d1a4125
Parity with chat-flue
JeremyFunk Jul 23, 2026
9b569e4
Fix deployment
JeremyFunk Jul 23, 2026
e5f53ec
Fix slack deployment
JeremyFunk Jul 23, 2026
6e1dd08
ci(preview): reuse PlanetScale PR branch with in-place SQL reset
JeremyFunk Jul 23, 2026
805d3be
ci(preview): raise PlanetScale ready-poll budget to 15 min
JeremyFunk Jul 23, 2026
6a32fae
ci(preview): match electric-pr-branch.ts to the real @electric-sql/cl…
JeremyFunk Jul 23, 2026
8164ff6
ci(preview): retry Electric env create while the deleted name is stil…
JeremyFunk Jul 23, 2026
1bb511b
ci(preview): reuse the Electric PR environment; reset services instead
JeremyFunk Jul 23, 2026
d5b8fe4
ci(preview): pass Electric a postgresql:// scheme database URL
JeremyFunk Jul 23, 2026
9566092
ci(preview): isolate the Electric create-source validation failure in…
JeremyFunk Jul 23, 2026
0b945ed
ci(preview): hand Electric an sslmode=require connection string
JeremyFunk Jul 23, 2026
60f196b
ci(preview): log the PR branch's replication state before the Electri…
JeremyFunk Jul 23, 2026
66e2b38
ci(preview): grant the CI role REPLICATION before creating the Electr…
JeremyFunk Jul 23, 2026
30d23b5
ci(preview): mint the PlanetScale CI role with REPLICATION
JeremyFunk Jul 23, 2026
888c960
ci(preview): own the Electric source activation wait
JeremyFunk Jul 23, 2026
fd66b9b
ci(preview): surface failover-slot prerequisites + slot state around …
JeremyFunk Jul 23, 2026
af913d2
ci(preview): set Electric-required cluster parameters on the PR branch
JeremyFunk Jul 23, 2026
56e790d
ci(preview): degrade gracefully when the CI token cannot resize the b…
JeremyFunk Jul 23, 2026
b014273
ci(preview): probe logical-slot creation before the Electric create
JeremyFunk Jul 23, 2026
9524eff
ci(preview): split the Electric credential off the main CI role
JeremyFunk Jul 23, 2026
d9e579b
Fix MCP list for slack agent
JeremyFunk Jul 23, 2026
375e3a5
ci(preview): publish synced tables to Electric Cloud's own publication
JeremyFunk Jul 23, 2026
539a5dd
Render slack-agent charts in the Maple dark theme
JeremyFunk Jul 23, 2026
13a1fe0
Fix blank chart text: pass explicit fontDirs to resvg
JeremyFunk Jul 23, 2026
8f04d87
ci(preview): assume table-owner roles before publishing to the cloud …
JeremyFunk Jul 23, 2026
a59cc03
ci(preview): reassign synced tables to postgres before cloud-publicat…
JeremyFunk Jul 23, 2026
63345d1
Add sweep
JeremyFunk Jul 23, 2026
48ddeae
ci(preview): normalize branch ownership to postgres so the in-place r…
JeremyFunk Jul 23, 2026
69db763
ci(preview): empty commit to exercise the in-place branch recycle path
JeremyFunk Jul 24, 2026
8d1b1fc
ci(preview): address review findings on the branch-lifecycle scripts
JeremyFunk Jul 24, 2026
cbcb5b6
feat(alerts): redesign Slack alert messages per Block Kit guidance
JeremyFunk Jul 24, 2026
ff169ac
Merge remote-tracking branch 'origin/main' into feat/slack-agent
JeremyFunk Jul 24, 2026
2b3da45
feat(alerts): align default notification templates with the redesigne…
JeremyFunk Jul 24, 2026
003fee3
fix(alerts): stop Slack rendering the fallback text as a duplicate line
JeremyFunk Jul 24, 2026
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ INGEST_REQUIRE_TLS=false
# HAZEL_OAUTH_CLIENT_SECRET=
# HAZEL_OAUTH_SCOPES=openid email profile organizations:read channels:read channel-webhooks:write

# Slack integration (bot install via OAuth v2)
# Maple installs a Slack app into a workspace via OAuth. Create a Slack app at
# https://api.slack.com/apps, add the bot scopes, and set the redirect URL to
# <API_BASE>/oauth/slack/callback. Both values are optional — the integration
# stays disabled until they are set.
# SLACK_CLIENT_ID=
# SLACK_CLIENT_SECRET=
# Optional dedicated bearer secret for the internal Slack-bot resolve endpoint
# (`GET /internal/slack/workspaces/:teamId`), so the Railway-hosted Slack agent
# can be given a secret distinct from INTERNAL_SERVICE_TOKEN. Falls back to
# INTERNAL_SERVICE_TOKEN when unset.
# SLACK_INTERNAL_SERVICE_TOKEN=

# GitHub integration (GitHub App)
# Maple connects to GitHub via a GitHub App for repo access, OAuth sign-in, and
# webhooks. Create one at https://github.com/settings/apps (or your org's
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ apps/api/.data/
apps/api/scripts/.bench/

.alchemy
# eve agent build/model-catalog cache
.eve
.mcp.json
.dev.vars
apps/chat-agent/.dev.vars
Expand Down
3 changes: 3 additions & 0 deletions apps/api/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) =>
...optionalPlain("HAZEL_OAUTH_CLIENT_ID"),
...optionalSecret("HAZEL_OAUTH_CLIENT_SECRET"),
...optionalPlain("HAZEL_OAUTH_SCOPES"),
// Slack integration (bot install via OAuth v2)
...optionalPlain("SLACK_CLIENT_ID"),
...optionalSecret("SLACK_CLIENT_SECRET"),
...optionalPlain("GITHUB_APP_ID"),
...optionalPlain("GITHUB_APP_SLUG"),
...optionalSecret("GITHUB_APP_PRIVATE_KEY"),
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { HttpV2ApiKeysLive } from "./routes/v2/api-keys.http"
import { HttpV2AttributeMappingsLive } from "./routes/v2/attribute-mappings.http"
import { HttpV2DashboardsLive } from "./routes/v2/dashboards.http"
import { HttpV2IngestKeysLive } from "./routes/v2/ingest-keys.http"
import { HttpV2SlackIntegrationsLive } from "./routes/v2/integrations.http"
import { HttpV2ErrorIssuesLive } from "./routes/v2/error-issues.http"
import { HttpV2AnomaliesLive } from "./routes/v2/anomalies.http"
import { HttpV2InvestigationsLive } from "./routes/v2/investigations.http"
Expand Down Expand Up @@ -47,6 +48,7 @@ import { OAuthDiscoveryRouter } from "./routes/oauth-discovery.http"
import { HttpOrgClickHouseSettingsLive } from "./routes/org-clickhouse-settings.http"
import { HttpOrganizationsLive } from "./routes/organizations.http"
import { PlanetScaleWebhookRouter } from "./routes/planetscale-webhook.http"
import { SlackCallbackRouter, SlackInternalRouter } from "./routes/slack-integration.http"
import { PrometheusScrapeProxyRouter } from "./routes/prometheus-scrape-proxy.http"
import { ScraperInternalRouter } from "./routes/scraper-internal.http"
import { VcsWebhookRouter } from "./routes/vcs-webhook.http"
Expand Down Expand Up @@ -93,6 +95,7 @@ import { PlanetScaleWebhookQueue } from "./services/planetscale/PlanetScaleWebho
import { PlanetScaleOAuthService } from "./services/PlanetScaleOAuthService"
import { PlanetScaleService } from "./services/PlanetScaleService"
import { ScrapeTargetsService } from "./services/ScrapeTargetsService"
import { SlackIntegrationService } from "./services/SlackIntegrationService"
import { WarehouseQueryService } from "./lib/WarehouseQueryService"
import { OAuthStateRepository } from "./services/OAuthStateRepository"
import { GithubAppClient } from "./services/vcs/vendor/github/GithubAppClient"
Expand Down Expand Up @@ -207,6 +210,13 @@ const NotificationDispatcherLive = NotificationDispatcher.layer.pipe(
Layer.provideMerge(Layer.mergeAll(CoreServicesLive, EmailServiceLive)),
)

// Slack integration: OAuth install/callback, status, channels, uninstall, and
// the internal bot-resolve endpoint. Needs ApiKeysService (mint the bot key) +
// OAuthStateRepository (CSRF state) on top of the core services.
const SlackIntegrationServiceLive = SlackIntegrationService.layer.pipe(
Layer.provideMerge(Layer.mergeAll(CoreServicesLive, OAuthStateRepository.layer)),
)

const ErrorsServiceLive = ErrorsService.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
Expand Down Expand Up @@ -274,6 +284,7 @@ export const MainLive = Layer.mergeAll(
DigestServiceLive,
DemoServiceLive,
VcsServicesLive,
SlackIntegrationServiceLive,
)

const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe(
Expand Down Expand Up @@ -314,6 +325,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe(
HttpV2AlertDestinationsLive,
HttpV2AlertIncidentsLive,
HttpV2IngestKeysLive,
HttpV2SlackIntegrationsLive,
HttpV2ErrorIssuesLive,
HttpV2AttributeMappingsLive,
HttpV2ScrapeTargetsLive,
Expand All @@ -336,6 +348,8 @@ export const AllRoutes = Layer.mergeAll(
ApiRoutes,
ApiV2Routes,
IntegrationsCallbackRouter,
SlackCallbackRouter,
SlackInternalRouter,
OAuthDiscoveryRouter,
PlanetScaleWebhookRouter,
PrometheusScrapeProxyRouter,
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/lib/Env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export interface EnvShape {
readonly HAZEL_OAUTH_CLIENT_ID: Option.Option<string>
readonly HAZEL_OAUTH_CLIENT_SECRET: Option.Option<Redacted.Redacted<string>>
readonly HAZEL_OAUTH_SCOPES: string
readonly SLACK_CLIENT_ID: Option.Option<string>
readonly SLACK_CLIENT_SECRET: Option.Option<Redacted.Redacted<string>>
/**
* Optional dedicated bearer secret for the internal Slack-bot resolve
* endpoint, so the Railway bot can hold a token distinct from the
* MCP-internal `INTERNAL_SERVICE_TOKEN`. Falls back to that token when unset.
*/
readonly SLACK_INTERNAL_SERVICE_TOKEN: Option.Option<Redacted.Redacted<string>>
readonly GITHUB_APP_ID: Option.Option<string>
readonly GITHUB_APP_SLUG: Option.Option<string>
readonly GITHUB_APP_PRIVATE_KEY: Option.Option<Redacted.Redacted<string>>
Expand Down Expand Up @@ -126,6 +134,9 @@ const envConfig = Config.all({
"HAZEL_OAUTH_SCOPES",
"openid email profile organizations:read channels:read channel-webhooks:write",
),
SLACK_CLIENT_ID: optionalString("SLACK_CLIENT_ID"),
SLACK_CLIENT_SECRET: optionalRedacted("SLACK_CLIENT_SECRET"),
SLACK_INTERNAL_SERVICE_TOKEN: optionalRedacted("SLACK_INTERNAL_SERVICE_TOKEN"),
GITHUB_APP_ID: optionalString("GITHUB_APP_ID"),
GITHUB_APP_SLUG: optionalString("GITHUB_APP_SLUG"),
GITHUB_APP_PRIVATE_KEY: optionalRedacted("GITHUB_APP_PRIVATE_KEY"),
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/mcp/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ describe("MCP dispatcher", () => {
}),
)

it("emits an object-typed inputSchema for every tool (strict MCP clients require it)", () => {
for (const definition of mapleToolDefinitions) {
const inputSchema = toInputSchema(definition.schema)
expect(inputSchema.type, `${definition.name} inputSchema.type`).toBe("object")
}
})

it.effect("returns MCP validation feedback for invalid model tool input", () =>
Effect.gen(function* () {
const result = yield* callMcpTool("inspect_trace", {}) as unknown as Effect.Effect<
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/mcp/tools/mutating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
* The Flue chat agent wraps these so a model call returns a `proposed` marker
* instead of mutating (see `apps/chat-flue/src/lib/approval.ts`); the web client
* applies the real change via `POST /api/chat/apply`, which only accepts tools
* in this set. Keep the two lists in sync.
* in this set. The Slack agent gates the same set behind eve's native
* human-in-the-loop approval (`apps/slack-agent/agent/lib/approval.ts`).
*
* Keep in sync — three mirrored copies, no shared import:
* - apps/api/src/mcp/tools/mutating.ts (this file, the source of truth)
* - apps/chat-flue/src/lib/approval.ts
* - apps/slack-agent/agent/lib/approval.ts
*/
export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set([
// dashboards
Expand Down
22 changes: 19 additions & 3 deletions apps/api/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,25 @@ export interface MapleToolDefinition {

export const toInputSchema = (schema: Schema.Top): Record<string, unknown> => {
const document = Schema.toJsonSchemaDocument(schema)
return Object.keys(document.definitions).length > 0
? { ...document.schema, $defs: document.definitions }
: document.schema
const base =
Object.keys(document.definitions).length > 0
? { ...document.schema, $defs: document.definitions }
: document.schema
// MCP requires the top-level inputSchema to be an object schema (`type: "object"`).
// Effect emits `{ anyOf: [{ type: "object" }, { type: "array" }] }` for an empty
// `Struct({})` (a no-parameter tool), which strict MCP clients reject — the Vercel
// AI SDK's `tools/list` Zod validator fails on `inputSchema.type` and drops EVERY
// tool from the connection. Normalize a no-property struct to an explicit empty
// object schema. `$ref` roots (hoisted schemas) already carry a valid object type.
if (base.type !== "object" && !("$ref" in base)) {
return {
type: "object",
properties: {},
additionalProperties: false,
...("$defs" in base ? { $defs: base.$defs } : {}),
}
}
return base
}

const collectMapleToolDefinitions = (): ReadonlyArray<MapleToolDefinition> => {
Expand Down
Loading
Loading