Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 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
8bb1866
Refactor
JeremyFunk Jul 27, 2026
5fa4387
Merge remote-tracking branch 'origin/main' into feat/slack-agent
JeremyFunk Jul 27, 2026
1776cd9
fix(tinybird): drop completed service_operations_minutely forward query
JeremyFunk Jul 27, 2026
bcda768
Add bot follow-up without metnion
JeremyFunk Jul 27, 2026
5ed1370
Fixes
JeremyFunk Jul 27, 2026
63f1ad8
Remvoe unnecessary tool
JeremyFunk Jul 27, 2026
fa63618
Refactors
JeremyFunk Jul 27, 2026
a48e068
Format
JeremyFunk Jul 27, 2026
45b1489
Merge remote-tracking branch 'origin/main' into feat/slack-agent
JeremyFunk Jul 27, 2026
3f2ffef
feat(slack-agent): strip __maple_ui payloads from MCP tool results
JeremyFunk Jul 27, 2026
35da2f5
Merge main into feat/slack-agent
JeremyFunk Jul 27, 2026
ed21776
merge migrations
JeremyFunk Jul 27, 2026
235e057
Fixes
JeremyFunk Jul 27, 2026
6bc36ad
fix(slack): apply review fixes; move Slack event intake to the Railwa…
JeremyFunk Jul 27, 2026
7c8c502
Merge remote-tracking branch 'origin/main' into feat/slack-agent
JeremyFunk Jul 27, 2026
746315e
test(api): provide SlackIntegrationServiceStubLayer in setup-audit ha…
JeremyFunk Jul 27, 2026
f863251
Increase timeout
JeremyFunk Jul 27, 2026
68227fa
docs: move the after-hours compute limit out of the shared CLAUDE.md
JeremyFunk Jul 27, 2026
a2089c7
feat(slack): surface remote disconnects on the integration status
JeremyFunk Jul 27, 2026
abe69cb
fix(slack): neutral copy for the remote-disconnect banner
JeremyFunk Jul 27, 2026
c2a7a26
Merge branch 'main' into feat/slack-agent
JeremyFunk Jul 28, 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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ 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=
# Dedicated bearer secret for the internal Slack-bot resolve endpoint
# (`GET /internal/slack/workspaces/:teamId`), which hands out decrypted bot
# tokens and a full-access Maple key. It never falls back to
# INTERNAL_SERVICE_TOKEN — the endpoint answers 401 until this is set.
# SLACK_INTERNAL_SERVICE_TOKEN=
# Slack only allows one Events API Request URL per app, and it's already
# pointed at the Railway-hosted bot (apps/slack-agent — see its own
# SLACK_SIGNING_SECRET). That bot detects app_uninstalled/tokens_revoked and
# calls this API's POST /internal/slack/workspaces/:teamId/revoke (same
# SLACK_INTERNAL_SERVICE_TOKEN auth as the resolve endpoint above) so the
# binding is dropped immediately instead of only via the 6-hourly
# reconciliation cron (worker.ts `scheduled`) backstop.

# 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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ jobs:
run: bun run check:bundle
working-directory: apps/web

slack-agent:
name: Slack agent
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2.4.4

# apps/slack-agent is deliberately outside the bun workspace (it
# self-deploys to Railway, not Cloudflare), so the root install and
# the `bun turbo` job above never reach it — it needs its own.
- run: bun install --frozen-lockfile
working-directory: apps/slack-agent

- run: bun run typecheck
working-directory: apps/slack-agent

# Includes the approval-list drift canary: a mutating MCP tool added
# on the API side without mirroring it here would otherwise ship
# ungated to Slack.
- run: bun test
working-directory: apps/slack-agent

