Skip to content
Merged
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
33 changes: 33 additions & 0 deletions mcp-server/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Prefer MOLECULE_URL (the canonical MCP env var), fall back to PLATFORM_URL
// (what the workspace runtime already injects for heartbeat/register), and
// only then to localhost:8080. Injecting MOLECULE_URL at container provision
// is handled by platform/internal/provisioner/provisioner.go; this fallback
// chain protects older containers and host-side users alike. Fixes #67.
export const PLATFORM_URL =
process.env.MOLECULE_URL ||
process.env.PLATFORM_URL ||
"http://localhost:8080";

export async function apiCall(method: string, path: string, body?: unknown) {
try {
const res = await fetch(`${PLATFORM_URL}${path}`, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
return { error: `HTTP ${res.status}`, detail: text };
}
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { raw: text, status: res.status };
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`Molecule AI API error (${method} ${path}): ${msg}`);
return { error: `Platform unreachable at ${PLATFORM_URL}`, detail: msg };
}
}
1,688 changes: 40 additions & 1,648 deletions mcp-server/src/index.ts

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions mcp-server/src/tools/agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall } from "../api.js";

export async function handleChatWithAgent(params: { workspace_id: string; message: string }) {
const { workspace_id, message } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/a2a`, {
method: "message/send",
params: {
message: { role: "user", parts: [{ type: "text", text: message }] },
},
});
const parts = data?.result?.parts || [];
const text = parts
.filter((p: { kind?: string }) => p.kind === "text")
.map((p: { text?: string }) => p.text || "")
.join("\n");
return { content: [{ type: "text" as const, text: text || JSON.stringify(data, null, 2) }] };
}

export async function handleAssignAgent(params: { workspace_id: string; model: string }) {
const { workspace_id, model } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/agent`, { model });
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleReplaceAgent(params: { workspace_id: string; model: string }) {
const { workspace_id, model } = params;
const data = await apiCall("PATCH", `/workspaces/${workspace_id}/agent`, { model });
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleRemoveAgent(params: { workspace_id: string }) {
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/agent`);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleMoveAgent(params: { workspace_id: string; target_workspace_id: string }) {
const { workspace_id, target_workspace_id } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/agent/move`, { target_workspace_id });
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleGetModel(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/model`);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export function registerAgentTools(srv: McpServer) {
srv.tool(
"chat_with_agent",
"Send a message to a workspace agent and get a response",
{
workspace_id: z.string().describe("Workspace ID"),
message: z.string().describe("Message to send"),
},
handleChatWithAgent
);

srv.tool(
"assign_agent",
"Assign an AI model to a workspace",
{
workspace_id: z.string().describe("Workspace ID"),
model: z.string().describe("Model string (e.g., openrouter:anthropic/claude-3.5-haiku)"),
},
handleAssignAgent
);

srv.tool(
"replace_agent",
"Replace the model on an existing workspace agent",
{ workspace_id: z.string(), model: z.string() },
handleReplaceAgent
);

srv.tool(
"remove_agent",
"Remove the agent from a workspace",
{ workspace_id: z.string() },
handleRemoveAgent
);

srv.tool(
"move_agent",
"Move an agent from one workspace to another",
{ workspace_id: z.string(), target_workspace_id: z.string() },
handleMoveAgent
);

srv.tool(
"get_model",
"Get current model configuration for a workspace",
{ workspace_id: z.string() },
handleGetModel
);
}
75 changes: 75 additions & 0 deletions mcp-server/src/tools/approvals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall } from "../api.js";

export async function handleListPendingApprovals() {
const data = await apiCall("GET", "/approvals/pending");
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleDecideApproval(params: {
workspace_id: string;
approval_id: string;
decision: "approved" | "denied";
}) {
const { workspace_id, approval_id, decision } = params;
const data = await apiCall(
"POST",
`/workspaces/${workspace_id}/approvals/${approval_id}/decide`,
{ decision, decided_by: "mcp-client" }
);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleCreateApproval(params: {
workspace_id: string;
action: string;
reason?: string;
}) {
const { workspace_id, action, reason } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/approvals`, { action, reason });
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleGetWorkspaceApprovals(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/approvals`);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export function registerApprovalTools(srv: McpServer) {
srv.tool(
"list_pending_approvals",
"List all pending approval requests across workspaces",
{},
handleListPendingApprovals
);

srv.tool(
"decide_approval",
"Approve or deny a pending approval request",
{
workspace_id: z.string().describe("Workspace ID"),
approval_id: z.string().describe("Approval ID"),
decision: z.enum(["approved", "denied"]).describe("Decision"),
},
handleDecideApproval
);

srv.tool(
"create_approval",
"Create an approval request for a workspace",
{
workspace_id: z.string(),
action: z.string().describe("What needs approval"),
reason: z.string().optional().describe("Why it's needed"),
},
handleCreateApproval
);

srv.tool(
"get_workspace_approvals",
"List approval requests for a specific workspace",
{ workspace_id: z.string() },
handleGetWorkspaceApprovals
);
}
142 changes: 142 additions & 0 deletions mcp-server/src/tools/channels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall } from "../api.js";

export async function handleListChannelAdapters() {
const data = await apiCall("GET", `/channels/adapters`);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleListChannels(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/channels`);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleAddChannel(params: {
workspace_id: string;
channel_type: string;
config: string;
allowed_users?: string;
}) {
let config: unknown;
try { config = JSON.parse(params.config); } catch { return { content: [{ type: "text" as const, text: "Error: config is not valid JSON" }] }; }
const allowed_users = params.allowed_users ? params.allowed_users.split(",").map((s) => s.trim()).filter(Boolean) : [];
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/channels`, {
channel_type: params.channel_type,
config,
allowed_users,
});
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleUpdateChannel(params: {
workspace_id: string;
channel_id: string;
config?: string;
enabled?: boolean;
allowed_users?: string;
}) {
const body: Record<string, unknown> = {};
if (params.config) {
try { body.config = JSON.parse(params.config); } catch { return { content: [{ type: "text" as const, text: "Error: config is not valid JSON" }] }; }
}
if (params.enabled !== undefined) body.enabled = params.enabled;
if (params.allowed_users !== undefined) {
body.allowed_users = params.allowed_users.split(",").map((s) => s.trim()).filter(Boolean);
}
const data = await apiCall("PATCH", `/workspaces/${params.workspace_id}/channels/${params.channel_id}`, body);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleRemoveChannel(params: { workspace_id: string; channel_id: string }) {
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/channels/${params.channel_id}`);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleSendChannelMessage(params: {
workspace_id: string;
channel_id: string;
text: string;
}) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/channels/${params.channel_id}/send`, {
text: params.text,
});
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleTestChannel(params: { workspace_id: string; channel_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/channels/${params.channel_id}/test`, {});
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export async function handleDiscoverChannelChats(params: {
type: string;
config: Record<string, unknown>;
}) {
const data = await apiCall("POST", "/channels/discover", params);
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export function registerChannelTools(srv: McpServer) {
srv.tool("list_channel_adapters", "List available social channel adapters (Telegram, Slack, etc.)", {}, handleListChannelAdapters);

srv.tool("list_channels", "List social channels connected to a workspace", {
workspace_id: z.string().describe("Workspace ID"),
}, handleListChannels);

srv.tool(
"add_channel",
"Connect a social channel (Telegram, Slack, etc.) to a workspace. Messages on the channel will be forwarded to the agent.",
{
workspace_id: z.string().describe("Workspace ID"),
channel_type: z.string().describe("Channel type (e.g., 'telegram')"),
config: z.string().describe('Channel config as JSON string (e.g., \'{"bot_token":"123:ABC","chat_id":"-100"}\')'),
allowed_users: z.string().optional().describe("Comma-separated user IDs allowed to message (empty = allow all)"),
},
handleAddChannel
);

srv.tool(
"update_channel",
"Update a social channel's config, enabled state, or allowed users. Triggers hot reload.",
{
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
config: z.string().optional().describe("Updated config as JSON string"),
enabled: z.boolean().optional().describe("Enable or disable the channel"),
allowed_users: z.string().optional().describe("Comma-separated user IDs (replaces existing list)"),
},
handleUpdateChannel
);

srv.tool("remove_channel", "Remove a social channel from a workspace", {
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
}, handleRemoveChannel);

srv.tool(
"send_channel_message",
"Send an outbound message from a workspace to its connected social channel (e.g., proactive Telegram message).",
{
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
text: z.string().describe("Message text to send"),
},
handleSendChannelMessage
);

srv.tool("test_channel", "Send a test message to verify a social channel connection works", {
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
}, handleTestChannel);

srv.tool(
"discover_channel_chats",
"Auto-detect chat IDs / channels for a given bot token (e.g. Telegram). Useful before creating a workspace channel.",
{
type: z.string().describe("Channel type (telegram, slack, etc.)"),
config: z.record(z.unknown()).describe("Adapter-specific config (bot_token, etc.)"),
},
handleDiscoverChannelChats,
);
}
Loading
Loading