Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Dockerfile.openclaw
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM node:22-slim

RUN apt-get update && apt-get install -y git inotify-tools && rm -rf /var/lib/apt/lists/*

RUN npm install -g openclaw@2026.3.7
RUN npm install -g openclaw@2026.3.7 mcporter@0.7.3

COPY config/openclaw.json /root/.openclaw/openclaw.json
COPY config/ensure-gateway-token.js /ensure-gateway-token.js
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.pinchy
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ WORKDIR /app/packages/web
COPY packages/plugins/pinchy-files /openclaw-extensions/pinchy-files
COPY packages/plugins/pinchy-context /openclaw-extensions/pinchy-context
COPY packages/plugins/pinchy-audit /openclaw-extensions/pinchy-audit
COPY packages/plugins/pinchy-mcp /openclaw-extensions/pinchy-mcp

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:7777/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
Expand Down
188 changes: 188 additions & 0 deletions packages/plugins/pinchy-mcp/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock mcporter
vi.mock("mcporter", () => ({
createRuntime: vi.fn().mockResolvedValue({
callTool: vi.fn().mockResolvedValue({ text: "tool result" }),
close: vi.fn().mockResolvedValue(undefined),
}),
}));

import plugin from "./index";

interface RegisteredFactory {
name: string;
factory: (ctx: { agentId?: string }) => unknown;
}

function createMockApi(config: unknown) {
const registeredTools: RegisteredFactory[] = [];

return {
pluginConfig: config,
registerTool: vi.fn(
(factory: (ctx: { agentId?: string }) => unknown, opts?: { name?: string }) => {
registeredTools.push({ name: opts?.name ?? "unnamed", factory });
}
),
registeredTools,
};
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("pinchy-mcp plugin", () => {
it("has correct id and name", () => {
expect(plugin.id).toBe("pinchy-mcp");
expect(plugin.name).toBe("Pinchy MCP");
});

it("validates config schema", () => {
expect(plugin.configSchema.validate(null)).toEqual(
expect.objectContaining({ ok: false })
);
expect(
plugin.configSchema.validate({ mcporterConfigPath: "/path", agents: {} })
).toEqual(expect.objectContaining({ ok: true }));
});

it("skips registration when no config provided", () => {
const api = createMockApi(undefined);
plugin.register(api);
expect(api.registerTool).not.toHaveBeenCalled();
});

it("registers tools from agent config", () => {
const api = createMockApi({
mcporterConfigPath: "/config/mcporter.json",
agents: {
"agent-1": {
allowedMcpTools: [
{ serverId: "srv-1", serverSlug: "github", toolName: "create_issue" },
{ serverId: "srv-1", serverSlug: "github", toolName: "list_repos" },
],
},
},
});

plugin.register(api);

expect(api.registerTool).toHaveBeenCalledTimes(2);
expect(api.registeredTools.map((t) => t.name)).toContain("mcp_github_create_issue");
expect(api.registeredTools.map((t) => t.name)).toContain("mcp_github_list_repos");
});

it("factory returns null for unauthorized agent", () => {
const api = createMockApi({
mcporterConfigPath: "/config/mcporter.json",
agents: {
"agent-1": {
allowedMcpTools: [
{ serverId: "srv-1", serverSlug: "github", toolName: "create_issue" },
],
},
},
});

plugin.register(api);

const factory = api.registeredTools[0].factory;

// Authorized agent
expect(factory({ agentId: "agent-1" })).not.toBeNull();

// Unauthorized agent
expect(factory({ agentId: "agent-2" })).toBeNull();

// No agent ID
expect(factory({})).toBeNull();
});

it("deduplicates tools shared across agents", () => {
const api = createMockApi({
mcporterConfigPath: "/config/mcporter.json",
agents: {
"agent-1": {
allowedMcpTools: [
{ serverId: "srv-1", serverSlug: "github", toolName: "create_issue" },
],
},
"agent-2": {
allowedMcpTools: [
{ serverId: "srv-1", serverSlug: "github", toolName: "create_issue" },
],
},
},
});

plugin.register(api);

// Only one tool registered (not two)
expect(api.registerTool).toHaveBeenCalledTimes(1);

const factory = api.registeredTools[0].factory;
// Both agents can use it
expect(factory({ agentId: "agent-1" })).not.toBeNull();
expect(factory({ agentId: "agent-2" })).not.toBeNull();
});

it("tool execute returns text content on success", async () => {
const api = createMockApi({
mcporterConfigPath: "/config/mcporter.json",
agents: {
"agent-1": {
allowedMcpTools: [
{ serverId: "srv-1", serverSlug: "github", toolName: "create_issue" },
],
},
},
});

plugin.register(api);

const tool = api.registeredTools[0].factory({ agentId: "agent-1" }) as {
execute: (id: string, params: Record<string, unknown>) => Promise<{
content: Array<{ type: string; text: string }>;
}>;
};

const result = await tool.execute("call-1", { args: { title: "Bug" } });
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
});

it("tool execute returns error text on failure (never throws)", async () => {
// Override mcporter mock to throw
const mcporter = await import("mcporter");
vi.mocked(mcporter.createRuntime).mockResolvedValueOnce({
callTool: vi.fn().mockRejectedValue(new Error("Connection refused")),
close: vi.fn(),
} as never);

const api = createMockApi({
mcporterConfigPath: "/config/mcporter.json",
agents: {
"agent-1": {
allowedMcpTools: [
{ serverId: "srv-1", serverSlug: "github", toolName: "create_issue" },
],
},
},
});

plugin.register(api);

const tool = api.registeredTools[0].factory({ agentId: "agent-1" }) as {
execute: (id: string, params: Record<string, unknown>) => Promise<{
content: Array<{ type: string; text: string }>;
}>;
};

// Should NOT throw — returns error text
const result = await tool.execute("call-1", {});
expect(result.content[0].text).toContain("Error calling MCP tool");
expect(result.content[0].text).toContain("Connection refused");
});
});
183 changes: 183 additions & 0 deletions packages/plugins/pinchy-mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
interface PluginToolContext {
agentId?: string;
}

interface AgentMcpToolGrant {
serverId: string;
serverSlug: string;
toolName: string;
}

interface AgentMcpConfig {
allowedMcpTools: AgentMcpToolGrant[];
}

interface PluginConfig {
mcporterConfigPath: string;
agents: Record<string, AgentMcpConfig>;
}

interface PluginApi {
pluginConfig?: PluginConfig;
registerTool: (
factory: (ctx: PluginToolContext) => AgentTool | null,
opts?: { name?: string }
) => void;
}

interface AgentTool {
name: string;
label: string;
description: string;
parameters: Record<string, unknown>;
execute: (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal
) => Promise<{ content: Array<{ type: string; text: string }> }>;
}

interface McpRuntime {
listTools: (
server: string,
options?: { includeSchema?: boolean }
) => Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>>;
callTool: (
server: string,
toolName: string,
options?: { args?: Record<string, unknown>; timeoutMs?: number }
) => Promise<unknown>;
close: (server?: string) => Promise<void>;
}

const plugin = {
id: "pinchy-mcp",
name: "Pinchy MCP",
description: "Bridges MCP server tools into OpenClaw agents via mcporter.",
configSchema: {
validate: (value: unknown) => {
if (
value &&
typeof value === "object" &&
"mcporterConfigPath" in value &&
"agents" in value
) {
return { ok: true as const, value };
}
return {
ok: false as const,
errors: ["Missing 'mcporterConfigPath' or 'agents' key in config"],
};
},
},

register(api: PluginApi) {
const config = api.pluginConfig;
if (!config?.mcporterConfigPath || !config?.agents) {
console.warn("[pinchy-mcp] No config provided, skipping registration");
return;
}

// Collect all unique tool registrations needed across all agents
const toolRegistrations = new Map<
string,
{ serverSlug: string; toolName: string; agentIds: Set<string> }
>();

for (const [agentId, agentConfig] of Object.entries(config.agents)) {
for (const grant of agentConfig.allowedMcpTools) {
const toolKey = `mcp_${grant.serverSlug}_${grant.toolName}`;
const existing = toolRegistrations.get(toolKey);
if (existing) {
existing.agentIds.add(agentId);
} else {
toolRegistrations.set(toolKey, {
serverSlug: grant.serverSlug,
toolName: grant.toolName,
agentIds: new Set([agentId]),
});
}
}
}

// Lazy-initialize mcporter runtime on first tool call
let runtimePromise: Promise<McpRuntime> | null = null;

function getRuntime(): Promise<McpRuntime> {
if (!runtimePromise) {
runtimePromise = import("mcporter").then((m) =>
m.createRuntime({ configPath: config!.mcporterConfigPath })
);
runtimePromise.catch((err) => {
console.error("[pinchy-mcp] Failed to create mcporter runtime:", err);
runtimePromise = null; // Allow retry on next call
});
}
return runtimePromise;
}

// Register each unique tool with a factory that scopes by agent
for (const [toolKey, registration] of toolRegistrations) {
api.registerTool(
(ctx: PluginToolContext) => {
const agentId = ctx.agentId;
if (!agentId) return null;
if (!registration.agentIds.has(agentId)) return null;

return {
name: toolKey,
label: `${registration.serverSlug}: ${registration.toolName}`,
description: `MCP tool: ${registration.toolName} from ${registration.serverSlug}`,
parameters: {
type: "object",
properties: {
args: {
type: "object",
description: "Arguments to pass to the MCP tool",
additionalProperties: true,
},
},
},
async execute(
_toolCallId: string,
params: Record<string, unknown>
) {
try {
const runtime = await getRuntime();
const args = (params.args as Record<string, unknown>) ?? {};
const result = await runtime.callTool(
registration.serverSlug,
registration.toolName,
{ args, timeoutMs: 30000 }
);

// Convert result to text
const text =
typeof result === "string"
? result
: JSON.stringify(result, null, 2);

return { content: [{ type: "text", text }] };
} catch (error) {
const message =
error instanceof Error ? error.message : "MCP tool call failed";
console.error(
`[pinchy-mcp] Tool ${toolKey} error:`,
message
);
return {
content: [
{ type: "text", text: `Error calling MCP tool: ${message}` },
],
};
}
},
};
},
{ name: toolKey }
);
}
},
};

export default plugin;
Loading
Loading