clickhouse-schema-e2e:
name: ClickHouse schema E2E (${{ matrix.clickhouse }})
runs-on: blacksmith-4vcpu-ubuntu-2404
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
5 changes: 0 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@ Toolchain (bun/node/rust/python) is pinned in [`mise.toml`](mise.toml); `mise ru
first-time install + `.env.local` + portless CA. mise is optional but bump versions there when
upgrading a runtime (keep `bun` in sync with `packageManager`).

**After 10 PM local time, don't run heavy local compute** — no full test suite (`bun run test`),
repo-wide `bun typecheck`, full builds, cargo builds, or `bun dev` across all apps. Scope to what's
needed instead (single-package test/typecheck via `--filter`, a single dev app) and tell the user
which heavy commands were skipped so they can run them later.

## Warehouse queries

**No Tinybird pipes/endpoints exist.** All backend queries use the ClickHouse DSL in
Expand Down
10 changes: 9 additions & 1 deletion apps/api/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) =>
// hourly — scrape_target_checks retention (was inline on the
// scrape-results write path; a busy target writes ~75k
// rows/day, so the 10k cap binds within hours)
crons: ["0 */12 * * *", "0 * * * *"],
// every 6h — Slack workspace reconciliation: backstop for
// SlackEventsRouter (app_uninstalled/tokens_revoked), which
// catches deliveries Slack never sent/retried through, or
// installs that predate the webhook
crons: ["0 */12 * * *", "0 * * * *", "0 */6 * * *"],
env: {
// Ref stages attach MAPLE_DB via worker.bind below.
...(mapleDb ? { MAPLE_DB: mapleDb } : {}),
Expand Down Expand Up @@ -219,6 +223,10 @@ 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"),
...optionalSecret("SLACK_INTERNAL_SERVICE_TOKEN"),
...optionalPlain("GITHUB_APP_ID"),
...optionalPlain("GITHUB_APP_SLUG"),
...optionalSecret("GITHUB_APP_PRIVATE_KEY"),
Expand Down
20 changes: 18 additions & 2 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 @@ -48,6 +49,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 @@ -95,6 +97,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 @@ -209,6 +212,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 @@ -279,6 +289,7 @@ export const MainLive = Layer.mergeAll(
DigestServiceLive,
DemoServiceLive,
VcsServicesLive,
SlackIntegrationServiceLive,
)

const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe(
Expand Down Expand Up @@ -319,6 +330,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe(
HttpV2AlertDestinationsLive,
HttpV2AlertIncidentsLive,
HttpV2IngestKeysLive,
HttpV2SlackIntegrationsLive,
HttpV2ErrorIssuesLive,
HttpV2AttributeMappingsLive,
HttpV2ScrapeTargetsLive,
Expand All @@ -342,6 +354,8 @@ export const AllRoutes = Layer.mergeAll(
ApiRoutes,
ApiV2Routes,
IntegrationsCallbackRouter,
SlackCallbackRouter,
SlackInternalRouter,
OAuthDiscoveryRouter,
PlanetScaleWebhookRouter,
PrometheusScrapeProxyRouter,
Expand All @@ -367,10 +381,12 @@ export const ApiAuthLive = Layer.mergeAll(ApiAuthorizationLayer, ApiAuthorizatio
// covers headers, not query parameters — so tracing these requests would retain
// a live bearer credential in telemetry. There is no per-attribute lever, so the
// auto server span is suppressed for them; each callback handler carries its own
// span with safe attributes instead (see `integrations.*OAuthCallback`).
// span with safe attributes instead (see `integrations.*OAuthCallback` and
// `slack.oauthCallback`). The second alternative must stay in sync with
// `SLACK_CALLBACK_PATH` — the Slack app install redirects there.
// `/oauth/authorize` is deliberately NOT here: its query carries no bearer
// credential, and `/oauth/token` + `/oauth/revoke` are POSTs (secrets in the body).
const OAUTH_CALLBACK_PATH = /^\/api\/integrations\/[^/]+\/callback(?:\?|$)/
const OAUTH_CALLBACK_PATH = /^(?:\/api\/integrations\/[^/]+\/callback|\/oauth\/slack\/callback)(?:\?|$)/

// The OTLP tracer/logger is constructed once at worker module scope and
// provided to the same runtime as the routes. This shared layer installs the
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/lib/Crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,25 @@ export const parseBase64Aes256GcmKey = <E>(raw: string, onError: (message: strin
catch: (error) => onError(error instanceof Error ? error.message : "Invalid encryption key"),
})

/**
* Optional additional authenticated data. AAD is authenticated but not stored,
* so it binds a ciphertext to the row it was written for: decryption only
* succeeds when the caller reconstructs the exact same bytes. Omitting it keeps
* the original (AAD-free) format — existing ciphertexts written without one must
* keep decrypting, so callers may only start passing an `aad` for columns with
* no live rows.
*/
export const encryptAes256Gcm = <E>(
plaintext: string,
encryptionKey: Buffer,
onError: (message: string) => E,
aad?: Buffer,
) =>
Effect.try({
try: () => {
const iv = randomBytes(12)
const cipher = createCipheriv("aes-256-gcm", encryptionKey, iv)
if (aad !== undefined) cipher.setAAD(aad)
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])

return {
Expand All @@ -45,10 +55,12 @@ export const encryptAes256Gcm = <E>(
catch: (error) => onError(error instanceof Error ? error.message : "Encryption failed"),
})

/** Counterpart to {@link encryptAes256Gcm} — `aad` must match the value used to encrypt. */
export const decryptAes256Gcm = <E>(
encrypted: EncryptedValue,
encryptionKey: Buffer,
onError: (message: string) => E,
aad?: Buffer,
) =>
Effect.try({
try: () => {
Expand All @@ -57,6 +69,7 @@ export const decryptAes256Gcm = <E>(
encryptionKey,
Buffer.from(encrypted.iv, "base64"),
)
if (aad !== undefined) decipher.setAAD(aad)
decipher.setAuthTag(Buffer.from(encrypted.tag, "base64"))

const plaintext = Buffer.concat([
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/lib/Env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ 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>>
/**
* Dedicated bearer secret for the internal Slack-bot resolve endpoint, kept
* distinct from the MCP-internal `INTERNAL_SERVICE_TOKEN` — there is no
* fallback to it, since that token must not unlock every org's bot token.
* The endpoint answers 401 while this is 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 +135,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
20 changes: 19 additions & 1 deletion apps/api/src/mcp/dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import type { InternalRpcToolNotFoundError } from "@maple/domain/internal-rpc"
import { callMcpTool, listMcpTools } from "./dispatcher"
import { mapleToolDefinitions, toInputSchema } from "./tools/registry"

describe("MCP dispatcher", () => {
it("publishes an object input schema for every tool", () => {
// Strict MCP clients (the Vercel AI SDK's `tools/list` validator) drop the
// WHOLE connection when any tool's inputSchema.type is not "object" — the
// offending tool names are collected so a failure names them.
const invalidSchemas = mapleToolDefinitions
.map((definition) => ({
name: definition.name,
Expand All @@ -29,6 +32,21 @@ describe("MCP dispatcher", () => {
}),
)

it("normalizes an empty Struct root and rejects a non-object root", () => {
// Effect emits `{ anyOf: [{type:"object"},{type:"array"}] }` — no `type` —
// for a no-parameter tool; that exact shape is normalized.
expect(toInputSchema(Schema.Struct({}))).toEqual({
type: "object",
properties: {},
additionalProperties: false,
})

// Anything else with a non-object root has parameters an empty object
// schema would erase, so registration fails loudly instead.
expect(() => toInputSchema(Schema.Literals(["a", "b"]))).toThrow(/object root/)
expect(() => toInputSchema(Schema.Array(Schema.String))).toThrow(/object root/)
})

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
47 changes: 44 additions & 3 deletions apps/api/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,52 @@ export interface MapleToolDefinition {
readonly handler: (params: unknown) => Effect.Effect<McpToolResult, McpToolError, any>
}

/**
* Effect emits exactly `{ anyOf: [{ type: "object" }, { type: "array" }] }` — no
* `type`, no `properties` — for an empty `Struct({})`. Matched structurally so
* the normalization below cannot swallow any other rootless schema.
*/
const isEmptyStructSchema = (base: Record<string, unknown>): boolean => {
if ("type" in base || "properties" in base) return false
const anyOf = base.anyOf
if (!Array.isArray(anyOf) || anyOf.length === 0) return false
return anyOf.every((member) => {
if (typeof member !== "object" || member === null) return false
const type = (member as { type?: unknown }).type
return Object.keys(member).length === 1 && (type === "object" || type === "array")
})
}

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"`).
// An empty `Struct({})` (a no-parameter tool) comes out untyped, 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 just that
// case. `$ref` roots (hoisted schemas) already carry a valid object type.
const record = base as Record<string, unknown>
if (isEmptyStructSchema(record)) {
return {
type: "object",
properties: {},
additionalProperties: false,
...("$defs" in record ? { $defs: record.$defs } : {}),
}
}
// A genuinely non-object root (a top-level `Schema.Union`/`Schema.Literals`/array)
// has parameters that an empty object schema would erase, publishing the tool to
// every MCP client as if it took none. Fail at registration instead — this runs at
// module init, so it surfaces in tests and at worker boot rather than in the wire.
if (record.type !== "object" && !("$ref" in record)) {
throw new Error(
`MCP tool input schemas must have an object root; got ${JSON.stringify(record).slice(0, 200)}. Wrap the tool input in a Schema.Struct.`,
)
}
return base
}

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