SLOPSHOPPER

mcp-call-probe

Probe: does $.mcp.call reach ws mailbox_recv without a permission prompt; $.tool.call control.

guardprompt
★ 1v0.1.0Apache-2.0updated 2026-10-17kang-sw/devenv/ai-docs/ref/claude-mcp-call-probe-mod
A shopper browsing a rack in a slop shop
README

devenv

Personal developer environment and local ws workflow workspace.

This repo started as dotfiles and a workstation bootstrap script. It now also contains the local ws and wsflow plugin packages, the native ws-mcp runtime source, and workflow docs for that system.

Quick Start

git clone https://github.com/kang-sw/devenv.git ~/devenv
cd ~/devenv
bash install.sh          # idempotent; safe to re-run
bash install.sh update   # skip packages & sudo, refresh config and symlinks

Neovim 0.10+ is required but not installed by the script.

What's Inside

nvim/                  Neovim config
shell/                 tmux, WezTerm, Starship, zsh, helper scripts
tools/                 local Claude session TUIs
agents-plugin/         ws plugin package
agents-plugin-wsflow/  agentless wsflow plugin package
agents-plugin-tool/    native ws MCP runtime and tooling source
ai-docs/               workflow specs, tickets, mental models, references
  • Workstation config: Neovim, tmux, WezTerm, Starship, zsh, shell helpers, and editor/tooling defaults for day-to-day development.
  • Local TUIs: claude-watch and claude-dash live under tools/.
  • ws plugin: Codex-first workflow skills plus the ws-mcp runtime. Claude Code compatibility uses the agents-plugin/ package metadata; there is no live claude-plugin/ source tree.
  • wsflow plugin: Agentless workflow skills that reuse the shared runtime without ws managed-agent orchestration.
  • Runtime source: Go tooling and MCP server code under agents-plugin-tool/.
  • Workflow docs: specs, tickets, mental models, and references under ai-docs/.

install.sh

Detects macOS, WSL, and Linux. Full mode installs packages and writes config. update mode skips package installation and refreshes dotfiles, symlinks, and local plugin setup.

ws Plugin

The active ws plugin package lives in agents-plugin/.

Codex installs use the repository marketplace entries under .agents/plugins/. Claude-compatible marketplace metadata lives under .claude-plugin/.

Local bootstrap registers a Claude-compatible snapshot from agents-plugin/ when Claude Code is available:

claude plugin marketplace add kang-sw/devenv
claude plugin install ws@kang-sw-devenv

The agentless wsflow package lives in agents-plugin-wsflow/ for separate marketplace installation. install.sh does not install wsflow into Claude.

Pi Adapter

The ws workflow tools are also available inside Pi (@earendil-works/pi-coding-agent) sessions through a bundled extension. Install it straight from this repo with Pi's own git package manager:

pi install git:github.com/kang-sw/devenv@<tag>

Replace <tag> with a published release tag (for example v0.46.8). The <host> segment (github.com) is required — Pi's git spec is git:<host>/<user>/<repo>, and a host-less git:kang-sw/devenv mis-parses kang-sw as the host and fails to resolve.

