@clawpump/claw-agent 0.1.16 → 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.
@@ -2,7 +2,7 @@
2
2
  "name": "hermes",
3
3
  "productName": "Claw Agent",
4
4
  "private": true,
5
- "version": "0.15.7",
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",
@@ -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
+ })
@@ -10,9 +10,7 @@ import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
10
10
  // Where an unauthenticated user goes to connect the ClawPump MCP — the gateway
11
11
  // (browser login / cpk_* key). Shown prominently when not connected.
12
12
  const CLAWPUMP_GATEWAY_URL = 'https://agents.clawpump.tech/dashboard/api'
13
- const CLAWPUMP_NAMES = new Set(['clawpump', 'clawpump-agents', 'clawpump-stdio'])
14
-
15
- const isClawpump = (s: McpServer) => CLAWPUMP_NAMES.has(s.name)
13
+ const isClawpump = (s: McpServer) => s.name.startsWith('clawpump')
16
14
 
17
15
  interface McpViewProps extends React.ComponentProps<'section'> {
18
16
  setStatusbarItemGroup?: SetStatusbarItemGroup
@@ -24,10 +22,20 @@ export function McpView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...prop
24
22
  const clawpump = servers.find(isClawpump)
25
23
  const others = servers.filter(s => !isClawpump(s))
26
24
 
27
- // authenticated === true OAuth tokens are on disk (the same session chat
28
- // uses), so the MCP is genuinely connected. === false → needs sign-in.
29
- const clawpumpConnected = clawpump?.authenticated === true
30
- const clawpumpNeedsAuth = clawpump != null && clawpump.authenticated === false
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.'
31
39
 
32
40
  const openGateway = () => void window.hermesDesktop?.openExternal?.(CLAWPUMP_GATEWAY_URL)
33
41
 
@@ -62,20 +70,23 @@ export function McpView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...prop
62
70
  <Check className="size-3" /> Connected
63
71
  </Badge>
64
72
  ) : (
65
- <Badge variant="outline">Not connected</Badge>
73
+ <Badge variant="outline">{clawpump.enabled ? 'Not connected' : 'Disabled'}</Badge>
66
74
  )}
67
75
  </div>
68
- {clawpumpNeedsAuth && (
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 && (
69
83
  <div className="mt-3 space-y-2">
70
- <p className="text-sm text-muted-foreground">
71
- Sign in at the ClawPump gateway to connect — then your 133 ClawPump tools come
72
- online in chat and across the app.
73
- </p>
84
+ <p className="text-sm text-muted-foreground">{clawpumpConnectionHelp}</p>
74
85
  <div className="flex flex-wrap items-center gap-2">
75
86
  <Button onClick={openGateway} size="sm">
76
- <ExternalLink className="size-4" /> Connect at the gateway
87
+ <ExternalLink className="size-4" /> {clawpumpConnectLabel}
77
88
  </Button>
78
- <code className="rounded bg-muted px-2 py-1 text-xs">claw clawpump login</code>
89
+ <code className="rounded bg-muted px-2 py-1 text-xs">{clawpumpConnectCommand}</code>
79
90
  </div>
80
91
  <p className="break-all text-xs text-muted-foreground">{CLAWPUMP_GATEWAY_URL}</p>
81
92
  </div>
@@ -776,8 +776,9 @@ export interface McpServer {
776
776
  transport: string
777
777
  url?: string | null
778
778
  command?: string | null
779
+ auth?: string | null
779
780
  enabled: boolean
780
- /** OAuth servers: true/false once tokens are checked; null when not applicable. */
781
+ /** true/false when backend can check credentials; null when not applicable/unknown. */
781
782
  authenticated?: boolean | null
782
783
  tools?: string[] | null
783
784
  }
@@ -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, install_dir: Optional[Path]
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
- cfg = load_config()
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(entry, install_dir)
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,14 +7983,51 @@ 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
+
7986
8018
  def _mcp_server_authenticated(name: str, cfg: Dict[str, Any]) -> Optional[bool]:
7987
- """True/False if this server uses OAuth and we can tell whether tokens are
7988
- on disk; None when auth state isn't applicable/known (stdio key servers).
8019
+ """True/False if this server's auth state can be determined.
7989
8020
 
7990
8021
  Lets the GUI render a real "Connected" vs "Connect" state for the ClawPump
7991
- MCP instead of guessing — the OAuth token is shared on disk, so the sidebar
7992
- reflects the same authenticated session the chat agent uses.
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.
7993
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
+
7994
8031
  is_oauth = cfg.get("auth") == "oauth" or (cfg.get("url") and not cfg.get("command"))
7995
8032
  if not is_oauth:
7996
8033
  return None
@@ -8024,11 +8061,10 @@ async def list_mcp_servers(profile: Optional[str] = None):
8024
8061
 
8025
8062
  with _profile_scope(profile):
8026
8063
  servers = _get_mcp_servers()
8027
- return {
8028
- "servers": [
8064
+ summaries = [
8029
8065
  _mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
8030
8066
  ]
8031
- }
8067
+ return {"servers": summaries}
8032
8068
 
8033
8069
 
8034
8070
  @app.post("/api/mcp/servers")
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawpump/claw-agent",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },