@clawpump/claw-agent 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/apps/desktop/package.json +1 -1
- package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +3 -2
- package/agent/apps/desktop/src/app/desktop-controller.tsx +9 -0
- package/agent/apps/desktop/src/app/mcp/index.test.tsx +111 -0
- package/agent/apps/desktop/src/app/mcp/index.tsx +134 -0
- package/agent/apps/desktop/src/app/routes.ts +5 -1
- package/agent/apps/desktop/src/app/types.ts +1 -0
- package/agent/apps/desktop/src/hermes.ts +20 -0
- package/agent/hermes_cli/distribution.py +9 -1
- package/agent/hermes_cli/mcp_catalog.py +17 -4
- package/agent/hermes_cli/web_server.py +74 -5
- package/agent/tools/mcp_tool.py +68 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "hermes",
|
|
3
3
|
"productName": "Claw Agent",
|
|
4
4
|
"private": true,
|
|
5
|
-
"version": "0.15.
|
|
5
|
+
"version": "0.15.8",
|
|
6
6
|
"description": "Claw Agent by ClawPump — native desktop app for Solana agents, built on Hermes Agent by Nous Research.",
|
|
7
7
|
"author": "ClawPump (built on Hermes by Nous Research)",
|
|
8
8
|
"type": "module",
|
|
@@ -95,7 +95,7 @@ import {
|
|
|
95
95
|
sessionPinId
|
|
96
96
|
} from '@/store/session'
|
|
97
97
|
|
|
98
|
-
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE, WALLET_ROUTE, X402_ROUTE } from '../../routes'
|
|
98
|
+
import { type AppView, ARTIFACTS_ROUTE, MCP_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE, WALLET_ROUTE, X402_ROUTE } from '../../routes'
|
|
99
99
|
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
|
100
100
|
import type { SidebarNavItem } from '../../types'
|
|
101
101
|
|
|
@@ -133,7 +133,8 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
|
|
|
133
133
|
{ id: 'messaging', label: '', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
|
134
134
|
{ id: 'artifacts', label: '', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE },
|
|
135
135
|
{ id: 'wallet', label: 'Wallet', icon: props => <Codicon name="credit-card" {...props} />, route: WALLET_ROUTE },
|
|
136
|
-
{ id: 'x402', label: 'x402', icon: props => <Codicon name="zap" {...props} />, route: X402_ROUTE }
|
|
136
|
+
{ id: 'x402', label: 'x402', icon: props => <Codicon name="zap" {...props} />, route: X402_ROUTE },
|
|
137
|
+
{ id: 'mcp', label: 'MCP', icon: props => <Codicon name="plug" {...props} />, route: MCP_ROUTE }
|
|
137
138
|
]
|
|
138
139
|
|
|
139
140
|
const WORKSPACE_PAGE = 5
|
|
@@ -139,6 +139,7 @@ const SettingsView = lazy(async () => ({ default: (await import('./settings')).S
|
|
|
139
139
|
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
|
|
140
140
|
const WalletView = lazy(async () => ({ default: (await import('./wallet')).WalletView }))
|
|
141
141
|
const X402View = lazy(async () => ({ default: (await import('./x402')).X402View }))
|
|
142
|
+
const McpView = lazy(async () => ({ default: (await import('./mcp')).McpView }))
|
|
142
143
|
|
|
143
144
|
// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The
|
|
144
145
|
// Cron sessions are written by a background scheduler tick (the desktop
|
|
@@ -1203,6 +1204,14 @@ export function DesktopController() {
|
|
|
1203
1204
|
}
|
|
1204
1205
|
path="x402"
|
|
1205
1206
|
/>
|
|
1207
|
+
<Route
|
|
1208
|
+
element={
|
|
1209
|
+
<Suspense fallback={null}>
|
|
1210
|
+
<McpView setStatusbarItemGroup={setStatusbarItemGroup} />
|
|
1211
|
+
</Suspense>
|
|
1212
|
+
}
|
|
1213
|
+
path="mcp"
|
|
1214
|
+
/>
|
|
1206
1215
|
<Route element={null} path="cron" />
|
|
1207
1216
|
<Route element={null} path="profiles" />
|
|
1208
1217
|
<Route element={null} path="settings" />
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const getMcpServers = vi.hoisted(() => vi.fn())
|
|
6
|
+
|
|
7
|
+
vi.mock('@/hermes', () => ({
|
|
8
|
+
getMcpServers
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
async function renderMcpView() {
|
|
12
|
+
const { McpView } = await import('./index')
|
|
13
|
+
|
|
14
|
+
const client = new QueryClient({
|
|
15
|
+
defaultOptions: { queries: { retry: false } }
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return render(
|
|
19
|
+
<QueryClientProvider client={client}>
|
|
20
|
+
<McpView />
|
|
21
|
+
</QueryClientProvider>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup()
|
|
27
|
+
vi.clearAllMocks()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('McpView', () => {
|
|
31
|
+
it('shows a ClawPump API-key connection action for stdio installs that are not authenticated', async () => {
|
|
32
|
+
getMcpServers.mockResolvedValue({
|
|
33
|
+
servers: [
|
|
34
|
+
{
|
|
35
|
+
authenticated: null,
|
|
36
|
+
command: 'npx',
|
|
37
|
+
enabled: true,
|
|
38
|
+
name: 'clawpump-stdio',
|
|
39
|
+
transport: 'stdio'
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await renderMcpView()
|
|
45
|
+
|
|
46
|
+
expect(await screen.findByText('ClawPump MCP')).toBeTruthy()
|
|
47
|
+
expect(screen.getByText('Not connected')).toBeTruthy()
|
|
48
|
+
expect(screen.getByRole('button', { name: /connect with api key/i })).toBeTruthy()
|
|
49
|
+
expect(screen.getByText('claw clawpump setup')).toBeTruthy()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not show a connect action when ClawPump credentials are present', async () => {
|
|
53
|
+
getMcpServers.mockResolvedValue({
|
|
54
|
+
servers: [
|
|
55
|
+
{
|
|
56
|
+
authenticated: true,
|
|
57
|
+
command: 'npx',
|
|
58
|
+
enabled: true,
|
|
59
|
+
name: 'clawpump-stdio',
|
|
60
|
+
transport: 'stdio'
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await renderMcpView()
|
|
66
|
+
|
|
67
|
+
expect(await screen.findByText('Connected')).toBeTruthy()
|
|
68
|
+
expect(screen.queryByRole('button', { name: /connect with api key/i })).toBeNull()
|
|
69
|
+
expect(screen.queryByRole('button', { name: /connect at the gateway/i })).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('recognizes custom clawpump-prefixed server names as the ClawPump MCP', async () => {
|
|
73
|
+
getMcpServers.mockResolvedValue({
|
|
74
|
+
servers: [
|
|
75
|
+
{
|
|
76
|
+
authenticated: false,
|
|
77
|
+
enabled: true,
|
|
78
|
+
name: 'clawpump-agents-local',
|
|
79
|
+
transport: 'http',
|
|
80
|
+
url: 'https://agents.clawpump.tech/mcp'
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await renderMcpView()
|
|
86
|
+
|
|
87
|
+
expect(await screen.findByText('ClawPump MCP')).toBeTruthy()
|
|
88
|
+
expect(screen.getByText('clawpump-agents-local')).toBeTruthy()
|
|
89
|
+
expect(screen.queryByText('Other servers')).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('shows disabled ClawPump servers as disabled without auth actions', async () => {
|
|
93
|
+
getMcpServers.mockResolvedValue({
|
|
94
|
+
servers: [
|
|
95
|
+
{
|
|
96
|
+
authenticated: true,
|
|
97
|
+
command: 'npx',
|
|
98
|
+
enabled: false,
|
|
99
|
+
name: 'clawpump-stdio',
|
|
100
|
+
transport: 'stdio'
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
await renderMcpView()
|
|
106
|
+
|
|
107
|
+
expect(await screen.findByText('Disabled')).toBeTruthy()
|
|
108
|
+
expect(screen.getByText(/installed but disabled/i)).toBeTruthy()
|
|
109
|
+
expect(screen.queryByRole('button', { name: /connect/i })).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import { Badge } from '@/components/ui/badge'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { getMcpServers, type McpServer } from '@/hermes'
|
|
6
|
+
import { Check, ExternalLink, Loader2, Zap } from '@/lib/icons'
|
|
7
|
+
|
|
8
|
+
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
|
9
|
+
|
|
10
|
+
// Where an unauthenticated user goes to connect the ClawPump MCP — the gateway
|
|
11
|
+
// (browser login / cpk_* key). Shown prominently when not connected.
|
|
12
|
+
const CLAWPUMP_GATEWAY_URL = 'https://agents.clawpump.tech/dashboard/api'
|
|
13
|
+
const isClawpump = (s: McpServer) => s.name.startsWith('clawpump')
|
|
14
|
+
|
|
15
|
+
interface McpViewProps extends React.ComponentProps<'section'> {
|
|
16
|
+
setStatusbarItemGroup?: SetStatusbarItemGroup
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function McpView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: McpViewProps) {
|
|
20
|
+
const query = useQuery({ queryKey: ['mcp-servers'], queryFn: getMcpServers, staleTime: 15_000 })
|
|
21
|
+
const servers = query.data?.servers ?? []
|
|
22
|
+
const clawpump = servers.find(isClawpump)
|
|
23
|
+
const others = servers.filter(s => !isClawpump(s))
|
|
24
|
+
|
|
25
|
+
// authenticated === true means the backend found the same OAuth/API-key
|
|
26
|
+
// credentials the chat runtime uses. Enabled-but-not-connected states need
|
|
27
|
+
// an action surface; otherwise stdio/API-key installs showed "Not connected"
|
|
28
|
+
// with no way to fix or refresh credentials.
|
|
29
|
+
const clawpumpConnected = Boolean(clawpump?.enabled && clawpump.authenticated === true)
|
|
30
|
+
const clawpumpDisabled = clawpump != null && !clawpump.enabled
|
|
31
|
+
const clawpumpNeedsConnection = clawpump != null && clawpump.enabled && !clawpumpConnected
|
|
32
|
+
const clawpumpUsesStdio = clawpump?.transport === 'stdio' || Boolean(clawpump?.command)
|
|
33
|
+
const clawpumpConnectCommand = clawpumpUsesStdio ? 'claw clawpump setup' : 'claw clawpump login'
|
|
34
|
+
const clawpumpConnectLabel = clawpumpUsesStdio ? 'Connect with API key' : 'Connect at the gateway'
|
|
35
|
+
|
|
36
|
+
const clawpumpConnectionHelp = clawpumpUsesStdio
|
|
37
|
+
? 'Add or refresh your ClawPump cpk_* API key, then restart the session so the MCP tools come online.'
|
|
38
|
+
: 'Sign in at the ClawPump gateway to connect — then your 133 ClawPump tools come online in chat and across the app.'
|
|
39
|
+
|
|
40
|
+
const openGateway = () => void window.hermesDesktop?.openExternal?.(CLAWPUMP_GATEWAY_URL)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<section {...props} className="flex h-full min-h-0 flex-col">
|
|
44
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
45
|
+
<div className="mx-auto max-w-3xl space-y-4 px-5 py-4">
|
|
46
|
+
<header className="flex items-center gap-2">
|
|
47
|
+
<Zap className="size-5 text-primary" />
|
|
48
|
+
<h1 className="text-lg font-semibold">MCP Servers</h1>
|
|
49
|
+
</header>
|
|
50
|
+
<p className="text-sm text-muted-foreground">
|
|
51
|
+
Model Context Protocol servers wired into your agent. The ClawPump MCP brings 133 tools —
|
|
52
|
+
wallet, trading, marketplace, perps, token launch.
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
{query.isPending && (
|
|
56
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
57
|
+
<Loader2 className="size-4 animate-spin" /> Loading…
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{clawpump && (
|
|
62
|
+
<div className="rounded-lg border p-4">
|
|
63
|
+
<div className="flex items-center justify-between gap-2">
|
|
64
|
+
<div className="flex items-center gap-2">
|
|
65
|
+
<span className="font-medium">ClawPump MCP</span>
|
|
66
|
+
<span className="text-xs text-muted-foreground">{clawpump.name}</span>
|
|
67
|
+
</div>
|
|
68
|
+
{clawpumpConnected ? (
|
|
69
|
+
<Badge className="gap-1">
|
|
70
|
+
<Check className="size-3" /> Connected
|
|
71
|
+
</Badge>
|
|
72
|
+
) : (
|
|
73
|
+
<Badge variant="outline">{clawpump.enabled ? 'Not connected' : 'Disabled'}</Badge>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
{clawpumpDisabled && (
|
|
77
|
+
<p className="mt-3 text-sm text-muted-foreground">
|
|
78
|
+
ClawPump MCP is installed but disabled. Re-enable it in MCP settings, then restart
|
|
79
|
+
the session so the tools come online.
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
82
|
+
{clawpumpNeedsConnection && (
|
|
83
|
+
<div className="mt-3 space-y-2">
|
|
84
|
+
<p className="text-sm text-muted-foreground">{clawpumpConnectionHelp}</p>
|
|
85
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
86
|
+
<Button onClick={openGateway} size="sm">
|
|
87
|
+
<ExternalLink className="size-4" /> {clawpumpConnectLabel}
|
|
88
|
+
</Button>
|
|
89
|
+
<code className="rounded bg-muted px-2 py-1 text-xs">{clawpumpConnectCommand}</code>
|
|
90
|
+
</div>
|
|
91
|
+
<p className="break-all text-xs text-muted-foreground">{CLAWPUMP_GATEWAY_URL}</p>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{!query.isPending && !clawpump && (
|
|
98
|
+
<div className="space-y-2 rounded-lg border p-4">
|
|
99
|
+
<p className="text-sm text-muted-foreground">
|
|
100
|
+
The ClawPump MCP isn't installed yet. Connect it at the gateway to unlock the 133
|
|
101
|
+
tools.
|
|
102
|
+
</p>
|
|
103
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
104
|
+
<Button onClick={openGateway} size="sm">
|
|
105
|
+
<ExternalLink className="size-4" /> Open the ClawPump gateway
|
|
106
|
+
</Button>
|
|
107
|
+
<code className="rounded bg-muted px-2 py-1 text-xs">claw clawpump setup</code>
|
|
108
|
+
</div>
|
|
109
|
+
<p className="break-all text-xs text-muted-foreground">{CLAWPUMP_GATEWAY_URL}</p>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{others.length > 0 && (
|
|
114
|
+
<div className="space-y-2">
|
|
115
|
+
<h2 className="text-sm font-medium text-muted-foreground">Other servers</h2>
|
|
116
|
+
{others.map(s => (
|
|
117
|
+
<div
|
|
118
|
+
className="flex items-center justify-between rounded-md border px-3 py-2"
|
|
119
|
+
key={s.name}
|
|
120
|
+
>
|
|
121
|
+
<div className="flex items-center gap-2">
|
|
122
|
+
<span className="text-sm font-medium">{s.name}</span>
|
|
123
|
+
<span className="text-xs text-muted-foreground">{s.transport}</span>
|
|
124
|
+
</div>
|
|
125
|
+
{s.enabled ? <Badge>Enabled</Badge> : <Badge variant="outline">Disabled</Badge>}
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</section>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -10,6 +10,7 @@ export const PROFILES_ROUTE = '/profiles'
|
|
|
10
10
|
export const AGENTS_ROUTE = '/agents'
|
|
11
11
|
export const WALLET_ROUTE = '/wallet'
|
|
12
12
|
export const X402_ROUTE = '/x402'
|
|
13
|
+
export const MCP_ROUTE = '/mcp'
|
|
13
14
|
|
|
14
15
|
export type AppView =
|
|
15
16
|
| 'agents'
|
|
@@ -17,6 +18,7 @@ export type AppView =
|
|
|
17
18
|
| 'chat'
|
|
18
19
|
| 'command-center'
|
|
19
20
|
| 'cron'
|
|
21
|
+
| 'mcp'
|
|
20
22
|
| 'messaging'
|
|
21
23
|
| 'profiles'
|
|
22
24
|
| 'settings'
|
|
@@ -29,6 +31,7 @@ export type AppRouteId =
|
|
|
29
31
|
| 'artifacts'
|
|
30
32
|
| 'command-center'
|
|
31
33
|
| 'cron'
|
|
34
|
+
| 'mcp'
|
|
32
35
|
| 'messaging'
|
|
33
36
|
| 'new'
|
|
34
37
|
| 'profiles'
|
|
@@ -54,7 +57,8 @@ export const APP_ROUTES = [
|
|
|
54
57
|
{ id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' },
|
|
55
58
|
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' },
|
|
56
59
|
{ id: 'wallet', path: WALLET_ROUTE, view: 'wallet' },
|
|
57
|
-
{ id: 'x402', path: X402_ROUTE, view: 'x402' }
|
|
60
|
+
{ id: 'x402', path: X402_ROUTE, view: 'x402' },
|
|
61
|
+
{ id: 'mcp', path: MCP_ROUTE, view: 'mcp' }
|
|
58
62
|
] as const satisfies readonly AppRoute[]
|
|
59
63
|
|
|
60
64
|
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
|
@@ -771,6 +771,26 @@ export function getPodStatus(): Promise<{ connected: boolean; balance_usdc?: num
|
|
|
771
771
|
})
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
export interface McpServer {
|
|
775
|
+
name: string
|
|
776
|
+
transport: string
|
|
777
|
+
url?: string | null
|
|
778
|
+
command?: string | null
|
|
779
|
+
auth?: string | null
|
|
780
|
+
enabled: boolean
|
|
781
|
+
/** true/false when backend can check credentials; null when not applicable/unknown. */
|
|
782
|
+
authenticated?: boolean | null
|
|
783
|
+
tools?: string[] | null
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Configured MCP servers + their connection state (for the sidebar MCP page). */
|
|
787
|
+
export function getMcpServers(): Promise<{ servers: McpServer[] }> {
|
|
788
|
+
return window.hermesDesktop.api<{ servers: McpServer[] }>({
|
|
789
|
+
...profileScoped(),
|
|
790
|
+
path: '/api/mcp/servers'
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
|
|
774
794
|
/** ClawPump agent wallets (id + name + USDC balance) for the Pod funding picker. */
|
|
775
795
|
export function getPodWallets(): Promise<{ ok: boolean; wallets: PodWallet[]; error?: string }> {
|
|
776
796
|
return window.hermesDesktop.api<{ ok: boolean; wallets: PodWallet[]; error?: string }>({
|
|
@@ -488,7 +488,15 @@ def _clawpump_mcp_config():
|
|
|
488
488
|
from hermes_cli.mcp_config import _get_mcp_servers, _resolve_mcp_server_config
|
|
489
489
|
|
|
490
490
|
servers = _get_mcp_servers()
|
|
491
|
-
|
|
491
|
+
# Match the dashboard's _clawpump_mcp(): known names first, then any
|
|
492
|
+
# clawpump* entry (e.g. clawpump-agents), so every ClawPump MCP variant
|
|
493
|
+
# the agent can load is also resolvable here.
|
|
494
|
+
name = next(
|
|
495
|
+
(n for n in ("clawpump", "clawpump-stdio", "clawpump-agents") if n in servers),
|
|
496
|
+
None,
|
|
497
|
+
)
|
|
498
|
+
if not name:
|
|
499
|
+
name = next((n for n in servers if n.startswith("clawpump")), None)
|
|
492
500
|
if not name:
|
|
493
501
|
return (None, None)
|
|
494
502
|
return (name, _resolve_mcp_server_config(servers[name]))
|
|
@@ -36,6 +36,7 @@ from hermes_constants import get_hermes_home, get_optional_mcps_dir
|
|
|
36
36
|
from hermes_cli.colors import Colors, color
|
|
37
37
|
from hermes_cli.config import (
|
|
38
38
|
load_config,
|
|
39
|
+
read_raw_config,
|
|
39
40
|
save_config,
|
|
40
41
|
get_env_value,
|
|
41
42
|
save_env_value,
|
|
@@ -458,7 +459,10 @@ def _prompt_env_vars(specs: List[EnvVarSpec]) -> Dict[str, str]:
|
|
|
458
459
|
|
|
459
460
|
|
|
460
461
|
def _build_server_config(
|
|
461
|
-
entry: CatalogEntry,
|
|
462
|
+
entry: CatalogEntry,
|
|
463
|
+
install_dir: Optional[Path],
|
|
464
|
+
*,
|
|
465
|
+
env_names: Optional[List[str]] = None,
|
|
462
466
|
) -> dict:
|
|
463
467
|
"""Translate a manifest into the ``mcp_servers.<name>`` block format used
|
|
464
468
|
by hermes_cli/mcp_config.py."""
|
|
@@ -468,6 +472,8 @@ def _build_server_config(
|
|
|
468
472
|
cfg["command"] = _expand_install_dir(t.command or "", install_dir)
|
|
469
473
|
if t.args:
|
|
470
474
|
cfg["args"] = [_expand_install_dir(a, install_dir) for a in t.args]
|
|
475
|
+
if env_names:
|
|
476
|
+
cfg["env"] = {name: f"${{{name}}}" for name in env_names}
|
|
471
477
|
elif t.type == "http":
|
|
472
478
|
cfg["url"] = t.url
|
|
473
479
|
if entry.auth.type == "oauth":
|
|
@@ -519,7 +525,9 @@ def _probe_tools(name: str) -> Optional[List[tuple]]:
|
|
|
519
525
|
|
|
520
526
|
def _write_tools_include(name: str, include: Optional[List[str]]) -> None:
|
|
521
527
|
"""Persist or clear ``mcp_servers.<name>.tools.include``."""
|
|
522
|
-
|
|
528
|
+
# Use the raw YAML here. load_config() expands ${ENV} placeholders, and
|
|
529
|
+
# saving that expanded object would persist API keys into config.yaml.
|
|
530
|
+
cfg = read_raw_config()
|
|
523
531
|
servers = cfg.setdefault("mcp_servers", {})
|
|
524
532
|
server_entry = servers.get(name) or {}
|
|
525
533
|
if include is None:
|
|
@@ -696,10 +704,11 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
|
|
|
696
704
|
install_dir = _do_git_install(entry)
|
|
697
705
|
|
|
698
706
|
# Auth
|
|
707
|
+
auth_env: Dict[str, str] = {}
|
|
699
708
|
if entry.auth.type == "api_key":
|
|
700
709
|
print()
|
|
701
710
|
print(color(" Configure credentials:", Colors.CYAN))
|
|
702
|
-
_prompt_env_vars(entry.auth.env)
|
|
711
|
+
auth_env = _prompt_env_vars(entry.auth.env)
|
|
703
712
|
elif entry.auth.type == "oauth":
|
|
704
713
|
if entry.auth.provider:
|
|
705
714
|
# Case 2: provider-mediated (Google, GitHub, etc.). We rely on
|
|
@@ -727,7 +736,11 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
|
|
|
727
736
|
|
|
728
737
|
# Build and write the mcp_servers entry (without tools filter yet;
|
|
729
738
|
# _apply_tool_selection() finalizes it below).
|
|
730
|
-
server_cfg = _build_server_config(
|
|
739
|
+
server_cfg = _build_server_config(
|
|
740
|
+
entry,
|
|
741
|
+
install_dir,
|
|
742
|
+
env_names=list(auth_env) if auth_env else None,
|
|
743
|
+
)
|
|
731
744
|
server_cfg["enabled"] = enable
|
|
732
745
|
|
|
733
746
|
from hermes_cli.mcp_config import _save_mcp_server
|
|
@@ -7983,6 +7983,61 @@ def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
|
|
|
7983
7983
|
return out
|
|
7984
7984
|
|
|
7985
7985
|
|
|
7986
|
+
_CLAWPUMP_MCP_NAMES = {"clawpump", "clawpump-stdio", "clawpump-agents"}
|
|
7987
|
+
|
|
7988
|
+
|
|
7989
|
+
def _is_clawpump_mcp(name: str) -> bool:
|
|
7990
|
+
return name in _CLAWPUMP_MCP_NAMES or name.startswith("clawpump")
|
|
7991
|
+
|
|
7992
|
+
|
|
7993
|
+
def _usable_mcp_secret_value(value: Any) -> bool:
|
|
7994
|
+
text = str(value or "").strip()
|
|
7995
|
+
return bool(text and "${" not in text)
|
|
7996
|
+
|
|
7997
|
+
|
|
7998
|
+
def _clawpump_api_key_present(cfg: Optional[Dict[str, Any]] = None) -> bool:
|
|
7999
|
+
if cfg:
|
|
8000
|
+
try:
|
|
8001
|
+
from hermes_cli.mcp_config import _resolve_mcp_server_config
|
|
8002
|
+
|
|
8003
|
+
resolved = _resolve_mcp_server_config(cfg)
|
|
8004
|
+
except Exception:
|
|
8005
|
+
resolved = cfg
|
|
8006
|
+
env = resolved.get("env") if isinstance(resolved, dict) else {}
|
|
8007
|
+
if isinstance(env, dict) and _usable_mcp_secret_value(env.get("CLAWPUMP_API_KEY")):
|
|
8008
|
+
return True
|
|
8009
|
+
|
|
8010
|
+
try:
|
|
8011
|
+
from hermes_cli.config import get_env_value
|
|
8012
|
+
|
|
8013
|
+
return _usable_mcp_secret_value(get_env_value("CLAWPUMP_API_KEY"))
|
|
8014
|
+
except Exception:
|
|
8015
|
+
return False
|
|
8016
|
+
|
|
8017
|
+
|
|
8018
|
+
def _mcp_server_authenticated(name: str, cfg: Dict[str, Any]) -> Optional[bool]:
|
|
8019
|
+
"""True/False if this server's auth state can be determined.
|
|
8020
|
+
|
|
8021
|
+
Lets the GUI render a real "Connected" vs "Connect" state for the ClawPump
|
|
8022
|
+
MCP instead of guessing. OAuth tokens and profile .env values are shared
|
|
8023
|
+
with the chat agent, so the sidebar reflects the same session chat uses.
|
|
8024
|
+
"""
|
|
8025
|
+
# ClawPump's stdio/API-key transport is fully usable when the cpk_* key is
|
|
8026
|
+
# present. Returning None made the desktop show "Not connected" even while
|
|
8027
|
+
# wallet routes were succeeding through the same MCP server.
|
|
8028
|
+
if _is_clawpump_mcp(name) and cfg.get("command") and not cfg.get("url"):
|
|
8029
|
+
return _clawpump_api_key_present(cfg)
|
|
8030
|
+
|
|
8031
|
+
is_oauth = cfg.get("auth") == "oauth" or (cfg.get("url") and not cfg.get("command"))
|
|
8032
|
+
if not is_oauth:
|
|
8033
|
+
return None
|
|
8034
|
+
try:
|
|
8035
|
+
from hermes_cli.mcp_config import _oauth_tokens_present
|
|
8036
|
+
return bool(_oauth_tokens_present(name))
|
|
8037
|
+
except Exception:
|
|
8038
|
+
return None
|
|
8039
|
+
|
|
8040
|
+
|
|
7986
8041
|
def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|
7987
8042
|
transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown")
|
|
7988
8043
|
return {
|
|
@@ -7994,6 +8049,7 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
7994
8049
|
"env": _redact_mcp_env(cfg.get("env") or {}),
|
|
7995
8050
|
"auth": cfg.get("auth"),
|
|
7996
8051
|
"enabled": cfg.get("enabled", True) is not False,
|
|
8052
|
+
"authenticated": _mcp_server_authenticated(name, cfg),
|
|
7997
8053
|
# Tool selection: list of enabled tool names, or None = all.
|
|
7998
8054
|
"tools": cfg.get("tools"),
|
|
7999
8055
|
}
|
|
@@ -8005,11 +8061,10 @@ async def list_mcp_servers(profile: Optional[str] = None):
|
|
|
8005
8061
|
|
|
8006
8062
|
with _profile_scope(profile):
|
|
8007
8063
|
servers = _get_mcp_servers()
|
|
8008
|
-
|
|
8009
|
-
"servers": [
|
|
8064
|
+
summaries = [
|
|
8010
8065
|
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
|
|
8011
8066
|
]
|
|
8012
|
-
}
|
|
8067
|
+
return {"servers": summaries}
|
|
8013
8068
|
|
|
8014
8069
|
|
|
8015
8070
|
@app.post("/api/mcp/servers")
|
|
@@ -12048,11 +12103,25 @@ async def set_dashboard_theme(body: ThemeSetBody):
|
|
|
12048
12103
|
# in the vetted catalog (the font's webfont URL is injected as a <link>,
|
|
12049
12104
|
# so we never accept an arbitrary user-supplied id/URL here).
|
|
12050
12105
|
def _clawpump_mcp():
|
|
12051
|
-
"""Return (server_name, config) for the configured ClawPump MCP, else (None, None).
|
|
12106
|
+
"""Return (server_name, config) for the configured ClawPump MCP, else (None, None).
|
|
12107
|
+
|
|
12108
|
+
The chat agent loads EVERY configured MCP server, so the ClawPump tools work
|
|
12109
|
+
in chat under whatever entry name the user installed — ``clawpump`` (remote
|
|
12110
|
+
OAuth), ``clawpump-stdio``, or ``clawpump-agents`` (``npx @clawpump/agents``).
|
|
12111
|
+
The dashboard must recognise the SAME entry, otherwise chat works while every
|
|
12112
|
+
sidebar MCP page (wallet / x402 / pod / mail) reports "not configured". Try
|
|
12113
|
+
the known names in preference order, then fall back to any ``clawpump*``
|
|
12114
|
+
server so a future/renamed entry still resolves.
|
|
12115
|
+
"""
|
|
12052
12116
|
from hermes_cli.mcp_config import _get_mcp_servers
|
|
12053
12117
|
|
|
12054
12118
|
servers = _get_mcp_servers()
|
|
12055
|
-
name = next(
|
|
12119
|
+
name = next(
|
|
12120
|
+
(n for n in ("clawpump", "clawpump-stdio", "clawpump-agents") if n in servers),
|
|
12121
|
+
None,
|
|
12122
|
+
)
|
|
12123
|
+
if name is None:
|
|
12124
|
+
name = next((n for n in servers if n.startswith("clawpump")), None)
|
|
12056
12125
|
return (name, servers[name]) if name else (None, None)
|
|
12057
12126
|
|
|
12058
12127
|
|
package/agent/tools/mcp_tool.py
CHANGED
|
@@ -2995,6 +2995,73 @@ def _interrupted_call_result() -> str:
|
|
|
2995
2995
|
# Config loading
|
|
2996
2996
|
# ---------------------------------------------------------------------------
|
|
2997
2997
|
|
|
2998
|
+
_CLAWPUMP_MCP_NAMES = {"clawpump", "clawpump-stdio", "clawpump-agents"}
|
|
2999
|
+
|
|
3000
|
+
|
|
3001
|
+
def _is_clawpump_stdio_mcp(name: str, cfg: dict) -> bool:
|
|
3002
|
+
return (
|
|
3003
|
+
(name in _CLAWPUMP_MCP_NAMES or name.startswith("clawpump"))
|
|
3004
|
+
and bool(cfg.get("command"))
|
|
3005
|
+
and not cfg.get("url")
|
|
3006
|
+
)
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
def _usable_secret_value(value) -> bool:
|
|
3010
|
+
text = str(value or "").strip()
|
|
3011
|
+
return bool(text and "${" not in text)
|
|
3012
|
+
|
|
3013
|
+
|
|
3014
|
+
def _clawpump_api_key_for_stdio_env() -> str:
|
|
3015
|
+
try:
|
|
3016
|
+
from agent.secret_scope import get_secret, is_multiplex_active
|
|
3017
|
+
|
|
3018
|
+
key = (get_secret("CLAWPUMP_API_KEY", "") or "").strip()
|
|
3019
|
+
if key:
|
|
3020
|
+
return key
|
|
3021
|
+
if is_multiplex_active():
|
|
3022
|
+
return ""
|
|
3023
|
+
except Exception:
|
|
3024
|
+
try:
|
|
3025
|
+
from agent.secret_scope import is_multiplex_active
|
|
3026
|
+
|
|
3027
|
+
if is_multiplex_active():
|
|
3028
|
+
return ""
|
|
3029
|
+
except Exception:
|
|
3030
|
+
pass
|
|
3031
|
+
|
|
3032
|
+
try:
|
|
3033
|
+
from hermes_cli.config import get_env_value
|
|
3034
|
+
|
|
3035
|
+
return (get_env_value("CLAWPUMP_API_KEY") or "").strip()
|
|
3036
|
+
except Exception:
|
|
3037
|
+
return ""
|
|
3038
|
+
|
|
3039
|
+
|
|
3040
|
+
def _with_clawpump_stdio_env(name: str, cfg: dict) -> dict:
|
|
3041
|
+
"""Backfill legacy ClawPump stdio configs with the saved API key.
|
|
3042
|
+
|
|
3043
|
+
The stdio subprocess environment intentionally excludes generic secrets
|
|
3044
|
+
unless they are named in ``mcp_servers.<name>.env``. Older ClawPump setup
|
|
3045
|
+
flows saved ``CLAWPUMP_API_KEY`` to ``.env`` but did not add that env block,
|
|
3046
|
+
so the MCP could be configured yet start without credentials.
|
|
3047
|
+
"""
|
|
3048
|
+
if not _is_clawpump_stdio_mcp(name, cfg):
|
|
3049
|
+
return cfg
|
|
3050
|
+
|
|
3051
|
+
env = cfg.get("env") if isinstance(cfg.get("env"), dict) else {}
|
|
3052
|
+
if _usable_secret_value(env.get("CLAWPUMP_API_KEY")):
|
|
3053
|
+
return cfg
|
|
3054
|
+
|
|
3055
|
+
key = _clawpump_api_key_for_stdio_env()
|
|
3056
|
+
if not key:
|
|
3057
|
+
return cfg
|
|
3058
|
+
|
|
3059
|
+
updated = dict(cfg)
|
|
3060
|
+
updated_env = dict(env)
|
|
3061
|
+
updated_env["CLAWPUMP_API_KEY"] = key
|
|
3062
|
+
updated["env"] = updated_env
|
|
3063
|
+
return updated
|
|
3064
|
+
|
|
2998
3065
|
def _interpolate_env_vars(value):
|
|
2999
3066
|
"""Recursively resolve ``${VAR}`` placeholders.
|
|
3000
3067
|
|
|
@@ -3076,7 +3143,7 @@ def _load_mcp_config() -> Dict[str, dict]:
|
|
|
3076
3143
|
for name, cfg in _filter_suspicious_mcp_servers(servers).items():
|
|
3077
3144
|
interpolated = _interpolate_env_vars(cfg)
|
|
3078
3145
|
if isinstance(interpolated, dict):
|
|
3079
|
-
safe_servers[name] = interpolated
|
|
3146
|
+
safe_servers[name] = _with_clawpump_stdio_env(name, interpolated)
|
|
3080
3147
|
return safe_servers
|
|
3081
3148
|
except Exception as exc:
|
|
3082
3149
|
logger.debug("Failed to load MCP config: %s", exc)
|