Skip to content

Commit 3c9d094

Browse files
committed
feat(mcp): add embedded stdio server, npx distribution, and cleaner error UX
Implement the HelpScout MCP surface directly in `hs` via `hs mcp -t stdio`, and map operational inbox leaf commands to MCP tools. MCP server and tooling: - add `hs mcp` command with stdio transport and output-mode control - add JSON-RPC handlers for `initialize`, `ping`, `tools/list`, and `tools/call` - discover tools dynamically from the inbox command tree (excluding `auth`, `config`, `permissions`) - build per-tool JSON schemas from Cobra positional args and flags - execute tool calls by invoking the same `hs` binary for full command parity - default tool output to json-clean, with `json_full` override support - normalize stdio framing for Inspector/client compatibility CLI UX and auth improvements: - suppress usage on runtime failures; show usage only for parse/shape errors - centralize `Execute()` error rendering with explicit `Error:` output - update unauthenticated guidance to env-first with MCP hint and `npx` fallback Distribution and docs: - add npm wrapper package `@operator-kit/hs` with binary downloader/launcher - extend release workflow to publish npm package when `NPM_TOKEN` is set - set GoReleaser releases to non-draft for public npm binary fetches - document MCP usage, coverage model, and client configuration in `README.md` Tests: - add MCP catalog, execution, server, and error-output tests
1 parent c9f7fe5 commit 3c9d094

21 files changed

+1858
-94
lines changed

.github/workflows/release.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,20 @@ jobs:
3030
env:
3131
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3232
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
33+
34+
- name: Setup Node
35+
if: ${{ secrets.NPM_TOKEN != '' }}
36+
uses: actions/setup-node@v4
37+
with:
38+
node-version: 20
39+
registry-url: "https://registry.npmjs.org"
40+
41+
- name: Publish npm package
42+
if: ${{ secrets.NPM_TOKEN != '' }}
43+
working-directory: npm
44+
run: |
45+
VERSION="${GITHUB_REF_NAME#v}"
46+
npm version "$VERSION" --no-git-tag-version
47+
npm publish --access public
48+
env:
49+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ checksum:
3131
name_template: checksums.txt
3232

3333
release:
34-
draft: true
34+
draft: false
3535

3636
brews:
3737
- repository:

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ curl -sSL https://raw.githubusercontent.com/operator-kit/hs-cli/main/install.sh
2626

