feat: MCP server content + npm publish CI
This commit is contained in:
parent
a1cef41f85
commit
aa44c6b565
16
.github/workflows/publish.yml
vendored
Normal file
16
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
name: Publish to npm
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20', registry-url: 'https://registry.npmjs.org' }
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
- run: npm publish --access public
|
||||
env: { NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' }
|
||||
107
README.md
Normal file
107
README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Molecule AI MCP Server
|
||||
|
||||
MCP server that exposes Molecule AI platform operations as tools for AI coding agents.
|
||||
|
||||
## 20 Tools Available
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_workspaces` | List all workspaces with status and skills |
|
||||
| `create_workspace` | Create a new workspace (with optional template) |
|
||||
| `get_workspace` | Get workspace details |
|
||||
| `delete_workspace` | Delete workspace (cascades to children) |
|
||||
| `restart_workspace` | Restart offline/failed workspace |
|
||||
| `chat_with_agent` | Send message and get AI response |
|
||||
| `assign_agent` | Assign model to workspace |
|
||||
| `set_secret` | Set API key or env var |
|
||||
| `list_secrets` | List secret keys (no values) |
|
||||
| `list_files` | List workspace config files |
|
||||
| `read_file` | Read a config file |
|
||||
| `write_file` | Create or update a file |
|
||||
| `delete_file` | Delete file or folder |
|
||||
| `commit_memory` | Store fact (LOCAL/TEAM/GLOBAL) |
|
||||
| `search_memory` | Search workspace memories |
|
||||
| `list_templates` | List available templates |
|
||||
| `expand_team` | Expand workspace to team |
|
||||
| `collapse_team` | Collapse team to single workspace |
|
||||
| `list_pending_approvals` | List pending approval requests |
|
||||
| `decide_approval` | Approve or deny a request |
|
||||
|
||||
### Phase 30 — Remote agent (SaaS) management
|
||||
|
||||
Tools that surface workspaces with `runtime='external'` (agents that run on
|
||||
machines outside this platform's Docker network and join via HTTP).
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_remote_agents` | Filter the workspace list to remote agents only — id / status / url / heartbeat |
|
||||
| `get_remote_agent_state` | Lightweight `{status, paused, deleted}` projection — faster than `get_workspace` when you only need lifecycle |
|
||||
| `get_remote_agent_setup_command` | Emit a `WORKSPACE_ID=… PLATFORM_URL=… python3 …` bash one-liner an operator can paste into a remote shell |
|
||||
| `check_remote_agent_freshness` | Compare `last_heartbeat_at` against a threshold (default 90s) — returns `{fresh, seconds_since_heartbeat}` |
|
||||
|
||||
## Setup
|
||||
|
||||
### Claude Code
|
||||
|
||||
Add to your project's `.mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"molecule": {
|
||||
"command": "node",
|
||||
"args": ["./mcp-server/dist/index.js"],
|
||||
"env": {
|
||||
"MOLECULE_URL": "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Add to `.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"molecule": {
|
||||
"command": "node",
|
||||
"args": ["./mcp-server/dist/index.js"],
|
||||
"env": {
|
||||
"MOLECULE_URL": "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Codex / OpenCode
|
||||
|
||||
```bash
|
||||
# Run directly
|
||||
MOLECULE_URL=http://localhost:8080 node mcp-server/dist/index.js
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MOLECULE_URL` | `http://localhost:8080` | Platform API URL |
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
You: "Create an SEO agent workspace using the seo-agent template"
|
||||
Agent: [calls create_workspace with template="seo-agent"]
|
||||
|
||||
You: "Set the OpenRouter API key for the SEO workspace"
|
||||
Agent: [calls set_secret with key="OPENROUTER_API_KEY"]
|
||||
|
||||
You: "Ask the SEO agent to audit my homepage"
|
||||
Agent: [calls chat_with_agent with message="Audit https://example.com for SEO"]
|
||||
|
||||
You: "What skills does the coding agent have?"
|
||||
Agent: [calls get_workspace, reads agent_card.skills]
|
||||
```
|
||||
31
jest.config.cjs
Normal file
31
jest.config.cjs
Normal file
@ -0,0 +1,31 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
testMatch: ["**/__tests__/**/*.test.ts"],
|
||||
moduleNameMapper: {
|
||||
// Strip .js extensions from imports so ts-jest can resolve .ts files
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
// Map ESM-only MCP SDK imports to their CJS equivalents
|
||||
"^@modelcontextprotocol/sdk/server/mcp\\.js$":
|
||||
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js",
|
||||
"^@modelcontextprotocol/sdk/server/stdio\\.js$":
|
||||
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/stdio.js",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
tsconfig: {
|
||||
module: "CommonJS",
|
||||
moduleResolution: "node",
|
||||
esModuleInterop: true,
|
||||
strict: true,
|
||||
target: "ES2022",
|
||||
isolatedModules: true,
|
||||
},
|
||||
diagnostics: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
5559
package-lock.json
generated
Normal file
5559
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@molecule-ai/mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for Molecule AI Agent Team \u2014 manage workspaces, agents, and skills from any AI coding tool",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"molecule-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^30.3.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Molecule-AI/molecule-mcp-server.git"
|
||||
}
|
||||
}
|
||||
1147
src/__tests__/index.test.ts
Normal file
1147
src/__tests__/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
66
src/api.ts
Normal file
66
src/api.ts
Normal file
@ -0,0 +1,66 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* Shape returned by apiCall when the request fails (network error, non-2xx,
|
||||
* or non-JSON body with no error). Returned-by-value — apiCall never throws.
|
||||
*/
|
||||
export type ApiError = { error: string; detail?: string; raw?: string; status?: number };
|
||||
|
||||
export function isApiError(v: unknown): v is ApiError {
|
||||
return !!v && typeof v === "object" && "error" in (v as object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap arbitrary JSON-serialisable data in the MCP content envelope that
|
||||
* tool handlers must return. Centralised so every handler uses the exact
|
||||
* same shape (and a future switch to e.g. structured content happens once).
|
||||
*/
|
||||
export function toMcpResult(data: unknown) {
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plain string (file contents, assistant reply text, error message)
|
||||
* in the MCP content envelope without JSON-stringifying it. For the handful
|
||||
* of handlers that return raw text rather than a JSON blob.
|
||||
*/
|
||||
export function toMcpText(text: string) {
|
||||
return { content: [{ type: "text" as const, text }] };
|
||||
}
|
||||
|
||||
export async function apiCall<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T | ApiError> {
|
||||
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) as T;
|
||||
} catch {
|
||||
return { raw: text, status: res.status } as ApiError;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// stdio MCP servers must log to stderr; stdout is the protocol channel.
|
||||
console.error(`Molecule AI API error (${method} ${path}): ${msg}`);
|
||||
return { error: `Platform unreachable at ${PLATFORM_URL}`, detail: msg };
|
||||
}
|
||||
}
|
||||
216
src/index.ts
Normal file
216
src/index.ts
Normal file
@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Molecule AI MCP Server
|
||||
*
|
||||
* Exposes Molecule AI platform operations as MCP tools so any AI coding agent
|
||||
* (Claude Code, Cursor, Codex, OpenCode) can manage workspaces, agents,
|
||||
* skills, and memory.
|
||||
*
|
||||
* Transport: stdio (for local CLI integration)
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
import { PLATFORM_URL, apiCall } from "./api.js";
|
||||
import { registerWorkspaceTools } from "./tools/workspaces.js";
|
||||
import { registerAgentTools } from "./tools/agents.js";
|
||||
import { registerSecretTools } from "./tools/secrets.js";
|
||||
import { registerFileTools } from "./tools/files.js";
|
||||
import { registerMemoryTools } from "./tools/memory.js";
|
||||
import { registerPluginTools } from "./tools/plugins.js";
|
||||
import { registerChannelTools } from "./tools/channels.js";
|
||||
import { registerDelegationTools } from "./tools/delegation.js";
|
||||
import { registerScheduleTools } from "./tools/schedules.js";
|
||||
import { registerApprovalTools } from "./tools/approvals.js";
|
||||
import { registerDiscoveryTools } from "./tools/discovery.js";
|
||||
import { registerRemoteAgentTools } from "./tools/remote_agents.js";
|
||||
|
||||
// Re-exports so existing importers (tests, SDK consumers) keep working.
|
||||
// Explicit names (not `export *`) so tree-shakers and TS readers can see
|
||||
// exactly which handlers are part of the public surface, and a missing
|
||||
// export triggers a compile error instead of a silent undefined at import.
|
||||
export { PLATFORM_URL, apiCall, isApiError, toMcpResult, toMcpText } from "./api.js";
|
||||
export type { ApiError } from "./api.js";
|
||||
|
||||
export {
|
||||
registerWorkspaceTools,
|
||||
handleListWorkspaces,
|
||||
handleCreateWorkspace,
|
||||
handleGetWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
handleRestartWorkspace,
|
||||
handleUpdateWorkspace,
|
||||
handlePauseWorkspace,
|
||||
handleResumeWorkspace,
|
||||
} from "./tools/workspaces.js";
|
||||
|
||||
export {
|
||||
registerAgentTools,
|
||||
handleChatWithAgent,
|
||||
handleAssignAgent,
|
||||
handleReplaceAgent,
|
||||
handleRemoveAgent,
|
||||
handleMoveAgent,
|
||||
handleGetModel,
|
||||
} from "./tools/agents.js";
|
||||
|
||||
export {
|
||||
registerSecretTools,
|
||||
handleSetSecret,
|
||||
handleListSecrets,
|
||||
handleDeleteSecret,
|
||||
handleListGlobalSecrets,
|
||||
handleSetGlobalSecret,
|
||||
handleDeleteGlobalSecret,
|
||||
} from "./tools/secrets.js";
|
||||
|
||||
export {
|
||||
registerFileTools,
|
||||
handleListFiles,
|
||||
handleReadFile,
|
||||
handleWriteFile,
|
||||
handleDeleteFile,
|
||||
handleReplaceAllFiles,
|
||||
handleGetConfig,
|
||||
handleUpdateConfig,
|
||||
} from "./tools/files.js";
|
||||
|
||||
export {
|
||||
registerMemoryTools,
|
||||
handleCommitMemory,
|
||||
handleSearchMemory,
|
||||
handleDeleteMemory,
|
||||
handleSessionSearch,
|
||||
handleGetSharedContext,
|
||||
handleSetKV,
|
||||
handleGetKV,
|
||||
handleListKV,
|
||||
handleDeleteKV,
|
||||
} from "./tools/memory.js";
|
||||
|
||||
export {
|
||||
registerPluginTools,
|
||||
handleListPluginRegistry,
|
||||
handleListInstalledPlugins,
|
||||
handleInstallPlugin,
|
||||
handleUninstallPlugin,
|
||||
handleListPluginSources,
|
||||
handleListAvailablePlugins,
|
||||
handleCheckPluginCompatibility,
|
||||
} from "./tools/plugins.js";
|
||||
|
||||
export {
|
||||
registerChannelTools,
|
||||
handleListChannelAdapters,
|
||||
handleListChannels,
|
||||
handleAddChannel,
|
||||
handleUpdateChannel,
|
||||
handleRemoveChannel,
|
||||
handleSendChannelMessage,
|
||||
handleTestChannel,
|
||||
handleDiscoverChannelChats,
|
||||
} from "./tools/channels.js";
|
||||
|
||||
export {
|
||||
registerDelegationTools,
|
||||
handleAsyncDelegate,
|
||||
handleCheckDelegations,
|
||||
handleRecordDelegation,
|
||||
handleUpdateDelegationStatus,
|
||||
handleReportActivity,
|
||||
handleListActivity,
|
||||
handleNotifyUser,
|
||||
handleListTraces,
|
||||
} from "./tools/delegation.js";
|
||||
|
||||
export {
|
||||
registerScheduleTools,
|
||||
handleListSchedules,
|
||||
handleCreateSchedule,
|
||||
handleUpdateSchedule,
|
||||
handleDeleteSchedule,
|
||||
handleRunSchedule,
|
||||
handleGetScheduleHistory,
|
||||
} from "./tools/schedules.js";
|
||||
|
||||
export {
|
||||
registerApprovalTools,
|
||||
handleListPendingApprovals,
|
||||
handleDecideApproval,
|
||||
handleCreateApproval,
|
||||
handleGetWorkspaceApprovals,
|
||||
} from "./tools/approvals.js";
|
||||
|
||||
export {
|
||||
registerDiscoveryTools,
|
||||
handleListPeers,
|
||||
handleDiscoverWorkspace,
|
||||
handleCheckAccess,
|
||||
handleListEvents,
|
||||
handleListTemplates,
|
||||
handleListOrgTemplates,
|
||||
handleImportOrg,
|
||||
handleImportTemplate,
|
||||
handleExportBundle,
|
||||
handleImportBundle,
|
||||
handleGetViewport,
|
||||
handleSetViewport,
|
||||
handleExpandTeam,
|
||||
handleCollapseTeam,
|
||||
} from "./tools/discovery.js";
|
||||
|
||||
export {
|
||||
registerRemoteAgentTools,
|
||||
handleListRemoteAgents,
|
||||
handleGetRemoteAgentState,
|
||||
handleGetRemoteAgentSetupCommand,
|
||||
handleCheckRemoteAgentFreshness,
|
||||
} from "./tools/remote_agents.js";
|
||||
|
||||
export function createServer() {
|
||||
const srv = new McpServer({
|
||||
name: "molecule",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
registerWorkspaceTools(srv);
|
||||
registerAgentTools(srv);
|
||||
registerSecretTools(srv);
|
||||
registerFileTools(srv);
|
||||
registerMemoryTools(srv);
|
||||
registerPluginTools(srv);
|
||||
registerChannelTools(srv);
|
||||
registerDelegationTools(srv);
|
||||
registerScheduleTools(srv);
|
||||
registerApprovalTools(srv);
|
||||
registerDiscoveryTools(srv);
|
||||
registerRemoteAgentTools(srv);
|
||||
|
||||
return srv;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Validate platform connectivity on startup
|
||||
try {
|
||||
const res = await fetch(`${PLATFORM_URL}/health`);
|
||||
if (res.ok) {
|
||||
console.error(`Molecule AI platform connected: ${PLATFORM_URL}`);
|
||||
} else {
|
||||
console.error(`WARNING: Molecule AI platform at ${PLATFORM_URL} returned ${res.status}. Tools may fail.`);
|
||||
}
|
||||
} catch {
|
||||
console.error(`WARNING: Cannot reach Molecule AI platform at ${PLATFORM_URL}. Start it with: cd platform && go run ./cmd/server`);
|
||||
}
|
||||
|
||||
const server = createServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Molecule AI MCP server running on stdio (87 tools available)");
|
||||
}
|
||||
|
||||
// Only auto-start when run directly (not when imported for testing).
|
||||
// JEST_WORKER_ID is set automatically by Jest in every worker process.
|
||||
if (!process.env.JEST_WORKER_ID) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
101
src/tools/agents.ts
Normal file
101
src/tools/agents.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult, toMcpText } from "../api.js";
|
||||
|
||||
export async function handleChatWithAgent(params: { workspace_id: string; message: string }) {
|
||||
const { workspace_id, message } = params;
|
||||
const data = await apiCall<{ result?: { parts?: Array<{ kind?: string; text?: string }> } }>(
|
||||
"POST",
|
||||
`/workspaces/${workspace_id}/a2a`,
|
||||
{
|
||||
method: "message/send",
|
||||
params: {
|
||||
message: { role: "user", parts: [{ type: "text", text: message }] },
|
||||
},
|
||||
},
|
||||
);
|
||||
const parts = (data as { result?: { parts?: Array<{ kind?: string; text?: string }> } } | null)?.result?.parts || [];
|
||||
const text = parts
|
||||
.filter((p) => p.kind === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("\n");
|
||||
return text ? toMcpText(text) : toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleRemoveAgent(params: { workspace_id: string }) {
|
||||
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/agent`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetModel(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/model`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
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
src/tools/approvals.ts
Normal file
75
src/tools/approvals.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleListPendingApprovals() {
|
||||
const data = await apiCall("GET", "/approvals/pending");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetWorkspaceApprovals(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/approvals`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
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
src/tools/channels.ts
Normal file
142
src/tools/channels.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult, toMcpText } from "../api.js";
|
||||
|
||||
export async function handleListChannelAdapters() {
|
||||
const data = await apiCall("GET", `/channels/adapters`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListChannels(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/channels`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpText("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 toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpText("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 toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
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 toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDiscoverChannelChats(params: {
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
}) {
|
||||
const data = await apiCall("POST", "/channels/discover", params);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
183
src/tools/delegation.ts
Normal file
183
src/tools/delegation.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleAsyncDelegate(params: {
|
||||
workspace_id: string;
|
||||
target_id: string;
|
||||
task: string;
|
||||
}) {
|
||||
const { workspace_id, target_id, task } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/delegate`, { target_id, task });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleCheckDelegations(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/delegations`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleRecordDelegation(params: {
|
||||
workspace_id: string;
|
||||
target_id: string;
|
||||
task: string;
|
||||
delegation_id: string;
|
||||
}) {
|
||||
const { workspace_id, ...body } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/delegations/record`, body);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleUpdateDelegationStatus(params: {
|
||||
workspace_id: string;
|
||||
delegation_id: string;
|
||||
status: "completed" | "failed";
|
||||
error?: string;
|
||||
response_preview?: string;
|
||||
}) {
|
||||
const { workspace_id, delegation_id, ...body } = params;
|
||||
const data = await apiCall(
|
||||
"POST",
|
||||
`/workspaces/${workspace_id}/delegations/${delegation_id}/update`,
|
||||
body,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleReportActivity(params: {
|
||||
workspace_id: string;
|
||||
activity_type: string;
|
||||
method?: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
error_detail?: string;
|
||||
request_body?: unknown;
|
||||
response_body?: unknown;
|
||||
duration_ms?: number;
|
||||
}) {
|
||||
const { workspace_id, ...body } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/activity`, body);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListActivity(params: {
|
||||
workspace_id: string;
|
||||
type?: "a2a_receive" | "a2a_send" | "task_update" | "agent_log" | "error";
|
||||
limit?: number;
|
||||
}) {
|
||||
const { workspace_id, type, limit } = params;
|
||||
const urlParams = new URLSearchParams();
|
||||
if (type) urlParams.set("type", type);
|
||||
if (limit) urlParams.set("limit", String(limit));
|
||||
const qs = urlParams.toString() ? `?${urlParams.toString()}` : "";
|
||||
const data = await apiCall("GET", `/workspaces/${workspace_id}/activity${qs}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleNotifyUser(params: {
|
||||
workspace_id: string;
|
||||
type: string;
|
||||
[k: string]: unknown;
|
||||
}) {
|
||||
const { workspace_id, ...body } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/notify`, body);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListTraces(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/traces`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerDelegationTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"async_delegate",
|
||||
"Delegate a task to another workspace (non-blocking). Returns immediately with a delegation_id. The target workspace processes the task in the background. Use check_delegations to poll for results.",
|
||||
{
|
||||
workspace_id: z.string().describe("Source workspace ID (the delegator)"),
|
||||
target_id: z.string().describe("Target workspace ID to delegate to"),
|
||||
task: z.string().describe("Task description to send"),
|
||||
},
|
||||
handleAsyncDelegate
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"check_delegations",
|
||||
"Check status of delegated tasks for a workspace. Returns recent delegations with their status (pending/completed/failed) and results.",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleCheckDelegations
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"record_delegation",
|
||||
"Register an agent-initiated delegation with the platform's activity log. Used by agent tooling so GET /delegations sees the same set as check_delegation_status.",
|
||||
{
|
||||
workspace_id: z.string().describe("Source workspace ID (the delegator)"),
|
||||
target_id: z.string().describe("Target workspace ID (the delegate)"),
|
||||
task: z.string().describe("Task description sent to the target"),
|
||||
delegation_id: z.string().describe("Agent-generated task_id to correlate with local state"),
|
||||
},
|
||||
handleRecordDelegation,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"update_delegation_status",
|
||||
"Mirror an agent-initiated delegation's status to activity_logs (completed or failed).",
|
||||
{
|
||||
workspace_id: z.string().describe("Source workspace ID"),
|
||||
delegation_id: z.string().describe("Delegation ID previously registered via record_delegation"),
|
||||
status: z.enum(["completed", "failed"]),
|
||||
error: z.string().optional(),
|
||||
response_preview: z.string().optional().describe("Response text (truncated to 500 chars server-side)"),
|
||||
},
|
||||
handleUpdateDelegationStatus,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"report_activity",
|
||||
"Write an arbitrary activity log row from an agent (a2a events, tool calls, errors).",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
activity_type: z.string().describe("a2a_receive / a2a_send / tool_call / task_complete / error / ..."),
|
||||
method: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
status: z.string().optional().describe("ok / error / pending"),
|
||||
error_detail: z.string().optional(),
|
||||
request_body: z.unknown().optional(),
|
||||
response_body: z.unknown().optional(),
|
||||
duration_ms: z.number().optional(),
|
||||
},
|
||||
handleReportActivity,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"list_activity",
|
||||
"List activity logs for a workspace (A2A communications, tasks, errors)",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
type: z
|
||||
.enum(["a2a_receive", "a2a_send", "task_update", "agent_log", "error"])
|
||||
.optional()
|
||||
.describe("Filter by activity type"),
|
||||
limit: z.number().optional().describe("Max entries to return (default 100, max 500)"),
|
||||
},
|
||||
handleListActivity
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"notify_user",
|
||||
"Push a notification from the agent to the canvas via WebSocket — appears as a toast / chat bubble.",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
type: z.string().describe("Notification category (e.g. 'delegation_complete', 'approval_needed')"),
|
||||
},
|
||||
handleNotifyUser,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"list_traces",
|
||||
"List recent LLM traces from Langfuse for a workspace",
|
||||
{ workspace_id: z.string() },
|
||||
handleListTraces
|
||||
);
|
||||
}
|
||||
173
src/tools/discovery.ts
Normal file
173
src/tools/discovery.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleListPeers(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/registry/${params.workspace_id}/peers`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDiscoverWorkspace(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/registry/discover/${params.workspace_id}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleCheckAccess(params: { caller_id: string; target_id: string }) {
|
||||
const { caller_id, target_id } = params;
|
||||
const data = await apiCall("POST", `/registry/check-access`, { caller_id, target_id });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListEvents(params: { workspace_id?: string }) {
|
||||
const path = params.workspace_id ? `/events/${params.workspace_id}` : "/events";
|
||||
const data = await apiCall("GET", path);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListTemplates() {
|
||||
const data = await apiCall("GET", "/templates");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListOrgTemplates() {
|
||||
const data = await apiCall("GET", "/org/templates");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleImportOrg(params: { dir: string }) {
|
||||
const data = await apiCall("POST", "/org/import", { dir: params.dir });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleImportTemplate(params: { name: string; files: Record<string, string> }) {
|
||||
const { name, files } = params;
|
||||
const data = await apiCall("POST", `/templates/import`, { name, files });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleExportBundle(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/bundles/export/${params.workspace_id}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleImportBundle(params: { bundle: Record<string, unknown> }) {
|
||||
const data = await apiCall("POST", `/bundles/import`, params.bundle);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetViewport() {
|
||||
const data = await apiCall("GET", "/canvas/viewport");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleSetViewport(params: { x: number; y: number; zoom: number }) {
|
||||
const data = await apiCall("PUT", "/canvas/viewport", params);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleExpandTeam(params: { workspace_id: string }) {
|
||||
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/expand`, {});
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleCollapseTeam(params: { workspace_id: string }) {
|
||||
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/collapse`, {});
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerDiscoveryTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"list_peers",
|
||||
"List reachable peer workspaces (siblings, children, parent)",
|
||||
{ workspace_id: z.string() },
|
||||
handleListPeers
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"discover_workspace",
|
||||
"Resolve a workspace URL by ID (for A2A communication)",
|
||||
{ workspace_id: z.string() },
|
||||
handleDiscoverWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"check_access",
|
||||
"Check if two workspaces can communicate",
|
||||
{ caller_id: z.string(), target_id: z.string() },
|
||||
handleCheckAccess
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"list_events",
|
||||
"List structure events (global or per workspace)",
|
||||
{ workspace_id: z.string().optional().describe("Filter to workspace, or omit for all") },
|
||||
handleListEvents
|
||||
);
|
||||
|
||||
srv.tool("list_templates", "List available workspace templates", {}, handleListTemplates);
|
||||
|
||||
srv.tool("list_org_templates", "List available org templates", {}, handleListOrgTemplates);
|
||||
|
||||
srv.tool(
|
||||
"import_org",
|
||||
"Import an org template to create an entire workspace hierarchy",
|
||||
{ dir: z.string().describe("Org template directory name (e.g., 'molecule-dev')") },
|
||||
handleImportOrg
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"import_template",
|
||||
"Import agent files as a new workspace template",
|
||||
{
|
||||
name: z.string().describe("Template name"),
|
||||
files: z.record(z.string()).describe("Map of file path → content"),
|
||||
},
|
||||
handleImportTemplate
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"export_bundle",
|
||||
"Export a workspace as a portable .bundle.json",
|
||||
{ workspace_id: z.string() },
|
||||
handleExportBundle
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"import_bundle",
|
||||
"Import a workspace from a bundle JSON object",
|
||||
{ bundle: z.record(z.unknown()).describe("Bundle JSON object") },
|
||||
handleImportBundle
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_canvas_viewport",
|
||||
"Get the current canvas viewport (x, y, zoom) persisted per-user.",
|
||||
{},
|
||||
handleGetViewport,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"set_canvas_viewport",
|
||||
"Persist the canvas viewport (x, y, zoom).",
|
||||
{
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
zoom: z.number(),
|
||||
},
|
||||
handleSetViewport,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"expand_team",
|
||||
"Expand a workspace into a team of sub-workspaces",
|
||||
{ workspace_id: z.string().describe("Workspace ID to expand") },
|
||||
handleExpandTeam
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"collapse_team",
|
||||
"Collapse a team back to a single workspace",
|
||||
{ workspace_id: z.string().describe("Workspace ID to collapse") },
|
||||
handleCollapseTeam
|
||||
);
|
||||
}
|
||||
111
src/tools/files.ts
Normal file
111
src/tools/files.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult, toMcpText } from "../api.js";
|
||||
|
||||
export async function handleListFiles(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/files`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleReadFile(params: { workspace_id: string; path: string }) {
|
||||
const { workspace_id, path } = params;
|
||||
const data = await apiCall<{ content?: string }>("GET", `/workspaces/${workspace_id}/files/${path}`);
|
||||
const fileText = (data as { content?: string } | null)?.content;
|
||||
return fileText ? toMcpText(fileText) : toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleWriteFile(params: { workspace_id: string; path: string; content: string }) {
|
||||
const { workspace_id, path, content } = params;
|
||||
const data = await apiCall("PUT", `/workspaces/${workspace_id}/files/${path}`, { content });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDeleteFile(params: { workspace_id: string; path: string }) {
|
||||
const { workspace_id, path } = params;
|
||||
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/files/${path}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleReplaceAllFiles(params: {
|
||||
workspace_id: string;
|
||||
files: Record<string, string>;
|
||||
}) {
|
||||
const { workspace_id, files } = params;
|
||||
const data = await apiCall("PUT", `/workspaces/${workspace_id}/files`, { files });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetConfig(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/config`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleUpdateConfig(params: { workspace_id: string; config: Record<string, unknown> }) {
|
||||
const { workspace_id, config } = params;
|
||||
const data = await apiCall("PATCH", `/workspaces/${workspace_id}/config`, config);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerFileTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"list_files",
|
||||
"List workspace config files (skills, prompts, config.yaml)",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleListFiles
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"read_file",
|
||||
"Read a workspace config file",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
path: z.string().describe("File path (e.g., system-prompt.md, skills/seo/SKILL.md)"),
|
||||
},
|
||||
handleReadFile
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"write_file",
|
||||
"Write or create a workspace config file",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
path: z.string().describe("File path"),
|
||||
content: z.string().describe("File content"),
|
||||
},
|
||||
handleWriteFile
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"delete_file",
|
||||
"Delete a workspace file or folder",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
path: z.string().describe("File or folder path"),
|
||||
},
|
||||
handleDeleteFile
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"replace_all_files",
|
||||
"Replace all workspace config files at once",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
files: z.record(z.string()).describe("Map of file path → content"),
|
||||
},
|
||||
handleReplaceAllFiles
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_config",
|
||||
"Get workspace runtime config as JSON",
|
||||
{ workspace_id: z.string() },
|
||||
handleGetConfig
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"update_config",
|
||||
"Update workspace runtime config",
|
||||
{ workspace_id: z.string(), config: z.record(z.unknown()).describe("Config fields to update") },
|
||||
handleUpdateConfig
|
||||
);
|
||||
}
|
||||
165
src/tools/memory.ts
Normal file
165
src/tools/memory.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleCommitMemory(params: {
|
||||
workspace_id: string;
|
||||
content: string;
|
||||
scope: "LOCAL" | "TEAM" | "GLOBAL";
|
||||
}) {
|
||||
const { workspace_id, content, scope } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/memories`, { content, scope });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleSearchMemory(params: {
|
||||
workspace_id: string;
|
||||
query?: string;
|
||||
scope?: "LOCAL" | "TEAM" | "GLOBAL" | "";
|
||||
}) {
|
||||
const { workspace_id, query, scope } = params;
|
||||
const urlParams = new URLSearchParams();
|
||||
if (query) urlParams.set("q", query);
|
||||
if (scope) urlParams.set("scope", scope);
|
||||
const data = await apiCall("GET", `/workspaces/${workspace_id}/memories?${urlParams}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDeleteMemory(params: { workspace_id: string; memory_id: string }) {
|
||||
const { workspace_id, memory_id } = params;
|
||||
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/memories/${memory_id}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleSessionSearch(params: {
|
||||
workspace_id: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { workspace_id, q, limit } = params;
|
||||
const qs = new URLSearchParams();
|
||||
if (q) qs.set("q", q);
|
||||
if (limit) qs.set("limit", String(limit));
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||
const data = await apiCall("GET", `/workspaces/${workspace_id}/session-search${suffix}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetSharedContext(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/shared-context`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleSetKV(params: {
|
||||
workspace_id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
ttl_seconds?: number;
|
||||
}) {
|
||||
const { workspace_id, ...body } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/memory`, body);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetKV(params: { workspace_id: string; key: string }) {
|
||||
const data = await apiCall(
|
||||
"GET",
|
||||
`/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListKV(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/memory`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDeleteKV(params: { workspace_id: string; key: string }) {
|
||||
const data = await apiCall(
|
||||
"DELETE",
|
||||
`/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerMemoryTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"commit_memory",
|
||||
"Store a fact in workspace memory (LOCAL, TEAM, or GLOBAL scope)",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
content: z.string().describe("Fact to remember"),
|
||||
scope: z.enum(["LOCAL", "TEAM", "GLOBAL"]).default("LOCAL").describe("Memory scope"),
|
||||
},
|
||||
handleCommitMemory
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"search_memory",
|
||||
"Search workspace memories",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
query: z.string().optional().describe("Search query"),
|
||||
scope: z.enum(["LOCAL", "TEAM", "GLOBAL", ""]).optional().describe("Filter by scope"),
|
||||
},
|
||||
handleSearchMemory
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"delete_memory",
|
||||
"Delete a specific memory entry",
|
||||
{ workspace_id: z.string(), memory_id: z.string() },
|
||||
handleDeleteMemory
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"session_search",
|
||||
"Search a workspace's recent session activity and memory (FTS). Useful for 'did I tell you about X'.",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
q: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
},
|
||||
handleSessionSearch,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_shared_context",
|
||||
"Get the shared-context blob for a workspace (persistent cross-turn context).",
|
||||
{ workspace_id: z.string() },
|
||||
handleGetSharedContext,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"memory_set",
|
||||
"Set a key-value memory entry with optional TTL. Distinct from commit_memory which uses HMA scopes.",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
ttl_seconds: z.number().optional(),
|
||||
},
|
||||
handleSetKV,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"memory_get",
|
||||
"Read a single K/V memory entry.",
|
||||
{ workspace_id: z.string(), key: z.string() },
|
||||
handleGetKV,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"memory_list",
|
||||
"List all K/V memory entries for a workspace.",
|
||||
{ workspace_id: z.string() },
|
||||
handleListKV,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"memory_delete_kv",
|
||||
"Delete a single K/V memory entry.",
|
||||
{ workspace_id: z.string(), key: z.string() },
|
||||
handleDeleteKV,
|
||||
);
|
||||
}
|
||||
106
src/tools/plugins.ts
Normal file
106
src/tools/plugins.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleListPluginRegistry() {
|
||||
const data = await apiCall("GET", "/plugins");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListInstalledPlugins(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleInstallPlugin(params: { workspace_id: string; source: string }) {
|
||||
const { workspace_id, source } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/plugins`, { source });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleUninstallPlugin(params: { workspace_id: string; name: string }) {
|
||||
const { workspace_id, name } = params;
|
||||
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/plugins/${name}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListPluginSources() {
|
||||
const data = await apiCall("GET", "/plugins/sources");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListAvailablePlugins(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins/available`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleCheckPluginCompatibility(params: {
|
||||
workspace_id: string;
|
||||
runtime: string;
|
||||
}) {
|
||||
const { workspace_id, runtime } = params;
|
||||
const data = await apiCall(
|
||||
"GET",
|
||||
`/workspaces/${workspace_id}/plugins/compatibility?runtime=${encodeURIComponent(runtime)}`,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerPluginTools(srv: McpServer) {
|
||||
srv.tool("list_plugin_registry", "List all available plugins from the registry", {}, handleListPluginRegistry);
|
||||
|
||||
srv.tool(
|
||||
"list_installed_plugins",
|
||||
"List plugins installed in a workspace",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleListInstalledPlugins
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"install_plugin",
|
||||
"Install a plugin into a workspace from any registered source (auto-restarts). Use GET /plugins/sources to list schemes.",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
source: z
|
||||
.string()
|
||||
.describe(
|
||||
"Source URL: 'local://<name>' for platform registry, 'github://<owner>/<repo>[#<ref>]' for GitHub, or any registered scheme."
|
||||
),
|
||||
},
|
||||
handleInstallPlugin
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"uninstall_plugin",
|
||||
"Remove a plugin from a workspace (auto-restarts)",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
name: z.string().describe("Plugin name to remove"),
|
||||
},
|
||||
handleUninstallPlugin
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"list_plugin_sources",
|
||||
"List registered plugin install-source schemes (e.g. local, github).",
|
||||
{},
|
||||
handleListPluginSources,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"list_available_plugins",
|
||||
"List plugins from the registry filtered to ones supported by this workspace's runtime.",
|
||||
{ workspace_id: z.string() },
|
||||
handleListAvailablePlugins,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"check_plugin_compatibility",
|
||||
"Preflight check: which installed plugins would break if this workspace switched runtime to <runtime>?",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
runtime: z.string().describe("Target runtime (claude-code, deepagents, langgraph, ...)"),
|
||||
},
|
||||
handleCheckPluginCompatibility,
|
||||
);
|
||||
}
|
||||
172
src/tools/remote_agents.ts
Normal file
172
src/tools/remote_agents.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, PLATFORM_URL, toMcpResult, isApiError } from "../api.js";
|
||||
|
||||
// Fetch the workspace list, filter to runtime='external'. The platform
|
||||
// has no dedicated /remote-agents endpoint — we filter client-side
|
||||
// because the workspace list is small (tens to low-hundreds, never
|
||||
// pagination scale) and adding a server endpoint would be a separate PR.
|
||||
export async function handleListRemoteAgents() {
|
||||
const data = await apiCall("GET", "/workspaces");
|
||||
if (!Array.isArray(data)) {
|
||||
return toMcpResult(data);
|
||||
}
|
||||
const remote = data
|
||||
.filter((w: { runtime?: string }) => w.runtime === "external")
|
||||
.map((w: Record<string, unknown>) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: w.status,
|
||||
url: w.url,
|
||||
last_heartbeat_at: w.last_heartbeat_at,
|
||||
uptime_seconds: w.uptime_seconds,
|
||||
tier: w.tier,
|
||||
}));
|
||||
return toMcpResult({ count: remote.length, agents: remote });
|
||||
}
|
||||
|
||||
// Phase 30.4 — token-gated; from MCP we don't have a workspace bearer
|
||||
// (we're an operator surface), so we hit the lightweight unauthenticated
|
||||
// /workspaces/:id endpoint and project the same shape. Still useful as
|
||||
// a focused tool that doesn't dump the full workspace blob.
|
||||
export async function handleGetRemoteAgentState(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}`);
|
||||
if (isApiError(data)) {
|
||||
return toMcpResult(data);
|
||||
}
|
||||
const w = data as Record<string, unknown>;
|
||||
const projected = {
|
||||
workspace_id: w.id,
|
||||
status: w.status,
|
||||
paused: w.status === "paused",
|
||||
deleted: w.status === "removed",
|
||||
runtime: w.runtime,
|
||||
last_heartbeat_at: w.last_heartbeat_at,
|
||||
};
|
||||
return toMcpResult(projected);
|
||||
}
|
||||
|
||||
export async function handleGetRemoteAgentSetupCommand(params: {
|
||||
workspace_id: string;
|
||||
platform_url_override?: string;
|
||||
}) {
|
||||
// Verify the workspace exists and is runtime='external' before generating
|
||||
// the command — saves the operator from pasting a bash line that will
|
||||
// fail because the workspace was a Docker workspace they typed by mistake.
|
||||
const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`);
|
||||
if (isApiError(ws)) {
|
||||
return toMcpResult(ws);
|
||||
}
|
||||
const w = ws as { id: string; name: string; runtime?: string };
|
||||
if (w.runtime !== "external") {
|
||||
return toMcpResult({
|
||||
error: "workspace is not external; setup command only applies to runtime='external'",
|
||||
workspace_id: w.id,
|
||||
actual_runtime: w.runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// The MCP server's PLATFORM_URL is whatever Claude Desktop / the host
|
||||
// injected — usually localhost when an operator runs us locally. That
|
||||
// URL is useless inside a remote-agent shell on a different machine.
|
||||
// If the caller passes platform_url_override we use it; otherwise we
|
||||
// detect localhost and surface a warning so the operator knows to
|
||||
// substitute the real public URL before pasting the command.
|
||||
const targetUrl = params.platform_url_override?.trim() || PLATFORM_URL;
|
||||
const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(targetUrl);
|
||||
const warnings: string[] = [];
|
||||
if (isLocalhost && !params.platform_url_override) {
|
||||
warnings.push(
|
||||
`PLATFORM_URL is ${targetUrl} — this only works if the remote agent is on the same machine as the platform. ` +
|
||||
`Pass platform_url_override with the agent-reachable URL (e.g. https://your-platform.example.com) before pasting on a different host.`
|
||||
);
|
||||
}
|
||||
|
||||
const setupCmd = [
|
||||
`# Run on the remote machine where the agent will live.`,
|
||||
`# Requires Python 3.11+ and bash (the SDK invokes setup.sh via bash).`,
|
||||
`pip install molecule-sdk # (or: pip install -e <molecule-checkout>/sdk/python)`,
|
||||
``,
|
||||
`WORKSPACE_ID=${w.id} \\`,
|
||||
`PLATFORM_URL=${targetUrl} \\`,
|
||||
`python3 -c "from molecule_agent import RemoteAgentClient; \\`,
|
||||
` c = RemoteAgentClient.register_from_env(); \\`,
|
||||
` c.pull_secrets(); \\`,
|
||||
` c.run_heartbeat_loop()"`,
|
||||
``,
|
||||
`# For a richer demo (logging, graceful shutdown) see`,
|
||||
`# sdk/python/examples/remote-agent/run.py in the molecule-monorepo checkout.`,
|
||||
`# The agent will register, mint its bearer token (cached at`,
|
||||
`# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`,
|
||||
].join("\n");
|
||||
return toMcpResult({
|
||||
workspace_id: w.id,
|
||||
workspace_name: w.name,
|
||||
platform_url: targetUrl,
|
||||
setup_command: setupCmd,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCheckRemoteAgentFreshness(params: {
|
||||
workspace_id: string;
|
||||
threshold_seconds?: number;
|
||||
}) {
|
||||
const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`);
|
||||
if (isApiError(ws)) {
|
||||
return toMcpResult(ws);
|
||||
}
|
||||
const w = ws as { last_heartbeat_at?: string; status?: string; runtime?: string };
|
||||
const threshold = params.threshold_seconds ?? 90;
|
||||
const heartbeatStr = w.last_heartbeat_at;
|
||||
let secondsSince: number | null = null;
|
||||
if (heartbeatStr) {
|
||||
const heartbeatMs = Date.parse(heartbeatStr);
|
||||
if (!isNaN(heartbeatMs)) {
|
||||
secondsSince = Math.floor((Date.now() - heartbeatMs) / 1000);
|
||||
}
|
||||
}
|
||||
const fresh = secondsSince !== null && secondsSince <= threshold;
|
||||
return toMcpResult({
|
||||
workspace_id: params.workspace_id,
|
||||
status: w.status,
|
||||
runtime: w.runtime,
|
||||
last_heartbeat_at: heartbeatStr,
|
||||
seconds_since_heartbeat: secondsSince,
|
||||
threshold_seconds: threshold,
|
||||
fresh,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerRemoteAgentTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"list_remote_agents",
|
||||
"List all workspaces with runtime='external' (Phase 30 remote agents). Returns id, name, status, last_heartbeat_at, url. Useful for spotting offline remote agents from a Claude session.",
|
||||
{},
|
||||
handleListRemoteAgents,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_remote_agent_state",
|
||||
"Phase 30.4 lightweight state poll for a remote workspace. Returns {status, paused, deleted}. Faster than get_workspace because it doesn't include config/agent_card. Useful when you only need to know whether a remote agent is alive.",
|
||||
{ workspace_id: z.string() },
|
||||
handleGetRemoteAgentState,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_remote_agent_setup_command",
|
||||
"Build a one-shot bash command an operator can paste into a remote machine to register an agent against this Molecule AI platform. Returns a string like `WORKSPACE_ID=... PLATFORM_URL=... python3 -m molecule_agent.bootstrap`. Pass platform_url_override when the MCP server's PLATFORM_URL is localhost (the agent will live on a different host and needs the platform's public URL). The workspace must exist and be runtime='external'.",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
platform_url_override: z.string().optional(),
|
||||
},
|
||||
handleGetRemoteAgentSetupCommand,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"check_remote_agent_freshness",
|
||||
"Compare a remote workspace's last_heartbeat_at against now. Returns {seconds_since_heartbeat, fresh, threshold_seconds} where `fresh` is true if the agent heartbeated within the platform's stale-after window. Useful for pre-flight checks before delegating work.",
|
||||
{ workspace_id: z.string(), threshold_seconds: z.number().optional() },
|
||||
handleCheckRemoteAgentFreshness,
|
||||
);
|
||||
}
|
||||
131
src/tools/schedules.ts
Normal file
131
src/tools/schedules.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleListSchedules(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/schedules`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleCreateSchedule(params: {
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
cron_expr: string;
|
||||
prompt: string;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { workspace_id, ...body } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/schedules`, body);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleUpdateSchedule(params: {
|
||||
workspace_id: string;
|
||||
schedule_id: string;
|
||||
name?: string;
|
||||
cron_expr?: string;
|
||||
prompt?: string;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { workspace_id, schedule_id, ...body } = params;
|
||||
const data = await apiCall(
|
||||
"PATCH",
|
||||
`/workspaces/${workspace_id}/schedules/${schedule_id}`,
|
||||
body,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDeleteSchedule(params: {
|
||||
workspace_id: string;
|
||||
schedule_id: string;
|
||||
}) {
|
||||
const data = await apiCall(
|
||||
"DELETE",
|
||||
`/workspaces/${params.workspace_id}/schedules/${params.schedule_id}`,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleRunSchedule(params: {
|
||||
workspace_id: string;
|
||||
schedule_id: string;
|
||||
}) {
|
||||
const data = await apiCall(
|
||||
"POST",
|
||||
`/workspaces/${params.workspace_id}/schedules/${params.schedule_id}/run`,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetScheduleHistory(params: {
|
||||
workspace_id: string;
|
||||
schedule_id: string;
|
||||
}) {
|
||||
const data = await apiCall(
|
||||
"GET",
|
||||
`/workspaces/${params.workspace_id}/schedules/${params.schedule_id}/history`,
|
||||
);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerScheduleTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"list_schedules",
|
||||
"List cron schedules for a workspace.",
|
||||
{ workspace_id: z.string() },
|
||||
handleListSchedules,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"create_schedule",
|
||||
"Create a cron schedule that fires a prompt on a recurring timer.",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
name: z.string(),
|
||||
cron_expr: z.string().describe("5-field cron (e.g. '0 9 * * 1-5')"),
|
||||
prompt: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
},
|
||||
handleCreateSchedule,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"update_schedule",
|
||||
"Update fields on an existing schedule.",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
schedule_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
cron_expr: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
},
|
||||
handleUpdateSchedule,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"delete_schedule",
|
||||
"Delete a schedule.",
|
||||
{ workspace_id: z.string(), schedule_id: z.string() },
|
||||
handleDeleteSchedule,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"run_schedule",
|
||||
"Fire a schedule manually, bypassing its cron expression.",
|
||||
{ workspace_id: z.string(), schedule_id: z.string() },
|
||||
handleRunSchedule,
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_schedule_history",
|
||||
"Get past runs of a schedule — status, start/end, output preview.",
|
||||
{ workspace_id: z.string(), schedule_id: z.string() },
|
||||
handleGetScheduleHistory,
|
||||
);
|
||||
}
|
||||
82
src/tools/secrets.ts
Normal file
82
src/tools/secrets.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleSetSecret(params: { workspace_id: string; key: string; value: string }) {
|
||||
const { workspace_id, key, value } = params;
|
||||
const data = await apiCall("POST", `/workspaces/${workspace_id}/secrets`, { key, value });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListSecrets(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/secrets`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDeleteSecret(params: { workspace_id: string; key: string }) {
|
||||
const { workspace_id, key } = params;
|
||||
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/secrets/${encodeURIComponent(key)}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleListGlobalSecrets() {
|
||||
const data = await apiCall("GET", "/settings/secrets");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleSetGlobalSecret(params: { key: string; value: string }) {
|
||||
const { key, value } = params;
|
||||
const data = await apiCall("PUT", "/settings/secrets", { key, value });
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDeleteGlobalSecret(params: { key: string }) {
|
||||
const data = await apiCall("DELETE", `/settings/secrets/${params.key}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerSecretTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"set_secret",
|
||||
"Set an API key or environment variable for a workspace",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
key: z.string().describe("Secret key (e.g., ANTHROPIC_API_KEY)"),
|
||||
value: z.string().describe("Secret value"),
|
||||
},
|
||||
handleSetSecret
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"list_secrets",
|
||||
"List secret keys for a workspace (values never exposed)",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleListSecrets
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"delete_secret",
|
||||
"Delete a secret from a workspace",
|
||||
{ workspace_id: z.string(), key: z.string() },
|
||||
handleDeleteSecret
|
||||
);
|
||||
|
||||
srv.tool("list_global_secrets", "List global secret keys (values never exposed)", {}, handleListGlobalSecrets);
|
||||
|
||||
srv.tool(
|
||||
"set_global_secret",
|
||||
"Set a global secret (available to all workspaces)",
|
||||
{
|
||||
key: z.string().describe("Secret key (e.g., GITHUB_TOKEN)"),
|
||||
value: z.string().describe("Secret value"),
|
||||
},
|
||||
handleSetGlobalSecret
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"delete_global_secret",
|
||||
"Delete a global secret",
|
||||
{ key: z.string().describe("Secret key") },
|
||||
handleDeleteGlobalSecret
|
||||
);
|
||||
}
|
||||
140
src/tools/workspaces.ts
Normal file
140
src/tools/workspaces.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleListWorkspaces() {
|
||||
const data = await apiCall("GET", "/workspaces");
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
// Random canvas seeding so MCP-created workspaces don't all stack at (0,0).
|
||||
// The platform stores these; canvas drag-drop overrides them immediately.
|
||||
function initialCanvasPosition() {
|
||||
return { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 };
|
||||
}
|
||||
|
||||
export async function handleCreateWorkspace(params: {
|
||||
name: string;
|
||||
role?: string;
|
||||
template?: string;
|
||||
tier?: number;
|
||||
parent_id?: string;
|
||||
runtime?: string;
|
||||
workspace_dir?: string;
|
||||
workspace_access?: "none" | "read_only" | "read_write";
|
||||
}) {
|
||||
const { name, role, template, tier, parent_id, runtime, workspace_dir, workspace_access } = params;
|
||||
const data = await apiCall("POST", "/workspaces", {
|
||||
name, role, template, tier, parent_id, runtime,
|
||||
workspace_dir, workspace_access,
|
||||
canvas: initialCanvasPosition(),
|
||||
});
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleGetWorkspace(params: { workspace_id: string }) {
|
||||
const data = await apiCall("GET", `/workspaces/${params.workspace_id}`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleDeleteWorkspace(params: { workspace_id: string }) {
|
||||
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}?confirm=true`);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleRestartWorkspace(params: { workspace_id: string }) {
|
||||
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/restart`, {});
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleUpdateWorkspace(params: {
|
||||
workspace_id: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
tier?: number;
|
||||
parent_id?: string | null;
|
||||
workspace_dir?: string;
|
||||
workspace_access?: "none" | "read_only" | "read_write";
|
||||
}) {
|
||||
const { workspace_id, ...fields } = params;
|
||||
const data = await apiCall("PATCH", `/workspaces/${workspace_id}`, fields);
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handlePauseWorkspace(params: { workspace_id: string }) {
|
||||
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/pause`, {});
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export async function handleResumeWorkspace(params: { workspace_id: string }) {
|
||||
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/resume`, {});
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
export function registerWorkspaceTools(srv: McpServer) {
|
||||
srv.tool("list_workspaces", "List all workspaces with their status, skills, and hierarchy", {}, handleListWorkspaces);
|
||||
|
||||
srv.tool(
|
||||
"create_workspace",
|
||||
"Create a new workspace node on the canvas",
|
||||
{
|
||||
name: z.string().describe("Workspace name"),
|
||||
role: z.string().optional().describe("Role description"),
|
||||
template: z.string().optional().describe("Template name from workspace-configs-templates/"),
|
||||
tier: z.number().min(1).max(4).default(1).describe("Tier (1=basic, 2=browser, 3=desktop, 4=VM)"),
|
||||
parent_id: z.string().optional().describe("Parent workspace ID for nesting"),
|
||||
runtime: z.string().optional().describe("Runtime: claude-code, langgraph, openclaw, deepagents, autogen, crewai, hermes, external"),
|
||||
workspace_dir: z.string().optional().describe("Host path to bind-mount at /workspace (PM only by convention)"),
|
||||
workspace_access: z.enum(["none", "read_only", "read_write"]).optional().describe("Filesystem access mode for /workspace"),
|
||||
},
|
||||
handleCreateWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_workspace",
|
||||
"Get detailed information about a specific workspace",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleGetWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"delete_workspace",
|
||||
"Delete a workspace (cascades to children)",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleDeleteWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"restart_workspace",
|
||||
"Restart an offline or failed workspace",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleRestartWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"update_workspace",
|
||||
"Update workspace fields (name, role, tier, parent_id, position)",
|
||||
{
|
||||
workspace_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
tier: z.number().optional(),
|
||||
parent_id: z.string().nullable().optional().describe("Set parent for nesting, null to un-nest"),
|
||||
},
|
||||
handleUpdateWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"pause_workspace",
|
||||
"Pause a workspace (stops container, preserves config)",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handlePauseWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"resume_workspace",
|
||||
"Resume a paused workspace",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
handleResumeWorkspace
|
||||
);
|
||||
}
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user