From 521f3704c1331e0552e6f70848dbe1c4a2ebe0ca Mon Sep 17 00:00:00 2001 From: Clawdrey Hepburn Date: Fri, 1 May 2026 02:28:15 -0700 Subject: [PATCH] mcp-openclaw: fix loader + align with current OpenClaw plugin API Closes #4. The plugin did not load against any current OpenClaw runtime. Three problems blocked discovery and activation: 1. package.json was missing the `openclaw` discovery key. OpenClaw discovers installed plugins via `package.json.openclaw.extensions` (or `runtimeExtensions`); without that key the plugin is invisible. 2. openclaw.plugin.json declared an `entry` field. The OpenClaw plugin manifest is metadata-only by design; code entry belongs in `package.json.openclaw.*`. The `entry` field was silently ignored. 3. `register(api)` called `api.getConfig()`, `api.registerTool(name, fn)`, and `api.onShutdown(fn)`. None of those exist on `OpenClawPluginApi`. The real API exposes `pluginConfig` (property), `registerTool({...})` (object), and `registerService({id, start, stop})` for lifecycle. This PR: - Adds `package.json.openclaw.{extensions, runtimeExtensions}` so the plugin is discoverable. - Drops the unused `entry` field from openclaw.plugin.json. - Rewrites src/index.ts against the real OpenClaw plugin API, using `registerService({start, stop})` for lifecycle. This also fixes the async-leaky `register()` (tools were previously registered after `register()` returned, which can miss the static catalog snapshot). - Plumbs `description` and `inputSchema` from MCP `listTools` through `ServerManager.getTools()` so registered OpenClaw tools have proper metadata. - Adds a clear logger warning when `agent_url` is missing or `mcp_servers` is empty (previously a silent no-op). - Fixes the README config example: the plugin id is `aauth-mcp`, not `aauth`. Removes the unused `delegate` field from the example. - Bumps version to 0.8.2. Tests in `server-manager.test.ts` are updated for the new `getTools()` shape and now also assert that tool description and inputSchema flow through. `vitest run` is green. Refs: https://docs.openclaw.ai/plugins/manifest https://docs.openclaw.ai/plugins/sdk-entrypoints --- mcp-openclaw/README.md | 13 ++- mcp-openclaw/openclaw.plugin.json | 1 - mcp-openclaw/package.json | 10 +- mcp-openclaw/src/index.ts | 118 +++++++++++++++++++----- mcp-openclaw/src/server-manager.test.ts | 25 ++++- mcp-openclaw/src/server-manager.ts | 44 +++++++-- 6 files changed, 168 insertions(+), 43 deletions(-) diff --git a/mcp-openclaw/README.md b/mcp-openclaw/README.md index 4010598..c1eefbc 100644 --- a/mcp-openclaw/README.md +++ b/mcp-openclaw/README.md @@ -18,11 +18,10 @@ Add to `~/.openclaw/openclaw.json`: { "plugins": { "entries": { - "aauth": { + "aauth-mcp": { "enabled": true, "config": { "agent_url": "https://user.github.io", - "delegate": "openclaw", "mcp_servers": { "my-files": "https://files-api.example.com/mcp", "my-db": "https://db-api.example.com/mcp" @@ -34,16 +33,20 @@ Add to `~/.openclaw/openclaw.json`: } ``` +The plugin id is `aauth-mcp` and must match the manifest id. + Tools from remote servers are registered with a prefix: `my-files_read_file`, `my-db_query`, etc. ## API -### `register(api, config)` +### `register(api)` -Plugin entry point called by OpenClaw. Connects to configured MCP servers and registers their tools. +Plugin entry point called by OpenClaw. Connects to configured MCP servers via +a `registerService` lifecycle and registers each remote tool as an OpenClaw +tool. The plugin reads its config from `api.pluginConfig`. ```ts -import { register } from '@aauth/mcp-openclaw' +import register from '@aauth/mcp-openclaw' ``` ### `ServerManager` diff --git a/mcp-openclaw/openclaw.plugin.json b/mcp-openclaw/openclaw.plugin.json index 772c22d..2298b5b 100644 --- a/mcp-openclaw/openclaw.plugin.json +++ b/mcp-openclaw/openclaw.plugin.json @@ -3,7 +3,6 @@ "name": "AAuth MCP", "description": "Connect to remote MCP servers with AAuth agent authentication", "version": "0.0.1", - "entry": "./dist/index.js", "configSchema": { "type": "object", "properties": { diff --git a/mcp-openclaw/package.json b/mcp-openclaw/package.json index 22c5549..9194f29 100644 --- a/mcp-openclaw/package.json +++ b/mcp-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/mcp-openclaw", - "version": "0.8.1", + "version": "0.8.2", "description": "OpenClaw plugin for AAuth-authenticated MCP server connections", "type": "module", "exports": { @@ -38,6 +38,14 @@ "@aauth/local-keys": "^0.8.0", "@modelcontextprotocol/sdk": "^1.15.1" }, + "openclaw": { + "extensions": [ + "./src/index.ts" + ], + "runtimeExtensions": [ + "./dist/index.js" + ] + }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.0" diff --git a/mcp-openclaw/src/index.ts b/mcp-openclaw/src/index.ts index 3297dec..5ca6e2f 100644 --- a/mcp-openclaw/src/index.ts +++ b/mcp-openclaw/src/index.ts @@ -1,6 +1,19 @@ +/** + * @aauth/mcp-openclaw — OpenClaw plugin for AAuth-authenticated MCP servers. + * + * Discovers tools on remote MCP servers reachable over HTTP and registers each + * as an OpenClaw tool. All HTTP traffic is signed with an AAuth agent token + * via `@aauth/mcp-agent`'s `createSignedFetch`. + */ + import { createAgentToken } from '@aauth/local-keys' import { ServerManager } from './server-manager.js' +export const id = 'aauth-mcp' +export const name = 'AAuth MCP' +export const description = + 'Connect to remote MCP servers with AAuth agent authentication' + export interface PluginConfig { agent_url?: string local?: string @@ -8,40 +21,99 @@ export interface PluginConfig { mcp_servers: Record } -export interface OpenClawPluginApi { - getConfig(): PluginConfig - registerTool(name: string, handler: (args: Record) => Promise): void - onShutdown(fn: () => Promise): void +/** + * Minimal slice of OpenClaw's plugin API surface used by this plugin. + * The real type is exported from `openclaw/plugin-sdk/plugin-entry`, but we + * inline the slice we need to keep the plugin dependency-free. + */ +interface OpenClawPluginApi { + pluginConfig?: PluginConfig + logger: { + info(msg: string, ...args: unknown[]): void + warn(msg: string, ...args: unknown[]): void + error(msg: string, ...args: unknown[]): void + } + registerTool( + tool: { + name: string + label?: string + description: string + parameters: Record + execute(toolCallId: string, params: Record): Promise + }, + opts?: { optional?: boolean }, + ): void + registerService(service: { + id: string + start(): Promise | void + stop(): Promise | void + }): void } -export const id = 'aauth-mcp' - -export function register(api: OpenClawPluginApi): void { - const config = api.getConfig() +export default function register(api: OpenClawPluginApi): void { + const config = (api.pluginConfig ?? { mcp_servers: {} }) as PluginConfig const { agent_url, local, token_lifetime, mcp_servers } = config - const getKeyMaterial = () => - createAgentToken({ - agentUrl: agent_url, - local: local ?? 'openclaw', - tokenLifetime: token_lifetime, - }) + if (!agent_url) { + api.logger.error( + `[${id}] missing required config "agent_url"; plugin will not connect to any MCP servers.`, + ) + return + } + + if (!mcp_servers || Object.keys(mcp_servers).length === 0) { + api.logger.warn( + `[${id}] no MCP servers configured (config.mcp_servers is empty); nothing to register.`, + ) + return + } const manager = new ServerManager({ servers: mcp_servers, - getKeyMaterial, + getKeyMaterial: () => + createAgentToken({ + agentUrl: agent_url, + local: local ?? 'openclaw', + tokenLifetime: token_lifetime, + }), }) - manager.connectAll().then(() => { - const tools = manager.getTools() - for (const tool of tools) { - api.registerTool(tool.prefixedName, (args) => - manager.callTool(tool.prefixedName, args), + api.registerService({ + id: `${id}/connection-manager`, + async start() { + try { + await manager.connectAll() + } catch (err) { + api.logger.error( + `[${id}] failed to connect to one or more MCP servers: ${(err as Error).message}`, + ) + return + } + + const tools = manager.getTools() + for (const tool of tools) { + api.registerTool({ + name: tool.prefixedName, + description: + tool.description ?? + `${tool.serverName}: ${tool.originalName} (AAuth MCP)`, + parameters: tool.inputSchema ?? { + type: 'object', + additionalProperties: true, + }, + execute: (_toolCallId, params) => + manager.callTool(tool.prefixedName, params).then((r) => r as unknown), + }) + } + + api.logger.info( + `[${id}] connected to ${Object.keys(mcp_servers).length} server(s); registered ${tools.length} tool(s).`, ) - } + }, + async stop() { + await manager.shutdown() + }, }) - - api.onShutdown(() => manager.shutdown()) } export { ServerManager } from './server-manager.js' diff --git a/mcp-openclaw/src/server-manager.test.ts b/mcp-openclaw/src/server-manager.test.ts index 44800ef..d6d1ca5 100644 --- a/mcp-openclaw/src/server-manager.test.ts +++ b/mcp-openclaw/src/server-manager.test.ts @@ -40,7 +40,14 @@ describe('ServerManager', () => { beforeEach(() => { vi.clearAllMocks() mockListTools.mockResolvedValue({ - tools: [{ name: 'read_file' }, { name: 'write_file' }], + tools: [ + { + name: 'read_file', + description: 'Read a file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }, + { name: 'write_file' }, + ], }) }) @@ -90,8 +97,20 @@ describe('ServerManager', () => { const tools = manager.getTools() expect(tools).toEqual([ - { prefixedName: 'myfiles_read_file', serverName: 'myfiles', originalName: 'read_file' }, - { prefixedName: 'myfiles_write_file', serverName: 'myfiles', originalName: 'write_file' }, + { + prefixedName: 'myfiles_read_file', + serverName: 'myfiles', + originalName: 'read_file', + description: 'Read a file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }, + { + prefixedName: 'myfiles_write_file', + serverName: 'myfiles', + originalName: 'write_file', + description: undefined, + inputSchema: undefined, + }, ]) }) diff --git a/mcp-openclaw/src/server-manager.ts b/mcp-openclaw/src/server-manager.ts index 54520f7..08a3437 100644 --- a/mcp-openclaw/src/server-manager.ts +++ b/mcp-openclaw/src/server-manager.ts @@ -4,11 +4,17 @@ import { createSignedFetch } from '@aauth/mcp-agent' import type { GetKeyMaterial } from '@aauth/mcp-agent' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +interface ManagedTool { + originalName: string + description?: string + inputSchema?: Record +} + interface ManagedServer { name: string client: Client transport: Transport - tools: Map // prefixed name → original name + tools: Map // prefixed name → tool metadata } export interface ServerManagerOptions { @@ -36,22 +42,40 @@ export class ServerManager { await client.connect(transport) const { tools } = await client.listTools() - const toolMap = new Map() + const toolMap = new Map() for (const tool of tools) { - toolMap.set(`${name}_${tool.name}`, tool.name) + toolMap.set(`${name}_${tool.name}`, { + originalName: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as Record | undefined, + }) } this.servers.set(name, { name, client, transport, tools: toolMap }) } - getTools(): Array<{ prefixedName: string; serverName: string; originalName: string; description?: string }> { - const result: Array<{ prefixedName: string; serverName: string; originalName: string; description?: string }> = [] + getTools(): Array<{ + prefixedName: string + serverName: string + originalName: string + description?: string + inputSchema?: Record + }> { + const result: Array<{ + prefixedName: string + serverName: string + originalName: string + description?: string + inputSchema?: Record + }> = [] for (const [, server] of this.servers) { - for (const [prefixedName, originalName] of server.tools) { + for (const [prefixedName, meta] of server.tools) { result.push({ prefixedName, serverName: server.name, - originalName, + originalName: meta.originalName, + description: meta.description, + inputSchema: meta.inputSchema, }) } } @@ -63,9 +87,9 @@ export class ServerManager { args: Record, ): Promise { for (const [, server] of this.servers) { - const originalName = server.tools.get(prefixedName) - if (originalName) { - return server.client.callTool({ name: originalName, arguments: args }) + const meta = server.tools.get(prefixedName) + if (meta) { + return server.client.callTool({ name: meta.originalName, arguments: args }) } } throw new Error(`Unknown tool: ${prefixedName}`)