Skip to content
Open
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
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 @@ -207,6 +207,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 @@ -92,6 +94,7 @@ import { PlanetScaleDiscoveryService } from "./services/PlanetScaleDiscoveryServ
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 @@ -205,6 +208,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 @@ -272,6 +282,7 @@ export const MainLive = Layer.mergeAll(
DigestServiceLive,
DemoServiceLive,
VcsServicesLive,
SlackIntegrationServiceLive,
)

const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe(
Expand Down Expand Up @@ -312,6 +323,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe(
HttpV2AlertDestinationsLive,
HttpV2AlertIncidentsLive,
HttpV2IngestKeysLive,
HttpV2SlackIntegrationsLive,
HttpV2ErrorIssuesLive,
HttpV2AttributeMappingsLive,
HttpV2ScrapeTargetsLive,
Expand All @@ -334,6 +346,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 @@ -18,6 +18,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
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