Pi clones the repo, runs npm install against the repo-root package.json (which declares the extension and its runtime dependencies), and loads the extension. No local build step is required: on first use the extension's launcher downloads the release's ws-mcp binary, verifies its checksum, and registers the ws/* tools automatically.

License

Personal configuration. Use freely, no warranty implied.

Source 1 files
hooks/register.ts 119 lines
1import type { Register } from 'claude-code'
2
3// Probe for 261016-research-claude-mod-mailbox-active-push, open question:
4// does $.mcp.call reach the ws server's mailbox_recv with no permission prompt
5// under a prompting mode, and with which server/tool spelling.
6// Inputs come from env-free constants so the probe stays self-contained;
7// the runner script rewrites PROBE_KEY / LOG before each run.
8const PROBE_KEY = 'baggy-disparity-external'
9const LOG = '/tmp/mcp-call-probe/log.txt'
10const RECV = 'mailbox_recv'
11const SERVER_SPELLINGS = ['plugin:ws:ws', 'plugin_ws_ws', 'ws', 'plugin:wsflow:wsflow', 'plugin_wsflow_wsflow']
12const CONTROL_TOOL = 'mcp__plugin_ws_ws__mailbox_recv'
13// When true, a tool.check hook allows this plugin's own main-loop calls of the
14// recv tool (next.origin.plugin is the caller) and defers everything else.
15const GRANT_OWN_CALLS = false
16const OWN_PLUGINS = ['mcp-call-probe', 'ws']
17
18function show(v: unknown): string {
19  try {
20    return JSON.stringify(v)
21  } catch {
22    return String(v)
23  }
24}
25
26function errText(err: unknown): string {
27  return err instanceof Error ? `${err.name}: ${err.message}` : show(err)
28}
29
30export const register: Register = (on) => {
31  let done = false
32  const lines: string[] = []
33
34  on('tool.check', { tool: CONTROL_TOOL }, async ($, e, next) => {
35    const origin = next.origin
36    const own = OWN_PLUGINS.includes(origin.plugin) && e.agentId === undefined
37    lines.push(`${new Date().toISOString()} tool.check hook: origin=${show(origin)} tool_use_id=${e.tool_use_id ?? '-'} agentId=${e.agentId ?? '-'} input=${show(e.input)} own=${own}`)
38    if (GRANT_OWN_CALLS && own) {
39      await $.fs.write(LOG, lines.join('\n') + '\n')
40      return { decision: 'allow', reason: 'ws mod drains its own bound inbox' }
41    }
42    const beneath = await next(e)
43    lines.push(`${new Date().toISOString()} tool.check hook: verdict beneath=${show(beneath)}`)
44    await $.fs.write(LOG, lines.join('\n') + '\n')
45    return beneath
46  }).catch(() => ({ decision: 'deny' }))
47
48  // Observer: does a tool.call hook see the model's own call of the recv tool?
49  on('tool.call', { tool: CONTROL_TOOL }, async ($, e, next) => {
50    lines.push(`${new Date().toISOString()} tool.call hook: origin=${show(next.origin)} agentId=${e.agentId ?? '-'} session_key=${show(e.session_key)} format=${show(e.format)} tool_use_id=${show((e as { tool_use_id?: string }).tool_use_id)}`)
51    await $.fs.write(LOG, lines.join('\n') + '\n')
52    const res = await next(e)
53    lines.push(`${new Date().toISOString()} tool.call hook: result=${show(res).slice(0, 300)}`)
54    await $.fs.write(LOG, lines.join('\n') + '\n')
55    return res
56  }).catch(($, e, next) => (next.called ? next(e) : next(e)))
57
58  on('prompt.submit', async ($, e, next) => {
59    if (done) return next(e)
60    done = true
61    const log = async (s: string) => {
62      lines.push(`${new Date().toISOString()} ${s}`)
63      await $.fs.write(LOG, lines.join('\n') + '\n')
64    }
65    try {
66      await log(`version=${show(await $.session.version())}`)
67    } catch (err) {
68      await log(`version error ${errText(err)}`)
69    }
70    try {
71      const tools = (await $.tool.list()).map((t) => t.name).filter((n) => n.includes('mailbox'))
72      await log(`tool.list mailbox tools=${show(tools)}`)
73    } catch (err) {
74      await log(`tool.list error ${errText(err)}`)
75    }
76    // Design sequence: pre-check with $.tool.check, then drain with $.mcp.call.
77    try {
78      const pre = await $.tool.check({ tool: CONTROL_TOOL, input: { session_key: PROBE_KEY, format: 'json' } } as never)
79      await log(`precheck tool.check(${CONTROL_TOOL}) -> ${show(pre)}`)
80      const res = await $.mcp.call('plugin:ws:ws', RECV, { session_key: PROBE_KEY, format: 'json' })
81      await log(`precheck-then mcp.call(plugin:ws:ws, ${RECV}) OK -> ${show(res)}`)
82    } catch (err) {
83      await log(`precheck sequence threw ${errText(err)}`)
84    }
85    const servers = [...SERVER_SPELLINGS]
86    for (const server of ['ws', 'wsflow']) {
87      try {
88        const c = await $.mcp.connect(server)
89        if (c.isConnected && !servers.includes(c.server)) servers.unshift(c.server)
90        await log(`mcp.connect(${server}) -> ${show(c)}`)
91      } catch (err) {
92        await log(`mcp.connect(${server}) threw ${errText(err)}`)
93      }
94    }
95    for (const server of servers) {
96      try {
97        const res = await $.mcp.call(server, RECV, { session_key: PROBE_KEY, format: 'json' })
98        await log(`mcp.call(${server}, ${RECV}) OK -> ${show(res)}`)
99      } catch (err) {
100        await log(`mcp.call(${server}, ${RECV}) threw ${errText(err)}`)
101      }
102    }
103    try {
104      const chk = await $.tool.check({ tool: CONTROL_TOOL, input: { session_key: PROBE_KEY, format: 'json' } } as never)
105      await log(`tool.check(${CONTROL_TOOL}) -> ${show(chk)}`)
106    } catch (err) {
107      await log(`tool.check(${CONTROL_TOOL}) threw ${errText(err)}`)
108    }
109    try {
110      const res = await $.tool.call({ tool: CONTROL_TOOL, session_key: PROBE_KEY, format: 'json' } as never)
111      await log(`tool.call(${CONTROL_TOOL}) -> ${show(res)}`)
112    } catch (err) {
113      await log(`tool.call(${CONTROL_TOOL}) threw ${errText(err)}`)
114    }
115    await log('done')
116    return next(e)
117  })
118}
119