2727
# From source (requires Go)
2828
go install github.com/operator-kit/hs-cli/cmd/hs@latest
29+
30+
# MCP-first install (no manual binary setup)
31+
npx -y @operator-kit/hs mcp -t stdio
2932
```
3033

3134
### Build from source
@@ -106,6 +109,70 @@ hs inbox auth logout
106109

107110
The `--format` flag can also be set permanently via the `format` key in the config file, or the `HS_FORMAT` environment variable.
108111

112+
## MCP Server
113+
114+
hs-cli ships an embedded MCP server, exposed as:
115+
116+
```bash
117+
hs mcp -t stdio
118+
```
119+
120+
### Coverage model
121+
122+
- One MCP tool per operational `hs inbox ...` leaf command.
123+
- Tool names are namespaced with prefixes like `helpscout_inbox_conversations_list`.
124+
- `inbox auth`, `inbox config`, and `inbox permissions` are intentionally excluded.
125+
- Existing command permissions (`HS_INBOX_PERMISSIONS`) still apply.
126+
- Existing redaction controls (`pii_mode`, `--unredacted`, `pii_allow_unredacted`) still apply.
127+
128+
### Output contract
129+
130+
- Default MCP tool output mode is clean JSON (`--format json`).
131+
- Per tool call, set `output_mode: "json_full"` for raw payload shape.
132+
- Server-wide default can be changed with:
133+
134+
```bash
135+
hs mcp -t stdio --default-output-mode json_full
136+
```
137+
138+
### MCP client config examples
139+
140+
Binary install:
141+
142+
```json
143+
{
144+
"mcpServers": {
145+
"helpscout": {
146+
"command": "hs",
147+
"args": ["mcp", "-t", "stdio"],
148+
"env": {
149+
"HS_CLIENT_ID": "your-client-id",
150+
"HS_CLIENT_SECRET": "your-client-secret",
151+
"HS_INBOX_PERMISSIONS": "*:read"
152+
}
153+
}
154+
}
155+
}
156+
```
157+
158+
npx wrapper:
159+
160+
```json
161+
{
162+
"mcpServers": {
163+
"helpscout": {
164+
"command": "npx",
165+
"args": ["-y", "@operator-kit/hs", "mcp", "-t", "stdio"],
166+
"env": {
167+
"HS_CLIENT_ID": "your-client-id",
168+
"HS_CLIENT_SECRET": "your-client-secret",
169+
"HS_INBOX_PERMISSIONS": "*:read"
170+
}
171+
}
172+
}
173+
}
174+
```
175+
109176
## PII Redaction Pipeline
110177

111178
hs-cli includes a production-focused PII redaction system designed for shared terminals, MCP/LLM workflows, and incident-safe exports.
@@ -179,6 +246,16 @@ hs inbox config set --pii-mode off --pii-allow-unredacted=false
179246

180247
Inbox API commands are namespaced under `hs inbox ...`.
181248

249+
### MCP
250+
251+
```bash
252+
# Start MCP server over stdio
253+
hs mcp -t stdio
254+
255+
# Use raw json-full output as the MCP default
256+
hs mcp -t stdio --default-output-mode json_full
257+
```
258+
182259
### Config
183260

184261
```bash
@@ -1135,6 +1212,10 @@ internal/
11351212
store.go OS keyring storage
11361213
cmd/
11371214
root.go Root command, global flags, PersistentPreRunE
1215+
mcp.go MCP command entrypoint
1216+
mcp_server.go Stdio MCP server + JSON-RPC handlers
1217+
mcp_catalog.go Dynamic tool catalog from inbox command tree
1218+
mcp_execute.go MCP args -> CLI argv execution bridge
11381219
json_clean.go Per-resource JSON cleanup (json vs json-full)
11391220
auth.go login / status / logout
11401221
config.go config set / get / path
@@ -1162,6 +1243,10 @@ internal/
11621243
check.go GitHub release check with 24h cache
11631244
update.go Download, verify, replace binary
11641245
types/ API response/request structs
1246+
npm/
1247+
package.json npx wrapper package (@operator-kit/hs)
1248+
bin/install.js platform binary downloader
1249+
bin/hs.js binary launcher
11651250
```
11661251

11671252
### Build

internal/cmd/attachments.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,9 @@ func newConversationAttachmentsCmd() *cobra.Command {
118118
dataLen = len(v)
119119
}
120120
rows := []map[string]string{{
121-
"id": args[1],
122-
"filename": asString(payload["filename"]),
123-
"mime": asString(payload["mimeType"]),
121+
"id": args[1],
122+
"filename": asString(payload["filename"]),
123+
"mime": asString(payload["mimeType"]),
124124
"data_bytes": strconv.Itoa(dataLen),
125125
}}
126126
return output.Print(getFormat(), []string{"id", "filename", "mime", "data_bytes"}, rows)

internal/cmd/error_output_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestShouldShowUsageForError_UnknownCommand(t *testing.T) {
12+
err := errors.New(`unknown command "nope" for "hs inbox"`)
13+
assert.True(t, shouldShowUsageForError(err))
14+
}
15+
16+
func TestExecute_UnknownFlag_PrintsUsage(t *testing.T) {
17+
_, buf := setupE2E(t)
18+
19+
rootCmd.SetArgs([]string{"inbox", "config", "get", "--wat"})
20+
err := Execute()
21+
require.Error(t, err)
22+
23+
out := buf.String()
24+
assert.Contains(t, out, "Error:")
25+
assert.Contains(t, out, "unknown flag")
26+
assert.Contains(t, out, "Usage:")
27+
}
28+
29+
func TestExecute_ArgCountMismatch_PrintsUsage(t *testing.T) {
30+
_, buf := setupE2E(t)
31+
32+
rootCmd.SetArgs([]string{"inbox", "config", "get", "a", "b"})
33+
err := Execute()
34+
require.Error(t, err)
35+
36+
out := buf.String()
37+
assert.Contains(t, out, "Error:")
38+
assert.Contains(t, out, "accepts at most 1 arg(s), received 2")
39+
assert.Contains(t, out, "Usage:")
40+
}
41+
42+
func TestExecute_MissingRequiredFlag_PrintsUsage(t *testing.T) {
43+
_, buf := setupE2E(t)
44+
t.Setenv("HS_CLIENT_ID", "test-id")
45+
t.Setenv("HS_CLIENT_SECRET", "test-secret")
46+
47+
rootCmd.SetArgs([]string{"inbox", "saved-replies", "create"})
48+
err := Execute()
49+
require.Error(t, err)
50+
51+
out := buf.String()
52+
assert.Contains(t, out, "Error:")
53+
assert.Contains(t, out, "required flag")
54+
assert.Contains(t, out, "Usage:")
55+
}
56+
57+
func TestExecute_RuntimeAuthError_DoesNotPrintUsage(t *testing.T) {
58+
_, buf := setupE2E(t)
59+
t.Setenv("HS_CLIENT_ID", "")
60+
t.Setenv("HS_CLIENT_SECRET", "")
61+
apiClient = nil
62+
63+
rootCmd.SetArgs([]string{"inbox", "customers", "list"})
64+
err := Execute()
65+
require.Error(t, err)
66+
67+
out := buf.String()
68+
assert.Contains(t, out, "Error:")
69+
assert.Contains(t, out, "not authenticated")
70+
assert.Contains(t, out, "HS_CLIENT_ID")
71+
assert.Contains(t, out, "HS_CLIENT_SECRET")
72+
assert.Contains(t, out, "MCP server env")
73+
assert.Contains(t, out, "hs inbox auth login")
74+
assert.Contains(t, out, "npx -y @operator-kit/hs inbox auth login")
75+
assert.NotContains(t, out, "Usage:")
76+
}
77+
78+
func TestExecute_RuntimeError_DoesNotPrintUsage(t *testing.T) {
79+
_, buf := setupE2E(t)
80+
81+
rootCmd.SetArgs([]string{"inbox", "config", "set", "--pii-mode", "invalid"})
82+
err := Execute()
83+
require.Error(t, err)
84+
85+
out := buf.String()
86+
assert.Contains(t, out, "Error:")
87+
assert.Contains(t, err.Error(), "invalid --pii-mode")
88+
assert.NotContains(t, buf.String(), "Usage:")
89+
}

internal/cmd/json_clean_test.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,16 @@ func TestCleanConversation(t *testing.T) {
3838
// Dropped
3939
assert.Nil(t, result["_links"])
4040
assert.Nil(t, result["_embedded"])
41-
assert.Nil(t, result["state"]) // "published" dropped
42-
assert.Nil(t, result["closedBy"]) // sentinel 0
43-
assert.Nil(t, result["closedByUser"]) // sentinel
44-
assert.Nil(t, result["createdBy"]) // duplicate
45-
assert.Nil(t, result["primaryCustomer"]) // replaced by "customer"
46-
assert.Nil(t, result["userUpdatedAt"]) // renamed
47-
assert.Nil(t, result["tags"]) // empty
48-
assert.Nil(t, result["customFields"]) // empty
49-
assert.Nil(t, result["cc"]) // empty
50-
assert.Nil(t, result["bcc"]) // empty
41+
assert.Nil(t, result["state"]) // "published" dropped
42+
assert.Nil(t, result["closedBy"]) // sentinel 0
43+
assert.Nil(t, result["closedByUser"]) // sentinel
44+
assert.Nil(t, result["createdBy"]) // duplicate
45+
assert.Nil(t, result["primaryCustomer"]) // replaced by "customer"
46+
assert.Nil(t, result["userUpdatedAt"]) // renamed
47+
assert.Nil(t, result["tags"]) // empty
48+
assert.Nil(t, result["customFields"]) // empty
49+
assert.Nil(t, result["cc"]) // empty
50+
assert.Nil(t, result["bcc"]) // empty
5151

5252
// Transformed
5353
assert.Equal(t, "Alice Smith (alice@test.com)", result["customer"])
@@ -90,12 +90,12 @@ func TestCleanConversationWithEmbeddedThreads(t *testing.T) {
9090

9191
thread := threads[0]
9292
assert.Equal(t, "customer", thread["type"])
93-
assert.Equal(t, "Hello", thread["body"]) // HTML→md
94-
assert.Equal(t, "Alice S (alice@test.com)", thread["from"]) // flattened
93+
assert.Equal(t, "Hello", thread["body"]) // HTML→md
94+
assert.Equal(t, "Alice S (alice@test.com)", thread["from"]) // flattened
9595
assert.Nil(t, thread["_links"])
96-
assert.Nil(t, thread["customer"]) // duplicate dropped
97-
assert.Nil(t, thread["state"]) // "published" dropped
98-
assert.Nil(t, thread["action"]) // default dropped
96+
assert.Nil(t, thread["customer"]) // duplicate dropped
97+
assert.Nil(t, thread["state"]) // "published" dropped
98+
assert.Nil(t, thread["action"]) // default dropped
9999
}
100100

101101
func TestCleanThread(t *testing.T) {
@@ -231,14 +231,14 @@ func TestCleanCustomer(t *testing.T) {
231231

232232
assert.Nil(t, result["_links"])
233233
assert.Nil(t, result["_embedded"])
234-
assert.Nil(t, result["background"]) // empty string
235-
assert.Nil(t, result["gender"]) // "Unknown"
236-
assert.Nil(t, result["draft"]) // false
237-
assert.Nil(t, result["photoType"]) // "default"
238-
assert.Nil(t, result["photoUrl"]) // dropped
239-
assert.Nil(t, result["phones"]) // empty array hoisted then dropped
240-
assert.Nil(t, result["chats"]) // empty
241-
assert.Nil(t, result["websites"]) // empty
234+
assert.Nil(t, result["background"]) // empty string
235+
assert.Nil(t, result["gender"]) // "Unknown"
236+
assert.Nil(t, result["draft"]) // false
237+
assert.Nil(t, result["photoType"]) // "default"
238+
assert.Nil(t, result["photoUrl"]) // dropped
239+
assert.Nil(t, result["phones"]) // empty array hoisted then dropped
240+
assert.Nil(t, result["chats"]) // empty
241+
assert.Nil(t, result["websites"]) // empty
242242
assert.Nil(t, result["social_profiles"]) // empty
243243

244244
// Hoisted from _embedded

internal/cmd/mcp.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
)
9+
10+
const (
11+
mcpOutputJSON = "json"
12+
mcpOutputJSONFull = "json_full"
13+
)
14+
15+
func init() {
16+
rootCmd.AddCommand(newMCPCmd())
17+
}
18+
19+
func newMCPCmd() *cobra.Command {
20+
var transport string
21+
var defaultOutputMode string
22+
23+
cmd := &cobra.Command{
24+
Use: "mcp",
25+
Short: "Start MCP server for HelpScout Inbox tools",
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
if transport != "stdio" {
28+
return fmt.Errorf("unsupported transport: %s (only stdio is supported)", transport)
29+
}
30+
31+
defaultOutputMode = strings.ToLower(strings.TrimSpace(defaultOutputMode))
32+
if defaultOutputMode == "json-full" {
33+
defaultOutputMode = mcpOutputJSONFull
34+
}
35+
36+
if !isValidMCPOutputMode(defaultOutputMode) {
37+
return fmt.Errorf("invalid --default-output-mode: %q (expected json|json_full)", defaultOutputMode)
38+
}
39+
40+
server, err := newMCPServer(defaultOutputMode, cfgPath, debug)
41+
if err != nil {
42+
return err
43+
}
44+
return server.serve(cmd.Context())
45+
},
46+
}
47+
48+
cmd.Flags().StringVarP(&transport, "transport", "t", "stdio", "MCP transport (stdio)")
49+
cmd.Flags().StringVar(&defaultOutputMode, "default-output-mode", mcpOutputJSON, "default output mode for tool calls: json|json_full")
50+
return cmd
51+
}
52+
53+
func isValidMCPOutputMode(mode string) bool {
54+
return mode == mcpOutputJSON || mode == mcpOutputJSONFull
55+
}

0 commit comments

Comments
 (0)