Skip to content

Commit bc8b210

Browse files
committed
feat(mcp): embed Model Context Protocol server (jira mcp serve)
Adds an embedded MCP server exposed as `jira mcp serve`, letting MCP-aware hosts (Cursor, Claude Desktop, etc.) read and modify Jira issues during a coding session while reusing the CLI's existing config, auth, and HTTP client. Single-user, IDE-focused v1 with five tools: search_issues, get_issue, create_issue, add_comment, transition_issue. Stdio transport only. New packages: - internal/mcp/ — server constructor + SDK wiring + panic recovery - internal/mcp/tools/ — five handlers, Deps DI struct, bodyToMarkdown helper - internal/cmd/mcp/ — Cobra surface (jira mcp parent + serve leaf) Wiring change: one line added to internal/cmd/root/root.go to register the new command. The "Using config file" debug print in cobra.OnInitialize is also routed to stderr so debug output never corrupts the stdio JSON-RPC stream when running `jira mcp serve --debug`. New direct dependency: github.com/modelcontextprotocol/go-sdk v1.5.0 (official Tier-1 SDK, Apache-2.0). Tracks upstream PR ankitpokhrel#985. Made-with: Cursor
1 parent 396933d commit bc8b210

24 files changed

Lines changed: 1643 additions & 6 deletions

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,36 @@ jira board list
720720
```
721721
</details>
722722

723+
## MCP server
724+
725+
`jira-cli` ships an embedded [Model Context Protocol](https://modelcontextprotocol.io) server so MCP-aware hosts (Cursor, Claude Desktop, etc.) can read and modify Jira issues during a coding session. The server reuses the same config, auth, and Jira API client as the rest of the CLI.
726+
727+
Start it from your MCP host configuration:
728+
729+
```json
730+
{
731+
"mcpServers": {
732+
"jira": {
733+
"command": "jira",
734+
"args": ["mcp", "serve"],
735+
"env": { "JIRA_API_TOKEN": "..." }
736+
}
737+
}
738+
}
739+
```
740+
741+
The server speaks stdio and exposes the following tools:
742+
743+
| Tool | Purpose |
744+
| --- | --- |
745+
| `search_issues` | Search by raw JQL or simple `status`/`assignee` filters. |
746+
| `get_issue` | Full issue details including description and recent comments. |
747+
| `create_issue` | Create a new issue in a project. |
748+
| `add_comment` | Add a comment to an issue. |
749+
| `transition_issue` | Move an issue to a new status by name. |
750+
751+
Every tool that returns an issue also returns its browser URL so the LLM can cite or link to it directly.
752+
723753
## Scripts
724754
Often times, you may want to use the output of the command to do something cool. However, the default interactive UI might not allow you to do that.
725755
The tool comes with the `--plain` flag that displays results in a simple layout that can then be manipulated from the shell script.

go.mod

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/mattn/go-isatty v0.0.20
2020
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
2121
github.com/mitchellh/go-homedir v1.1.0
22+
github.com/modelcontextprotocol/go-sdk v1.5.0
2223
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
2324
github.com/rivo/tview v0.0.0-20240406141410-79d4cc321256
2425
github.com/russross/blackfriday/v2 v2.1.0
@@ -47,6 +48,7 @@ require (
4748
github.com/fsnotify/fsnotify v1.7.0 // indirect
4849
github.com/gdamore/encoding v1.0.1 // indirect
4950
github.com/godbus/dbus/v5 v5.1.0 // indirect
51+
github.com/google/jsonschema-go v0.4.2 // indirect
5052
github.com/gorilla/css v1.0.1 // indirect
5153
github.com/hashicorp/hcl v1.0.0 // indirect
5254
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
@@ -64,18 +66,22 @@ require (
6466
github.com/rivo/uniseg v0.4.7 // indirect
6567
github.com/sagikazarmark/locafero v0.4.0 // indirect
6668
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
69+
github.com/segmentio/asm v1.1.3 // indirect
70+
github.com/segmentio/encoding v0.5.4 // indirect
6771
github.com/sourcegraph/conc v0.3.0 // indirect
6872
github.com/spf13/afero v1.11.0 // indirect
6973
github.com/spf13/cast v1.6.0 // indirect
7074
github.com/spf13/pflag v1.0.5 // indirect
7175
github.com/subosito/gotenv v1.6.0 // indirect
7276
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
77+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
7378
github.com/yuin/goldmark v1.7.8 // indirect
7479
github.com/yuin/goldmark-emoji v1.0.5 // indirect
7580
go.uber.org/multierr v1.11.0 // indirect
7681
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
7782
golang.org/x/net v0.38.0 // indirect
78-
golang.org/x/sys v0.31.0 // indirect
83+
golang.org/x/oauth2 v0.35.0 // indirect
84+
golang.org/x/sys v0.41.0 // indirect
7985
golang.org/x/text v0.23.0 // indirect
8086
gopkg.in/ini.v1 v1.67.0 // indirect
8187
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,12 @@ github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAY
6363
github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
6464
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
6565
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
66-
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
67-
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
66+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
67+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
68+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
69+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
70+
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
71+
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
6872
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
6973
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
7074
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -109,6 +113,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
109113
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
110114
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
111115
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
116+
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
117+
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
112118
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
113119
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
114120
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
@@ -136,6 +142,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
136142
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
137143
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
138144
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
145+
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
146+
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
147+
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
148+
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
139149
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
140150
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
141151
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
@@ -165,6 +175,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
165175
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
166176
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
167177
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
178+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
179+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
168180
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
169181
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
170182
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
@@ -187,6 +199,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
187199
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
188200
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
189201
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
202+
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
203+
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
190204
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
191205
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
192206
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -200,8 +214,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
200214
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
201215
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
202216
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
203-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
204-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
217+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
218+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
205219
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
206220
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
207221
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -220,6 +234,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
220234
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
221235
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
222236
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
237+
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
238+
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
223239
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
224240
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
225241
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

internal/cmd/mcp/mcp.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package mcp
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/ankitpokhrel/jira-cli/internal/cmd/mcp/serve"
7+
)
8+
9+
const helpText = `Run jira-cli as a Model Context Protocol (MCP) server, exposing
10+
Jira operations to MCP-aware hosts (e.g. Cursor, Claude Desktop).`
11+
12+
// NewCmdMCP is the parent command for MCP-related subcommands.
13+
func NewCmdMCP() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "mcp",
16+
Short: "Run jira-cli as an MCP server",
17+
Long: helpText,
18+
Annotations: map[string]string{"cmd:main": "true"},
19+
RunE: func(cmd *cobra.Command, _ []string) error {
20+
return cmd.Help()
21+
},
22+
}
23+
cmd.AddCommand(serve.NewCmdServe())
24+
return cmd
25+
}

internal/cmd/mcp/serve/serve.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package serve
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/signal"
7+
"syscall"
8+
9+
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
10+
"github.com/spf13/cobra"
11+
"github.com/spf13/viper"
12+
13+
"github.com/ankitpokhrel/jira-cli/api"
14+
jiramcp "github.com/ankitpokhrel/jira-cli/internal/mcp"
15+
"github.com/ankitpokhrel/jira-cli/internal/mcp/tools"
16+
)
17+
18+
const helpText = `Start an MCP server over stdio.
19+
20+
Configure your MCP host (Cursor, Claude Desktop, etc.) like this:
21+
22+
{
23+
"mcpServers": {
24+
"jira": {
25+
"command": "jira",
26+
"args": ["mcp", "serve"],
27+
"env": { "JIRA_API_TOKEN": "..." }
28+
}
29+
}
30+
}
31+
32+
The server inherits the same configuration as every other jira-cli command:
33+
JIRA_CONFIG_FILE, ~/.config/.jira/.config.yml, .netrc, and keychain all work
34+
unchanged. The server reads from stdin and writes JSON-RPC frames to stdout;
35+
all logs go to stderr.`
36+
37+
// NewCmdServe is the `jira mcp serve` command.
38+
func NewCmdServe() *cobra.Command {
39+
return &cobra.Command{
40+
Use: "serve",
41+
Short: "Start an MCP server over stdio",
42+
Long: helpText,
43+
RunE: run,
44+
}
45+
}
46+
47+
func run(cmd *cobra.Command, _ []string) error {
48+
server := viper.GetString("server")
49+
if server == "" {
50+
return fmt.Errorf("no Jira server configured. Run 'jira init' to set up the tool")
51+
}
52+
53+
// Honor browse_server override the same way internal/cmdutil.GenerateServerBrowseURL does,
54+
// so MCP-emitted issue URLs match what the rest of the CLI produces for users whose web
55+
// client and API endpoints differ.
56+
browseServer := server
57+
if v := viper.GetString("browse_server"); v != "" {
58+
browseServer = v
59+
}
60+
61+
// Stdout is reserved for JSON-RPC frames; pkg/jira's debug dump and
62+
// root's "Using config file: ..." both write to stdout when debug is
63+
// enabled, which would corrupt the MCP transport. Force-disable here
64+
// and surface a stderr notice if the user had it on in config.
65+
if viper.GetBool("debug") {
66+
fmt.Fprintln(os.Stderr, "jira-cli MCP server: ignoring debug=true (would corrupt stdio transport)")
67+
}
68+
deps := &tools.Deps{
69+
Client: api.DefaultClient(false),
70+
Server: browseServer,
71+
DefaultProject: viper.GetString("project.key"),
72+
Installation: viper.GetString("installation"),
73+
}
74+
75+
srv := jiramcp.NewServer(deps)
76+
77+
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
78+
defer stop()
79+
80+
fmt.Fprintln(os.Stderr, "jira-cli MCP server: listening on stdio")
81+
if err := srv.Run(ctx, &mcpsdk.StdioTransport{}); err != nil {
82+
return fmt.Errorf("mcp server: %w", err)
83+
}
84+
return nil
85+
}

internal/cmd/root/root.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
initCmd "github.com/ankitpokhrel/jira-cli/internal/cmd/init"
1515
"github.com/ankitpokhrel/jira-cli/internal/cmd/issue"
1616
"github.com/ankitpokhrel/jira-cli/internal/cmd/man"
17+
"github.com/ankitpokhrel/jira-cli/internal/cmd/mcp"
1718
"github.com/ankitpokhrel/jira-cli/internal/cmd/me"
1819
"github.com/ankitpokhrel/jira-cli/internal/cmd/open"
1920
"github.com/ankitpokhrel/jira-cli/internal/cmd/project"
@@ -64,7 +65,7 @@ func init() {
6465
viper.SetEnvPrefix("jira")
6566

6667
if err := viper.ReadInConfig(); err == nil && debug {
67-
fmt.Printf("Using config file: %s\n", viper.ConfigFileUsed())
68+
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
6869
}
6970
})
7071
}
@@ -140,6 +141,7 @@ func addChildCommands(cmd *cobra.Command) {
140141
version.NewCmdVersion(),
141142
release.NewCmdRelease(),
142143
man.NewCmdMan(),
144+
mcp.NewCmdMCP(),
143145
)
144146
}
145147

internal/mcp/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Package mcp implements a Model Context Protocol server that exposes
2+
// a subset of jira-cli's capabilities to MCP-aware hosts (e.g. Cursor,
3+
// Claude Desktop). Wiring lives in internal/cmd/mcp.
4+
package mcp

internal/mcp/server.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
9+
10+
"github.com/ankitpokhrel/jira-cli/internal/mcp/tools"
11+
)
12+
13+
const (
14+
// ServerName is the implementation name advertised over MCP.
15+
ServerName = "jira-cli"
16+
// ServerVersion is the MCP server version advertised to clients.
17+
// Bumped independently of the jira-cli release version when the MCP
18+
// surface changes in a backward-incompatible way.
19+
ServerVersion = "0.1.0"
20+
)
21+
22+
// NewServer constructs a configured *mcp.Server with all jira-cli tools
23+
// registered. The caller is responsible for invoking server.Run with a
24+
// transport.
25+
func NewServer(d *tools.Deps) *mcpsdk.Server {
26+
srv := mcpsdk.NewServer(&mcpsdk.Implementation{
27+
Name: ServerName,
28+
Version: ServerVersion,
29+
}, nil)
30+
31+
registerTool(srv, "search_issues",
32+
"Search Jira issues by JQL or simple filters. Defaults to the configured project.",
33+
d, tools.SearchIssues)
34+
35+
registerTool(srv, "get_issue",
36+
"Get full details of a Jira issue including description and recent comments.",
37+
d, tools.GetIssue)
38+
39+
registerTool(srv, "create_issue",
40+
"Create a new Jira issue in the given project.",
41+
d, tools.CreateIssue)
42+
43+
registerTool(srv, "add_comment",
44+
"Add a comment to a Jira issue.",
45+
d, tools.AddComment)
46+
47+
registerTool(srv, "transition_issue",
48+
"Transition a Jira issue to a new status by name (e.g. \"In Progress\", \"Done\").",
49+
d, tools.TransitionIssue)
50+
51+
return srv
52+
}
53+
54+
// registerTool adapts a tools.* handler (which takes Deps + Input and returns
55+
// Output + error) onto the SDK's expected handler signature. It also recovers
56+
// from panics in the handler body so a single bad call cannot kill the server
57+
// mid-session, and converts both errors and panics into MCP tool errors that
58+
// the LLM can read.
59+
func registerTool[In, Out any](
60+
srv *mcpsdk.Server,
61+
name, description string,
62+
d *tools.Deps,
63+
fn func(context.Context, *tools.Deps, In) (Out, error),
64+
) {
65+
mcpsdk.AddTool(srv,
66+
&mcpsdk.Tool{Name: name, Description: description},
67+
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in In) (result *mcpsdk.CallToolResult, out Out, err error) {
68+
defer func() {
69+
if r := recover(); r != nil {
70+
var zero Out
71+
fmt.Fprintf(os.Stderr, "mcp: panic in tool %q: %v\n", name, r)
72+
result = &mcpsdk.CallToolResult{
73+
IsError: true,
74+
Content: []mcpsdk.Content{&mcpsdk.TextContent{
75+
Text: fmt.Sprintf("internal error in tool %q: %v", name, r),
76+
}},
77+
}
78+
out = zero
79+
err = nil
80+
}
81+
}()
82+
83+
out, callErr := fn(ctx, d, in)
84+
if callErr != nil {
85+
var zero Out
86+
return &mcpsdk.CallToolResult{
87+
IsError: true,
88+
Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: callErr.Error()}},
89+
}, zero, nil
90+
}
91+
return nil, out, nil
92+
},
93+
)
94+
}

0 commit comments

Comments
 (0)