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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ The full specification is in [`spec/`](./spec/):
- [Web](./spec/integrations/web.md) — browser integration, postMessage, security tiers
- [Desktop](./spec/integrations/desktop.md) — Unix sockets, native messaging
- [MCP Interoperability](./spec/integrations/mcp.md) — MCP Apps bridge, proxy, discovery alignment
- [LLM Context Injection](./spec/integrations/llm-context.md) — ephemeral `<slop-state>` tail, prompt-cache-friendly framing

### Status and limits

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ This plugin provides **5 static MCP tools**:
| `app_action` | Perform a single action on an app node |
| `app_action_batch` | Perform multiple actions in one call |

App state is injected into context on every user message via a `UserPromptSubmit` hook. Claude reads affordances, paths, and parameter schemas from the injected state tree and uses the generic action tools to invoke them.
App state is injected into context on every user message via a `UserPromptSubmit` hook. The bridge renders the live `<slop-state>` / `<slop-apps-available>` blocks and writes them to `/tmp/claude-slop-plugin/context.txt` on every state change and on a 10s heartbeat; the hook emits the file verbatim and drops it once the file is more than 30s old, so a dead bridge stops injecting. Claude reads affordances, paths, and parameter schemas from the injected state tree and uses the generic action tools to invoke them.

## Comparison with `slop-native`

Expand Down
Original file line number Diff line number Diff line change
@@ -1,61 +1,28 @@
#!/usr/bin/env node

/**
* Hook script: reads the shared state file written by slop-bridge
* and outputs connected providers' state trees for context injection.
* Hook script: emits the framed SLOP context written by slop-bridge.
*
* Called by the UserPromptSubmit hook on every user message.
* Outputs nothing if no providers are connected/discovered or if the state is stale.
* The bridge owns rendering — this script is just a stale-aware reader.
* Outputs nothing if the file is missing, empty, or older than the freshness
* threshold. Stale-detection uses file mtime, no JSON parsing.
*/

import fs from "node:fs";

const STATE_FILE = "/tmp/claude-slop-plugin/state.json";
const STALE_THRESHOLD = 30_000; // 30 seconds
const CONTEXT_FILE = "/tmp/claude-slop-plugin/context.txt";
const STALE_THRESHOLD_MS = 30_000;

try {
if (!fs.existsSync(STATE_FILE)) process.exit(0);
if (!fs.existsSync(CONTEXT_FILE)) process.exit(0);

const raw = fs.readFileSync(STATE_FILE, "utf-8");
const data = JSON.parse(raw);
const stat = fs.statSync(CONTEXT_FILE);
if (Date.now() - stat.mtimeMs > STALE_THRESHOLD_MS) process.exit(0);

// Skip injection if state file is stale (MCP server may have crashed)
if (data.lastUpdated && Date.now() - data.lastUpdated > STALE_THRESHOLD) {
process.exit(0);
}
const content = fs.readFileSync(CONTEXT_FILE, "utf-8");
if (content.trim().length === 0) process.exit(0);

const hasProviders = data.providers && data.providers.length > 0;
const hasAvailable = data.available && data.available.length > 0;

if (!hasProviders && !hasAvailable) process.exit(0);

let output = "## SLOP Apps\n\n";

if (hasProviders) {
output += `${data.providers.length} app(s) connected. `;
output +=
"Use app_action or app_action_batch to act on apps. Call connect_app to refresh full state and available actions.\n\n";

for (const provider of data.providers) {
output += `### ${provider.name} (${provider.id})\n\n`;
if (provider.state && provider.state !== "(no state yet)") {
output += "```\n" + provider.state + "\n```\n\n";
} else {
output += "(awaiting state snapshot)\n\n";
}
}
}

if (hasAvailable) {
output += "### Available (not connected)\n\n";
for (const app of data.available) {
output += `- **${app.name}** (id: \`${app.id}\`, ${app.transport}, ${app.source})\n`;
}
output += "\nCall connect_app with an app name to connect.\n";
}

process.stdout.write(output);
process.stdout.write(content);
} catch {
// Silently exit — don't break the session if the state file is corrupt
process.exit(0);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { buildSlopContext } from "@slop-ai/discovery/context";
import { createDiscoveryService } from "@slop-ai/discovery/service";
import { createToolHandlers } from "@slop-ai/discovery/tools";
import { formatTree } from "@slop-ai/consumer";
import fs from "node:fs";
import path from "node:path";

Expand All @@ -31,7 +31,8 @@ import path from "node:path";
// ---------------------------------------------------------------------------

const STATE_DIR = "/tmp/claude-slop-plugin";
const STATE_FILE = path.join(STATE_DIR, "state.json");
const CONTEXT_FILE = path.join(STATE_DIR, "context.txt");
const LEGACY_STATE_FILE = path.join(STATE_DIR, "state.json");

// ---------------------------------------------------------------------------
// Logger (MCP servers must not write to stdout — use stderr)
Expand All @@ -53,49 +54,44 @@ const handlers = createToolHandlers(discovery);
// State file management (for hook-based context injection)
// ---------------------------------------------------------------------------

function writeStateFile() {
function writeContextFile() {
try {
fs.mkdirSync(STATE_DIR, { recursive: true });

const connected = discovery.getProviders();
const discovered = discovery.getDiscovered();
const connectedIds = new Set(connected.map((p) => p.id));

// Discovered but not connected
const available = discovered
.filter((d) => !connectedIds.has(d.id))
.map((d) => ({
id: d.id,
name: d.name,
transport: d.transport.type,
source: d.source ?? "local",
}));

if (connected.length === 0 && available.length === 0) {
if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
return;
if (fs.existsSync(LEGACY_STATE_FILE)) {
try {
fs.unlinkSync(LEGACY_STATE_FILE);
} catch {}
}

const providers = connected.map((p) => {
const tree = p.consumer.getTree(p.subscriptionId);
return {
id: p.id,
name: p.name,
state: tree ? formatTree(tree) : "(no state yet)",
};
});
const { stateTail, availableAppsTail } = buildSlopContext(discovery);
const parts = [stateTail, availableAppsTail].filter((t) => !!t);

fs.writeFileSync(STATE_FILE, JSON.stringify({ lastUpdated: Date.now(), providers, available }, null, 2));
if (parts.length === 0) {
if (fs.existsSync(CONTEXT_FILE)) fs.unlinkSync(CONTEXT_FILE);
return;
}

fs.writeFileSync(CONTEXT_FILE, parts.join("\n\n"));
} catch (err) {
log.error("Failed to write state file:", err.message);
log.error("Failed to write context file:", err.message);
}
}

// Update state file whenever state changes
// Update context file whenever state changes
discovery.onStateChange(() => {
writeStateFile();
writeContextFile();
});

// Heartbeat: re-render the context file periodically while we're alive so
// `generated_at` refreshes and the hook's stale-detection signals "bridge
// died", not "state hasn't changed recently". The tail is uncached by design.
const HEARTBEAT_INTERVAL_MS = 10_000;
const heartbeatTimer = setInterval(() => {
writeContextFile();
}, HEARTBEAT_INTERVAL_MS);
heartbeatTimer.unref?.();

// ---------------------------------------------------------------------------
// Static MCP Tool definitions
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -341,14 +337,14 @@ async function main() {
process.on("SIGINT", () => {
discovery.stop();
try {
fs.unlinkSync(STATE_FILE);
fs.unlinkSync(CONTEXT_FILE);
} catch {}
process.exit(0);
});
process.on("SIGTERM", () => {
discovery.stop();
try {
fs.unlinkSync(STATE_FILE);
fs.unlinkSync(CONTEXT_FILE);
} catch {}
process.exit(0);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Claude calls these directly — no proxy through meta-tools needed. Tools are re
2. When Claude calls `connect_app` with an app name, the service lazy-connects via the appropriate transport (WebSocket, Unix socket, or extension relay) and subscribes to the state tree.
3. `createDynamicTools` from `@slop-ai/discovery/tools` converts each connected app's affordances into namespaced MCP tools. The server notifies Claude via `tools/list_changed`.
4. Claude calls affordance tools directly (e.g. `excalidraw__elements__add_rectangle`). The server resolves each tool name to a provider + path + action and invokes it.
5. The `UserPromptSubmit` hook reads a shared state file (`/tmp/claude-slop-plugin/state.json`) that the MCP server updates whenever state changes, injecting live state into Claude's context.
5. The MCP server renders the live `<slop-state>` / `<slop-apps-available>` blocks and writes them to `/tmp/claude-slop-plugin/context.txt` on every state change and on a 10s heartbeat. The bundled `UserPromptSubmit` hook emits that file verbatim into Claude's context, and drops it once the file is more than 30s old so a dead bridge stops injecting.
6. When Claude calls `disconnect_app`, the provider is disconnected, its tools are removed, and state drops from the hook.

## Architecture
Expand Down
Original file line number Diff line number Diff line change
@@ -1,61 +1,28 @@
#!/usr/bin/env node

/**
* Hook script: reads the shared state file written by slop-bridge
* and outputs connected providers' state trees for context injection.
* Hook script: emits the framed SLOP context written by slop-bridge.
*
* Called by the UserPromptSubmit hook on every user message.
* Outputs nothing if no providers are connected/discovered or if the state is stale.
* The bridge owns rendering — this script is just a stale-aware reader.
* Outputs nothing if the file is missing, empty, or older than the freshness
* threshold. Stale-detection uses file mtime, no JSON parsing.
*/

import fs from "node:fs";

const STATE_FILE = "/tmp/claude-slop-plugin/state.json";
const STALE_THRESHOLD = 30_000; // 30 seconds
const CONTEXT_FILE = "/tmp/claude-slop-plugin/context.txt";
const STALE_THRESHOLD_MS = 30_000;

try {
if (!fs.existsSync(STATE_FILE)) process.exit(0);
if (!fs.existsSync(CONTEXT_FILE)) process.exit(0);

const raw = fs.readFileSync(STATE_FILE, "utf-8");
const data = JSON.parse(raw);
const stat = fs.statSync(CONTEXT_FILE);
if (Date.now() - stat.mtimeMs > STALE_THRESHOLD_MS) process.exit(0);

// Skip injection if state file is stale (MCP server may have crashed)
if (data.lastUpdated && Date.now() - data.lastUpdated > STALE_THRESHOLD) {
process.exit(0);
}
const content = fs.readFileSync(CONTEXT_FILE, "utf-8");
if (content.trim().length === 0) process.exit(0);

const hasProviders = data.providers && data.providers.length > 0;
const hasAvailable = data.available && data.available.length > 0;

if (!hasProviders && !hasAvailable) process.exit(0);

let output = "## SLOP Apps\n\n";

if (hasProviders) {
output += `${data.providers.length} app(s) connected. `;
output +=
"Use the app-specific tools registered for each connected app to act on them directly. Call connect_app to refresh state and tool registration if needed.\n\n";

for (const provider of data.providers) {
output += `### ${provider.name} (${provider.id})\n\n`;
if (provider.state && provider.state !== "(no state yet)") {
output += "```\n" + provider.state + "\n```\n\n";
} else {
output += "(awaiting state snapshot)\n\n";
}
}
}

if (hasAvailable) {
output += "### Available (not connected)\n\n";
for (const app of data.available) {
output += `- **${app.name}** (id: \`${app.id}\`, ${app.transport}, ${app.source})\n`;
}
output += "\nCall connect_app with an app name to connect.\n";
}

process.stdout.write(output);
process.stdout.write(content);
} catch {
// Silently exit — don't break the session if the state file is corrupt
process.exit(0);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { buildSlopContext } from "@slop-ai/discovery/context";
import { createDiscoveryService } from "@slop-ai/discovery/service";
import { createToolHandlers, createDynamicTools } from "@slop-ai/discovery/tools";
import { formatTree } from "@slop-ai/consumer";
import fs from "node:fs";
import path from "node:path";

Expand All @@ -29,7 +29,8 @@ import path from "node:path";
// ---------------------------------------------------------------------------

const STATE_DIR = "/tmp/claude-slop-plugin";
const STATE_FILE = path.join(STATE_DIR, "state.json");
const CONTEXT_FILE = path.join(STATE_DIR, "context.txt");
const LEGACY_STATE_FILE = path.join(STATE_DIR, "state.json");

// ---------------------------------------------------------------------------
// Logger (MCP servers must not write to stdout — use stderr)
Expand Down Expand Up @@ -57,49 +58,44 @@ let dynamicToolSet = createDynamicTools(discovery);
// State file management (for hook-based context injection)
// ---------------------------------------------------------------------------

function writeStateFile() {
function writeContextFile() {
try {
fs.mkdirSync(STATE_DIR, { recursive: true });

const connected = discovery.getProviders();
const discovered = discovery.getDiscovered();
const connectedIds = new Set(connected.map((p) => p.id));

// Discovered but not connected
const available = discovered
.filter((d) => !connectedIds.has(d.id))
.map((d) => ({
id: d.id,
name: d.name,
transport: d.transport.type,
source: d.source ?? "local",
}));

if (connected.length === 0 && available.length === 0) {
if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
return;
if (fs.existsSync(LEGACY_STATE_FILE)) {
try {
fs.unlinkSync(LEGACY_STATE_FILE);
} catch {}
}

const providers = connected.map((p) => {
const tree = p.consumer.getTree(p.subscriptionId);
return {
id: p.id,
name: p.name,
state: tree ? formatTree(tree) : "(no state yet)",
};
});
const { stateTail, availableAppsTail } = buildSlopContext(discovery);
const parts = [stateTail, availableAppsTail].filter((t) => !!t);

if (parts.length === 0) {
if (fs.existsSync(CONTEXT_FILE)) fs.unlinkSync(CONTEXT_FILE);
return;
}

fs.writeFileSync(STATE_FILE, JSON.stringify({ lastUpdated: Date.now(), providers, available }, null, 2));
fs.writeFileSync(CONTEXT_FILE, parts.join("\n\n"));
} catch (err) {
log.error("Failed to write state file:", err.message);
log.error("Failed to write context file:", err.message);
}
}

// Heartbeat: re-render the context file periodically while we're alive so
// `generated_at` refreshes and the hook's stale-detection signals "bridge
// died", not "state hasn't changed recently". The tail is uncached by design.
const HEARTBEAT_INTERVAL_MS = 10_000;
const heartbeatTimer = setInterval(() => {
writeContextFile();
}, HEARTBEAT_INTERVAL_MS);
heartbeatTimer.unref?.();

// Update state file + dynamic tools whenever state changes
discovery.onStateChange(() => {
const prevCount = dynamicToolSet.tools.length;
dynamicToolSet = createDynamicTools(discovery);
writeStateFile();
writeContextFile();

// Notify Claude that the tool list changed (new/removed affordances)
if (dynamicToolSet.tools.length !== prevCount) {
Expand Down Expand Up @@ -312,14 +308,14 @@ async function main() {
process.on("SIGINT", () => {
discovery.stop();
try {
fs.unlinkSync(STATE_FILE);
fs.unlinkSync(CONTEXT_FILE);
} catch {}
process.exit(0);
});
process.on("SIGTERM", () => {
discovery.stop();
try {
fs.unlinkSync(STATE_FILE);
fs.unlinkSync(CONTEXT_FILE);
} catch {}
process.exit(0);
});
Expand Down
Loading