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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@effect/platform-node-shared": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-node": "0.9.4",
"@mosaicjs/ai": "^0.8.0",
"@opencode-ai/sdk": "^1.3.15",
"@pierre/diffs": "catalog:",
"effect": "catalog:",
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
PreviewSnapshotToolkit,
PreviewStandardToolkit,
} from "./toolkits/preview/tools.ts";
import { MosaicToolkitHandlersLive } from "./toolkits/mosaic/handlers.ts";
import { MosaicToolkit } from "./toolkits/mosaic/tools.ts";

const unauthorized = HttpServerResponse.jsonUnsafe(
{
Expand Down Expand Up @@ -203,9 +205,14 @@ const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnaps
Layer.provide(PreviewSnapshotToolkitHandlersLive),
);

const MosaicToolkitRegistrationLive = McpServer.toolkit(MosaicToolkit).pipe(
Layer.provide(MosaicToolkitHandlersLive),
);

export const PreviewToolkitRegistrationLive = Layer.mergeAll(
PreviewStandardToolkitRegistrationLive,
PreviewSnapshotRegistrationLive,
MosaicToolkitRegistrationLive,
);

const McpTransportLive = McpServer.layerHttp({
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/mcp/toolkits/mosaic/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { catBlock, lsBlocks, validateSource } from "@mosaicjs/ai";
import * as Effect from "effect/Effect";

import { MosaicToolkit } from "./tools.ts";

// The Mosaic introspection handlers are pure functions over @mosaicjs/core, so
// each tool just wraps a synchronous result in Effect.succeed - no dependencies,
// no capability gate, no I/O.
export const MosaicToolkitHandlersLive = MosaicToolkit.toLayer({
mosaic_ls: (input) => Effect.succeed(lsBlocks(input.kind)),
mosaic_cat: (input) => Effect.succeed(catBlock(input.block)),
mosaic_validate: (input) => Effect.succeed(validateSource(input.source)),
});
32 changes: 32 additions & 0 deletions apps/server/src/mcp/toolkits/mosaic/tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, it } from "@effect/vitest";
import { Tool } from "effect/unstable/ai";

import { MosaicToolkit } from "./tools.ts";

// A provider (Anthropic/OpenAI) rejects an MCP tool whose parameters are not a
// top-level `{ type: "object" }` schema - which is exactly what an empty
// Schema.Struct({}) produced for mosaic_ls. Pin that every Mosaic tool exports a
// provider-compatible object schema so the tools actually load into the model.
it("exports provider-compatible object schemas for every Mosaic tool", () => {
const tools = Object.values(MosaicToolkit.tools);
expect(tools.length).toBe(3);
for (const tool of tools) {
const schema = Tool.getJsonSchema(tool) as {
readonly type?: unknown;
readonly properties?: Readonly<Record<string, unknown>>;
readonly anyOf?: unknown;
readonly oneOf?: unknown;
};
expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object");
expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined();
expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined();
expect(
schema.properties,
`${tool.name} must expose a properties object`,
).toBeTypeOf("object");
expect(
tool.description?.length ?? 0,
`${tool.name} should carry a useful description`,
).toBeGreaterThan(40);
}
});
52 changes: 52 additions & 0 deletions apps/server/src/mcp/toolkits/mosaic/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/unstable/ai";

// Mosaic introspection tools, surfaced to the agent over T3 Code's MCP host so
// it can learn a block's exact schema (and validate a draft) before emitting a
// ```mosaic artifact - instead of guessing prop shapes. The tool logic lives in
// @mosaicjs/ai; these are the read-only, side-effect-free MCP wrappers.

const introspectionTool = <T extends Tool.Any>(tool: T): T =>
tool
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true) as T;

export const MosaicLsTool = introspectionTool(
Tool.make("mosaic_ls", {
description:
"List every Mosaic block you can compose a ```mosaic artifact from, grouped by kind. Call this first when building a Mosaic artifact to see what is available. Pass kind to narrow to one group.",
parameters: Schema.Struct({
kind: Schema.optional(
Schema.String.annotate({
description: "Optional: one of layout, content, control, structure, data.",
}),
),
}),
success: Schema.String,
}).annotate(Tool.Title, "List Mosaic blocks"),
);

export const MosaicCatTool = introspectionTool(
Tool.make("mosaic_cat", {
description:
"Show one Mosaic block's full prop schema - prop names, types, enum values, nested shapes, which are required - plus a minimal example. Call before using a block you are unsure about (DataTable, Chart, Diagram, Timeline, Tabs) so you write the exact schema instead of guessing.",
parameters: Schema.Struct({
block: Schema.String.annotate({ description: 'The block name, e.g. "DataTable".' }),
}),
success: Schema.String,
}).annotate(Tool.Title, "Show a Mosaic block schema"),
);

export const MosaicValidateTool = introspectionTool(
Tool.make("mosaic_validate", {
description:
"Compile and validate a Mosaic artifact (the mosaic-jsx inside a ```mosaic fence, or the bare source) and return every error, or confirm it is sound. Run this on your draft before emitting so mistakes are caught and fixed first.",
parameters: Schema.Struct({
source: Schema.String.annotate({ description: "The mosaic-jsx source to validate." }),
}),
success: Schema.String,
}).annotate(Tool.Title, "Validate a Mosaic artifact"),
);

export const MosaicToolkit = Toolkit.make(MosaicLsTool, MosaicCatTool, MosaicValidateTool);
17 changes: 16 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ import {
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
import {
makeClaudeCapabilitiesCacheKey,
makeClaudeContinuationGroupKey,
resolveClaudeHomePath,
} from "./ClaudeHome.ts";
import { provisionMosaicSkill } from "../MosaicSkill.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
Expand Down Expand Up @@ -128,6 +133,16 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
instanceId,
});
const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;

// Deliver the Mosaic skill natively into <home>/.claude/skills, where the
// Claude Code SDK discovers it (settingSources includes "user"). Best
// effort: a skill-write failure must never keep the provider from coming
// up.
const claudeHome = yield* resolveClaudeHomePath(effectiveConfig);
yield* provisionMosaicSkill(path.join(claudeHome, ".claude", "skills")).pipe(
Effect.catch((cause) => Effect.logWarning("Failed to provision Mosaic skill.", { cause })),
);

const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
env: processEnv,
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
materializeCodexShadowHome,
resolveCodexHomeLayout,
} from "./CodexHomeLayout.ts";
import { provisionMosaicSkill } from "../MosaicSkill.ts";
const decodeCodexSettings = Schema.decodeSync(CodexSettings);

const DRIVER_KIND = ProviderDriverKind.make("codex");
Expand Down Expand Up @@ -139,6 +140,16 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
}),
),
);

// Deliver the Mosaic skill natively into the Codex home's skills dir,
// where Codex's `skills/list` discovers it (KNOWN_SHARED_DIRECTORIES
// symlinks it into the shadow home too). Best effort: a skill-write
// failure must never keep the provider from coming up.
const codexSkillsDir = (yield* Path.Path).join(homeLayout.sharedHomePath, "skills");
yield* provisionMosaicSkill(codexSkillsDir).pipe(
Effect.catch((cause) => Effect.logWarning("Failed to provision Mosaic skill.", { cause })),
);

const effectiveConfig = {
...config,
enabled,
Expand Down
Loading
Loading