#
tokens: 26012/50000 20/20 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .github
│   ├── dependabot.yml
│   └── workflows
│       ├── ci.yml
│       ├── release.yml
│       └── security.yml
├── .gitignore
├── .golangci.yaml
├── .ko.yaml
├── cmd
│   └── server
│       ├── main_test.go
│       └── main.go
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── dco.md
├── go.mod
├── go.sum
├── LICENSE
├── pkg
│   ├── mcp
│   │   ├── server_test.go
│   │   └── server.go
│   └── osv
│       ├── client_test.go
│       └── client.go
├── README.md
├── renovate.json
├── SECURITY.md
└── Taskfile.yml
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Binaries for programs and plugins
 2 | *.exe
 3 | *.exe~
 4 | *.dll
 5 | *.so
 6 | *.dylib
 7 | 
 8 | # Test binary, built with `go test -c`
 9 | *.test
10 | 
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 | 
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | 
17 | # Go workspace file
18 | go.work
19 | 
20 | # Build artifacts
21 | /build/
22 | 
23 | # IDE files
24 | .idea/
25 | .vscode/
26 | *.swp
27 | *.swo
28 | 
29 | # OS files
30 | .DS_Store
31 | Thumbs.db
32 | 
33 | # ko build artifacts
34 | .ko.local/
35 | 
36 | # task
37 | .task
38 | 
```

--------------------------------------------------------------------------------
/.ko.yaml:
--------------------------------------------------------------------------------

```yaml
 1 | builds:
 2 | - id: osv-mcp-server
 3 |   dir: .
 4 |   main: ./cmd/server
 5 |   env:
 6 |     - CGO_ENABLED=0
 7 |   flags:
 8 |     - -trimpath
 9 |   ldflags:
10 |     - -s -w
11 |     - -X main.version={{.Env.VERSION}}
12 |   labels:
13 |     org.opencontainers.image.created: "{{.Env.CREATION_TIME}}"
14 |     org.opencontainers.image.description: "osv-mcp - An OSV MCP Server"
15 |     org.opencontainers.image.licenses: "Apache-2.0"
16 |     org.opencontainers.image.revision: "{{.Env.GITHUB_SHA}}"
17 |     org.opencontainers.image.source: "{{.Env.GITHUB_SERVER_URL}}/{{.Env.GITHUB_REPOSITORY}}"
18 |     org.opencontainers.image.title: "osv-mcp"
19 |     org.opencontainers.image.url: "{{.Env.GITHUB_SERVER_URL}}/{{.Env.GITHUB_REPOSITORY}}"
20 |     org.opencontainers.image.version: "{{.Env.VERSION}}"
21 | 
```

--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------

```yaml
  1 | version: "2"
  2 | run:
  3 |   issues-exit-code: 1
  4 | output:
  5 |   formats:
  6 |     text:
  7 |       path: stdout
  8 |       print-linter-name: true
  9 |       print-issued-lines: true
 10 | linters:
 11 |   default: none
 12 |   enable:
 13 |     - depguard
 14 |     - exhaustive
 15 |     - goconst
 16 |     - gocyclo
 17 |     - gosec
 18 |     - govet
 19 |     - ineffassign
 20 |     - lll
 21 |     - paralleltest
 22 |     - promlinter
 23 |     - revive
 24 |     - staticcheck
 25 |     - thelper
 26 |     - tparallel
 27 |     - unparam
 28 |     - unused
 29 |   settings:
 30 |     depguard:
 31 |       rules:
 32 |         prevent_unmaintained_packages:
 33 |           list-mode: lax
 34 |           files:
 35 |             - $all
 36 |             - '!$test'
 37 |           deny:
 38 |             - pkg: io/ioutil
 39 |               desc: this is deprecated
 40 |     gocyclo:
 41 |       min-complexity: 15
 42 |     gosec:
 43 |       excludes:
 44 |         - G601
 45 |     lll:
 46 |       line-length: 130
 47 |     revive:
 48 |       severity: warning
 49 |       rules:
 50 |         - name: blank-imports
 51 |           severity: warning
 52 |         - name: context-as-argument
 53 |         - name: context-keys-type
 54 |         - name: duplicated-imports
 55 |         - name: error-naming
 56 |         - name: error-return
 57 |         - name: exported
 58 |           severity: error
 59 |         - name: if-return
 60 |         - name: identical-branches
 61 |         - name: indent-error-flow
 62 |         - name: import-shadowing
 63 |         - name: package-comments
 64 |         - name: redefines-builtin-id
 65 |         - name: struct-tag
 66 |         - name: unconditional-recursion
 67 |         - name: unnecessary-stmt
 68 |         - name: unreachable-code
 69 |         - name: unused-parameter
 70 |         - name: unused-receiver
 71 |         - name: unhandled-error
 72 |           disabled: true
 73 |   exclusions:
 74 |     generated: lax
 75 |     rules:
 76 |       - linters:
 77 |           - lll
 78 |           - gocyclo
 79 |           - errcheck
 80 |           - dupl
 81 |           - gosec
 82 |           - paralleltest
 83 |         path: (.+)_test\.go
 84 |       - linters:
 85 |           - lll
 86 |         path: .golangci.yml
 87 |     paths:
 88 |       - third_party$
 89 |       - builtin$
 90 |       - examples$
 91 | formatters:
 92 |   enable:
 93 |     - gci
 94 |     - gofmt
 95 |   settings:
 96 |     gci:
 97 |       sections:
 98 |         - standard
 99 |         - default
100 |         - prefix(github.com/StacklokLabs/osv-mcp)
101 |   exclusions:
102 |     generated: lax
103 |     paths:
104 |       - third_party$
105 |       - builtin$
106 |       - examples$
107 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # OSV MCP Server
  2 | [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/StacklokLabs/osv-mcp)](https://archestra.ai/mcp-catalog/stackloklabs__osv-mcp)
  3 | 
  4 | An MCP (Model Context Protocol) server that provides access to the
  5 | [OSV (Open Source Vulnerabilities) database](https://osv.dev/).
  6 | 
  7 | ## Overview
  8 | 
  9 | This project implements an SSE-based MCP server that allows LLM-powered
 10 | applications to query the OSV database for vulnerability information. The server
 11 | provides tools for:
 12 | 
 13 | 1. Querying vulnerabilities for a specific package version or commit
 14 | 2. Batch querying vulnerabilities for multiple packages or commits
 15 | 3. Getting detailed information about a specific vulnerability by ID
 16 | 
 17 | ## Installation
 18 | 
 19 | ### Prerequisites
 20 | 
 21 | - Go 1.21 or later
 22 | - [Task](https://taskfile.dev/) (optional, for running tasks)
 23 | - [ko](https://ko.build/) (optional, for building container images)
 24 | 
 25 | ### Building from source
 26 | 
 27 | ```bash
 28 | # Clone the repository
 29 | git clone https://github.com/StacklokLabs/osv-mcp.git
 30 | cd osv-mcp
 31 | 
 32 | # Build the server
 33 | task build
 34 | ```
 35 | 
 36 | ## Usage
 37 | 
 38 | ### Running with ToolHive (Recommended)
 39 | 
 40 | The easiest way to run the OSV MCP server is using
 41 | [ToolHive](https://github.com/stacklok/toolhive), which provides secure,
 42 | containerized deployment of MCP servers:
 43 | 
 44 | ```bash
 45 | # Install ToolHive (if not already installed)
 46 | # See: https://docs.stacklok.com/toolhive/guides-cli/install
 47 | 
 48 | # Register a supported client so ToolHive can auto-configure your environment
 49 | thv client setup
 50 | 
 51 | # Run the OSV MCP server (packaged as 'osv' in ToolHive)
 52 | thv run osv
 53 | 
 54 | # List running servers
 55 | thv list
 56 | 
 57 | # Get detailed information about the server
 58 | thv registry info osv
 59 | ```
 60 | 
 61 | The server will be available to your MCP-compatible clients and can query the
 62 | OSV database for vulnerability information.
 63 | 
 64 | ### Running from Source
 65 | 
 66 | ### Server Configuration
 67 | 
 68 | The server can be configured using environment variables:
 69 | 
 70 | - `MCP_PORT`: The port number to run the server on (default: 8080)
 71 | 
 72 |   - Must be a valid integer between 0 and 65535
 73 |   - If invalid or not set, the server will use port 8080
 74 | 
 75 | - `MCP_TRANSPORT`: The transport mode for the server (default: `sse`)
 76 |   - Supported values: `sse`, `streamable-http`
 77 |   - If invalid or not set, the server will use SSE transport mode
 78 | 
 79 | Example:
 80 | 
 81 | ```bash
 82 | # Run on port 3000
 83 | MCP_PORT=3000 ./build/osv-mcp-server
 84 | 
 85 | # Run on default port 8080
 86 | ./build/osv-mcp-server
 87 | ```
 88 | 
 89 | ### MCP Tools
 90 | 
 91 | The server provides the following MCP tools:
 92 | 
 93 | #### query_vulnerability
 94 | 
 95 | Query for vulnerabilities affecting a specific package version or commit.
 96 | 
 97 | **Input Schema:**
 98 | 
 99 | ```json
100 | {
101 |   "type": "object",
102 |   "properties": {
103 |     "commit": {
104 |       "type": "string",
105 |       "description": "The commit hash to query for. If specified, version should not be set."
106 |     },
107 |     "version": {
108 |       "type": "string",
109 |       "description": "The version string to query for. If specified, commit should not be set."
110 |     },
111 |     "package_name": {
112 |       "type": "string",
113 |       "description": "The name of the package."
114 |     },
115 |     "ecosystem": {
116 |       "type": "string",
117 |       "description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
118 |     },
119 |     "purl": {
120 |       "type": "string",
121 |       "description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
122 |     }
123 |   }
124 | }
125 | ```
126 | 
127 | #### query_vulnerabilities_batch
128 | 
129 | Query for vulnerabilities affecting multiple packages or commits at once.
130 | 
131 | **Input Schema:**
132 | 
133 | ```json
134 | {
135 |   "type": "object",
136 |   "properties": {
137 |     "queries": {
138 |       "type": "array",
139 |       "description": "Array of query objects",
140 |       "items": {
141 |         "type": "object",
142 |         "properties": {
143 |           "commit": {
144 |             "type": "string",
145 |             "description": "The commit hash to query for. If specified, version should not be set."
146 |           },
147 |           "version": {
148 |             "type": "string",
149 |             "description": "The version string to query for. If specified, commit should not be set."
150 |           },
151 |           "package_name": {
152 |             "type": "string",
153 |             "description": "The name of the package."
154 |           },
155 |           "ecosystem": {
156 |             "type": "string",
157 |             "description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
158 |           },
159 |           "purl": {
160 |             "type": "string",
161 |             "description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
162 |           }
163 |         }
164 |       }
165 |     }
166 |   },
167 |   "required": ["queries"]
168 | }
169 | ```
170 | 
171 | #### get_vulnerability
172 | 
173 | Get details for a specific vulnerability by ID.
174 | 
175 | **Input Schema:**
176 | 
177 | ```json
178 | {
179 |   "type": "object",
180 |   "properties": {
181 |     "id": {
182 |       "type": "string",
183 |       "description": "The OSV vulnerability ID"
184 |     }
185 |   },
186 |   "required": ["id"]
187 | }
188 | ```
189 | 
190 | ## Examples
191 | 
192 | ### Querying vulnerabilities for a package
193 | 
194 | ```json
195 | {
196 |   "package_name": "lodash",
197 |   "ecosystem": "npm",
198 |   "version": "4.17.15"
199 | }
200 | ```
201 | 
202 | ### Querying vulnerabilities for a commit
203 | 
204 | ```json
205 | {
206 |   "commit": "6879efc2c1596d11a6a6ad296f80063b558d5e0f"
207 | }
208 | ```
209 | 
210 | ### Batch querying vulnerabilities
211 | 
212 | ```json
213 | {
214 |   "queries": [
215 |     {
216 |       "package_name": "lodash",
217 |       "ecosystem": "npm",
218 |       "version": "4.17.15"
219 |     },
220 |     {
221 |       "package_name": "jinja2",
222 |       "ecosystem": "PyPI",
223 |       "version": "2.4.1"
224 |     }
225 |   ]
226 | }
227 | ```
228 | 
229 | ### Getting vulnerability details
230 | 
231 | ```json
232 | {
233 |   "id": "GHSA-vqj2-4v8m-8vrq"
234 | }
235 | ```
236 | 
237 | ## Development
238 | 
239 | ### Running tests
240 | 
241 | ```bash
242 | task test
243 | ```
244 | 
245 | ### Linting
246 | 
247 | ```bash
248 | task lint
249 | ```
250 | 
251 | ### Formatting code
252 | 
253 | ```bash
254 | task fmt
255 | ```
256 | 
257 | ## Contributing
258 | 
259 | We welcome contributions to this MCP server! If you'd like to contribute, please
260 | review the [CONTRIBUTING guide](./CONTRIBUTING.md) for details on how to get
261 | started.
262 | 
263 | If you run into a bug or have a feature request, please
264 | [open an issue](https://github.com/StacklokLabs/osv-mcp/issues) in the
265 | repository or join us in the `#mcp-servers` channel on our
266 | [community Discord server](https://discord.gg/stacklok).
267 | 
268 | ## License
269 | 
270 | This project is licensed under the Apache v2 License - see the LICENSE file for
271 | details.
272 | 
```

--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Code of Conduct
 2 | 
 3 | ## Our Pledge
 4 | 
 5 | In the interest of fostering an open and welcoming environment, we as
 6 | contributors and maintainers pledge to making participation in our project and
 7 | our community a harassment-free experience for everyone, regardless of age, body
 8 | size, disability, ethnicity, gender identity and expression, level of experience,
 9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 | 
12 | ## Our Standards
13 | 
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 | 
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 | 
23 | Examples of unacceptable behavior by participants include:
24 | 
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 |   advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 |   address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 |   professional setting
33 | 
34 | ## Our Responsibilities
35 | 
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 | 
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 | 
46 | ## Scope
47 | 
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 | 
55 | ## Enforcement
56 | 
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at <[email protected]>. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 | 
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 | 
68 | ## Attribution
69 | 
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [http://contributor-covenant.org/version/1/4][version]
72 | 
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 | 
```

--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Contributing to `osv-mcp` <!-- omit from toc -->
 2 | 
 3 | First off, thank you for taking the time to contribute to osv-mcp! :+1: :tada:
 4 | osv-mcp is released under the Apache 2.0 license. If you would like to
 5 | contribute something or want to hack on the code, this document should help you
 6 | get started. You can find some hints for starting development in osv-mcp's
 7 | [README](https://github.com/StacklokLabs/osv-mcp/blob/main/README.md).
 8 | 
 9 | ## Table of contents <!-- omit from toc -->
10 | 
11 | - [Code of conduct](#code-of-conduct)
12 | - [Reporting security vulnerabilities](#reporting-security-vulnerabilities)
13 | - [How to contribute](#how-to-contribute)
14 |   - [Using GitHub Issues](#using-github-issues)
15 |   - [Not sure how to start contributing?](#not-sure-how-to-start-contributing)
16 |   - [Pull request process](#pull-request-process)
17 |   - [Commit message guidelines](#commit-message-guidelines)
18 | 
19 | ## Code of conduct
20 | 
21 | This project adheres to the
22 | [Contributor Covenant](https://github.com/StacklokLabs/osv-mcp/blob/main/CODE_OF_CONDUCT.md)
23 | code of conduct. By participating, you are expected to uphold this code. Please
24 | report unacceptable behavior to
25 | [[email protected]](mailto:[email protected]).
26 | 
27 | ## Reporting security vulnerabilities
28 | 
29 | If you think you have found a security vulnerability in osv-mcp please DO NOT
30 | disclose it publicly until we've had a chance to fix it. Please don't report
31 | security vulnerabilities using GitHub issues; instead, please follow this
32 | [process](https://github.com/StacklokLabs/osv-mcp/blob/main/SECURITY.md)
33 | 
34 | ## How to contribute
35 | 
36 | ### Using GitHub Issues
37 | 
38 | We use GitHub issues to track bugs and enhancements. If you have a general usage
39 | question, please ask in the #mcp-servers channel of the
40 | [Stacklok Discord server](https://discord.gg/stacklok).
41 | 
42 | If you are reporting a bug, please help to speed up problem diagnosis by
43 | providing as much information as possible. Ideally, that would include a small
44 | sample project that reproduces the problem.
45 | 
46 | ### Not sure how to start contributing?
47 | 
48 | PRs to resolve existing issues are greatly appreciated and issues labeled as
49 | ["good first issue"](https://github.com/StacklokLabs/osv-mcp/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
50 | are a great place to start!
51 | 
52 | ### Pull request process
53 | 
54 | -All commits must include a Signed-off-by trailer at the end of each commit
55 | message to indicate that the contributor agrees to the Developer Certificate of
56 | Origin. For additional details, check out the [DCO instructions](dco.md).
57 | 
58 | - Create an issue outlining the fix or feature.
59 | - Fork the osv-mcp repository to your own GitHub account and clone it locally.
60 | - Hack on your changes.
61 | - Correctly format your commit messages, see
62 |   [Commit message guidelines](#commit-message-guidelines) below.
63 | - Open a PR by ensuring the title and its description reflect the content of the
64 |   PR.
65 | - Ensure that CI passes, if it fails, fix the failures.
66 | - Every pull request requires a review from the core osv-mcp team before
67 |   merging.
68 | - Once approved, all of your commits will be squashed into a single commit with
69 |   your PR title.
70 | 
71 | ### Commit message guidelines
72 | 
73 | We follow the commit formatting recommendations found on
74 | [Chris Beams' How to Write a Git Commit Message article](https://chris.beams.io/posts/git-commit/):
75 | 
76 | 1. Separate subject from body with a blank line
77 | 1. Limit the subject line to 50 characters
78 | 1. Capitalize the subject line
79 | 1. Do not end the subject line with a period
80 | 1. Use the imperative mood in the subject line
81 | 1. Use the body to explain what and why vs. how
82 | 
```

--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Security Policy
  2 | 
  3 | The StacklokLabs community take security seriously! We appreciate your efforts
  4 | to disclose your findings responsibly and will make every effort to acknowledge
  5 | your contributions.
  6 | 
  7 | ## Reporting a vulnerability
  8 | 
  9 | To report a security issue, please use the GitHub Security Advisory
 10 | ["Report a Vulnerability"](https://github.com/StacklokLabs/osv-mcp/security/advisories/new)
 11 | tab.
 12 | 
 13 | If you are unable to access GitHub you can also email us at
 14 | [[email protected]](mailto:[email protected]).
 15 | 
 16 | Include steps to reproduce the vulnerability, the vulnerable versions, and any
 17 | additional files to reproduce the vulnerability.
 18 | 
 19 | If you are only comfortable sharing under GPG, please start by sending an email
 20 | requesting a public PGP key to use for encryption.
 21 | 
 22 | ### Contacting the StacklokLabs security team
 23 | 
 24 | Contact the team by sending email to
 25 | [[email protected]](mailto:[email protected]).
 26 | 
 27 | ## Disclosures
 28 | 
 29 | ### Private disclosure processes
 30 | 
 31 | The StacklokLabs community asks that all suspected vulnerabilities be handled in
 32 | accordance with
 33 | [Responsible Disclosure model](https://en.wikipedia.org/wiki/Responsible_disclosure).
 34 | 
 35 | ### Public disclosure processes
 36 | 
 37 | If anyone knows of a publicly disclosed security vulnerability please
 38 | IMMEDIATELY email [[email protected]](mailto:[email protected]) to
 39 | inform us about the vulnerability so that we may start the patch, release, and
 40 | communication process.
 41 | 
 42 | If a reporter contacts the us to express intent to make an issue public before a
 43 | fix is available, we will request if the issue can be handled via a private
 44 | disclosure process. If the reporter denies the request, we will move swiftly
 45 | with the fix and release process.
 46 | 
 47 | ## Patch, release, and public communication
 48 | 
 49 | For each vulnerability, the StacklokLabs security team will coordinate to create
 50 | the fix and release, and notify the rest of the community.
 51 | 
 52 | All of the timelines below are suggestions and assume a Private Disclosure.
 53 | 
 54 | - The security team drives the schedule using their best judgment based on
 55 |   severity, development time, and release work.
 56 | - If the security team is dealing with a Public Disclosure all timelines become
 57 |   ASAP.
 58 | - If the fix relies on another upstream project's disclosure timeline, that will
 59 |   adjust the process as well.
 60 | - We will work with the upstream project to fit their timeline and best protect
 61 |   StacklokLabs users.
 62 | - The Security team will give advance notice to the Private Distributors list
 63 |   before the fix is released.
 64 | 
 65 | ### Fix team organization
 66 | 
 67 | These steps should be completed within the first 24 hours of Disclosure.
 68 | 
 69 | - The security team will work quickly to identify relevant engineers from the
 70 |   affected projects and packages and being those engineers into the
 71 |   [security advisory](https://docs.github.com/en/code-security/security-advisories/)
 72 |   thread.
 73 | - These selected developers become the "Fix Team" (the fix team is often drawn
 74 |   from the projects MAINTAINERS)
 75 | 
 76 | ### Fix development process
 77 | 
 78 | These steps should be completed within the 1-7 days of Disclosure.
 79 | 
 80 | - Create a new
 81 |   [security advisory](https://docs.github.com/en/code-security/security-advisories/)
 82 |   in affected repository by visiting
 83 |   `https://github.com/StacklokLabs/osv-mcp/security/advisories/new`
 84 | - As many details as possible should be entered such as versions affected, CVE
 85 |   (if available yet). As more information is discovered, edit and update the
 86 |   advisory accordingly.
 87 | - Use the CVSS calculator to score a severity level.
 88 | - Add collaborators from codeowners team only (outside members can only be added
 89 |   after approval from the security team)
 90 | - The reporter may be added to the issue to assist with review, but **only
 91 |   reporters who have contacted the security team using a private channel**.
 92 | - Select 'Request CVE'
 93 | - The security team / Fix Team create a private temporary fork
 94 | - The Fix team performs all work in a 'security advisory' within its temporary
 95 |   fork
 96 | - CI can be checked locally using the [act](https://github.com/nektos/act)
 97 |   project
 98 | - All communication happens within the security advisory, it is _not_ discussed
 99 |   in slack channels or non private issues.
100 | - The Fix Team will notify the security team that work on the fix branch is
101 |   completed, this can be done by tagging names in the advisory
102 | - The Fix team and the security team will agree on fix release day
103 | - The recommended release time is 4pm UTC on a non-Friday weekday. This means
104 |   the announcement will be seen morning Pacific, early evening Europe, and late
105 |   evening Asia.
106 | 
107 | If the CVSS score is under ~4.0
108 | ([a low severity score](https://www.first.org/cvss/specification-document#i5))
109 | or the assessed risk is low the Fix Team can decide to slow the release process
110 | down in the face of holidays, developer bandwidth, etc.
111 | 
112 | Note: CVSS is convenient but imperfect. Ultimately, the security team has
113 | discretion on classifying the severity of a vulnerability.
114 | 
115 | The severity of the bug and related handling decisions must be discussed on in
116 | the security advisory, never in public repos.
117 | 
118 | ### Fix disclosure process
119 | 
120 | With the Fix Development underway, the security team needs to come up with an
121 | overall communication plan for the wider community. This Disclosure process
122 | should begin after the Fix Team has developed a Fix or mitigation so that a
123 | realistic timeline can be communicated to users.
124 | 
125 | **Fix release day** (Completed within 1-21 days of Disclosure)
126 | 
127 | - The Fix Team will approve the related pull requests in the private temporary
128 |   branch of the security advisory
129 | - The security team will merge the security advisory / temporary fork and its
130 |   commits into the main branch of the affected repository
131 | - The security team will ensure all the binaries are built, signed, publicly
132 |   available, and functional.
133 | - The security team will announce the new releases, the CVE number, severity,
134 |   and impact, and the location of the binaries to get wide distribution and user
135 |   action. As much as possible this announcement should be actionable, and
136 |   include any mitigating steps users can take prior to upgrading to a fixed
137 |   version. An announcement template is available below. The announcement will be
138 |   sent to the following channels:
139 | - A link to fix will be posted to the
140 |   [Stacklok Discord Server](https://discord.gg/stacklok) in the #mcp-servers
141 |   channel.
142 | 
143 | ## Retrospective
144 | 
145 | These steps should be completed 1-3 days after the Release Date. The
146 | retrospective process
147 | [should be blameless](https://landing.google.com/sre/book/chapters/postmortem-culture.html).
148 | 
149 | - The security team will send a retrospective of the process to the
150 |   [Stacklok Discord Server](https://discord.gg/stacklok) including details on
151 |   everyone involved, the timeline of the process, links to relevant PRs that
152 |   introduced the issue, if relevant, and any critiques of the response and
153 |   release process.
154 | 
```

--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------

```json
1 | {
2 |   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 |   "extends": [
4 |     "config:recommended"
5 |   ]
6 | }
7 | 
```

--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------

```yaml
 1 | version: 2
 2 | updates:
 3 |   # Enable version updates for Go modules
 4 |   - package-ecosystem: "gomod"
 5 |     directory: "/"
 6 |     schedule:
 7 |       interval: "weekly"
 8 |     open-pull-requests-limit: 10
 9 | 
10 |   # Enable version updates for GitHub Actions
11 |   - package-ecosystem: "github-actions"
12 |     directory: "/"
13 |     schedule:
14 |       interval: "weekly"
15 |     open-pull-requests-limit: 10
```

--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: CI
 2 | 
 3 | on:
 4 |   push:
 5 |     branches: [ main ]
 6 |   pull_request:
 7 |     branches: [ main ]
 8 | 
 9 | jobs:
10 |   lint:
11 |     name: Lint
12 |     runs-on: ubuntu-latest
13 |     steps:
14 |       - name: Checkout code
15 |         uses: actions/checkout@v5
16 | 
17 |       - name: Set up Go
18 |         uses: actions/setup-go@v6
19 |         with:
20 |           go-version-file: 'go.mod'
21 |           cache: true
22 | 
23 |       - name: golangci-lint
24 |         uses: golangci/golangci-lint-action@v8
25 |         with:
26 |           version: latest
27 | 
28 |   build:
29 |     name: Build and Test
30 |     runs-on: ubuntu-latest
31 |     steps:
32 |       - name: Checkout code
33 |         uses: actions/checkout@v5
34 | 
35 |       - name: Set up Go
36 |         uses: actions/setup-go@v6
37 |         with:
38 |           go-version-file: 'go.mod'
39 |           cache: true
40 | 
41 |       - name: Install Task
42 |         uses: arduino/setup-task@v2
43 |         with:
44 |           version: '3.x'
45 |           repo-token: ${{ secrets.GITHUB_TOKEN }}
46 | 
47 |       - name: Install dependencies
48 |         run: task install
49 | 
50 |       - name: Build
51 |         run: task build
52 | 
53 |       - name: Test
54 |         run: task test
55 | 
56 |       - name: Upload build artifacts
57 |         uses: actions/upload-artifact@v4
58 |         with:
59 |           name: osv-mcp-server
60 |           path: build/osv-mcp-server
61 |           retention-days: 7
```

--------------------------------------------------------------------------------
/.github/workflows/security.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: Security Scan
 2 | 
 3 | on:
 4 |   pull_request:
 5 |     branches: [ main ]
 6 |   schedule:
 7 |     - cron: '0 0 * * 0'  # Run weekly on Sundays at midnight
 8 | 
 9 | jobs:
10 |   trivy-scan:
11 |     name: Trivy Security Scan
12 |     runs-on: ubuntu-latest
13 |     steps:
14 |       - name: Checkout code
15 |         uses: actions/checkout@v5
16 | 
17 |       - name: Run Trivy vulnerability scanner in repo mode
18 |         uses: aquasecurity/[email protected]
19 |         with:
20 |           scan-type: 'fs'
21 |           ignore-unfixed: true
22 |           format: 'sarif'
23 |           output: 'trivy-results.sarif'
24 |           severity: 'CRITICAL,HIGH'
25 | 
26 |       - name: Upload Trivy scan results to GitHub Security tab
27 |         uses: github/codeql-action/upload-sarif@v4
28 |         if: always()
29 |         with:
30 |           sarif_file: 'trivy-results.sarif'
31 |           category: 'trivy-fs'
32 | 
33 |       - name: Run Trivy vulnerability scanner in IaC mode
34 |         uses: aquasecurity/[email protected]
35 |         with:
36 |           scan-type: 'config'
37 |           hide-progress: false
38 |           format: 'sarif'
39 |           output: 'trivy-config-results.sarif'
40 |           exit-code: '1'
41 |           severity: 'CRITICAL,HIGH'
42 | 
43 |       - name: Upload Trivy IaC scan results to GitHub Security tab
44 |         uses: github/codeql-action/upload-sarif@v4
45 |         if: always()
46 |         with:
47 |           sarif_file: 'trivy-config-results.sarif'
48 |           category: 'trivy-config'
```

--------------------------------------------------------------------------------
/cmd/server/main_test.go:
--------------------------------------------------------------------------------

```go
 1 | package main
 2 | 
 3 | import (
 4 | 	"os"
 5 | 	"testing"
 6 | 
 7 | 	"github.com/stretchr/testify/assert"
 8 | 	"github.com/stretchr/testify/require"
 9 | 
10 | 	"github.com/StacklokLabs/osv-mcp/pkg/mcp"
11 | 	"github.com/StacklokLabs/osv-mcp/pkg/osv"
12 | )
13 | 
14 | func TestCreateServer(t *testing.T) {
15 | 	// Create OSV client
16 | 	osvClient := osv.NewClient()
17 | 	require.NotNil(t, osvClient)
18 | 
19 | 	// Create MCP server
20 | 	mcpServer := mcp.NewServer(
21 | 		mcp.WithOSVClient(osvClient),
22 | 	)
23 | 	require.NotNil(t, mcpServer)
24 | 
25 | 	// Verify server properties
26 | 	assert.Equal(t, mcp.ServerName, "osv-mcp")
27 | 	assert.Equal(t, mcp.ServerVersion, "0.1.0")
28 | }
29 | 
30 | func TestGetMCPServerPort(t *testing.T) {
31 | 	// Save original env value and restore it after the test
32 | 	originalPort := os.Getenv("MCP_PORT")
33 | 	defer func() {
34 | 		if originalPort != "" {
35 | 			os.Setenv("MCP_PORT", originalPort)
36 | 		} else {
37 | 			os.Unsetenv("MCP_PORT")
38 | 		}
39 | 	}()
40 | 
41 | 	tests := []struct {
42 | 		name     string
43 | 		envPort  string
44 | 		expected string
45 | 	}{
46 | 		{
47 | 			name:     "No environment variable set",
48 | 			envPort:  "",
49 | 			expected: "8080",
50 | 		},
51 | 		{
52 | 			name:     "Valid port number",
53 | 			envPort:  "3000",
54 | 			expected: "3000",
55 | 		},
56 | 		{
57 | 			name:     "Invalid port (non-numeric)",
58 | 			envPort:  "abc",
59 | 			expected: "8080",
60 | 		},
61 | 		{
62 | 			name:     "Invalid port (negative number)",
63 | 			envPort:  "-1",
64 | 			expected: "8080",
65 | 		},
66 | 		{
67 | 			name:     "Invalid port (too large)",
68 | 			envPort:  "70000",
69 | 			expected: "8080",
70 | 		},
71 | 	}
72 | 
73 | 	for _, tt := range tests {
74 | 		t.Run(tt.name, func(t *testing.T) {
75 | 			// Set up environment
76 | 			if tt.envPort != "" {
77 | 				os.Setenv("MCP_PORT", tt.envPort)
78 | 			} else {
79 | 				os.Unsetenv("MCP_PORT")
80 | 			}
81 | 
82 | 			// Test the function
83 | 			port := getMCPServerPort()
84 | 			assert.Equal(t, tt.expected, port)
85 | 		})
86 | 	}
87 | }
88 | 
```

--------------------------------------------------------------------------------
/Taskfile.yml:
--------------------------------------------------------------------------------

```yaml
 1 | version: '3'
 2 | 
 3 | vars:
 4 |   BINARY_NAME: osv-mcp-server
 5 |   BUILD_DIR: build
 6 |   MAIN_PACKAGE: ./cmd/server
 7 |   KO_DOCKER_REPO: stackloklabs/osv-mcp
 8 | 
 9 | tasks:
10 |   default:
11 |     desc: Run tests and build the application
12 |     deps: [test, build]
13 | 
14 |   build:
15 |     desc: Build the application
16 |     cmds:
17 |       - mkdir -p {{.BUILD_DIR}}
18 |       - go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.MAIN_PACKAGE}}
19 | 
20 |   run:
21 |     desc: Run the application
22 |     deps: [build]
23 |     cmds:
24 |       - ./{{.BUILD_DIR}}/{{.BINARY_NAME}} {{.CLI_ARGS}}
25 | 
26 |   test:
27 |     desc: Run tests
28 |     cmds:
29 |       - go test -v ./...
30 | 
31 |   clean:
32 |     desc: Clean the build directory
33 |     cmds:
34 |       - rm -rf {{.BUILD_DIR}}
35 | 
36 |   fmt:
37 |     desc: Format the code
38 |     cmds:
39 |       - go fmt ./...
40 |       - golangci-lint run --fix
41 | 
42 |   lint:
43 |     desc: Lint the code
44 |     cmds:
45 |       - golangci-lint run
46 | 
47 |   deps:
48 |     desc: Update dependencies
49 |     cmds:
50 |       - go mod tidy
51 | 
52 |   install:
53 |     desc: Install dependencies
54 |     cmds:
55 |       - go mod download
56 | 
57 |   ko-build:
58 |     desc: Build container image with ko
59 |     env:
60 |       KO_DOCKER_REPO: '{{.KO_DOCKER_REPO}}'
61 |     cmds:
62 |       - ko build --platform=linux/amd64 --base-import-paths {{.MAIN_PACKAGE}} -t latest
63 | 
64 |   ko-run:
65 |     desc: Run container built with ko
66 |     env:
67 |       KO_DOCKER_REPO: '{{.KO_DOCKER_REPO}}'
68 |     cmds:
69 |       - ko run --platform=linux/amd64 --base-import-paths {{.MAIN_PACKAGE}} -- {{.CLI_ARGS}}
70 | 
71 |   ko-publish:
72 |     desc: Publish container image with ko
73 |     env:
74 |       KO_DOCKER_REPO: '{{.KO_DOCKER_REPO}}'
75 |     cmds:
76 |       - ko publish --platform=linux/amd64 --base-import-paths {{.MAIN_PACKAGE}} -t latest
77 | 
78 |   all:
79 |     desc: Run all tasks (fmt, lint, test, build)
80 |     cmds:
81 |       - task: fmt
82 |       - task: lint
83 |       - task: test
84 |       - task: build
```

--------------------------------------------------------------------------------
/dco.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Developer Certificate of Origin (DCO)
 2 | 
 3 | In order to contribute to the project, you must agree to the Developer
 4 | Certificate of Origin. A
 5 | [Developer Certificate of Origin (DCO)](https://developercertificate.org/) is an
 6 | affirmation that the developer contributing the proposed changes has the
 7 | necessary rights to submit those changes. A DCO provides some additional legal
 8 | protections while being relatively easy to do.
 9 | 
10 | The entire DCO can be summarized as:
11 | 
12 | - Certify that the submitted code can be submitted under the open source license
13 |   of the project (e.g. MIT)
14 | - I understand that what I am contributing is public and will be redistributed
15 |   indefinitely
16 | 
17 | ## How to Use Developer Certificate of Origin
18 | 
19 | In order to contribute to the project, you must agree to the Developer
20 | Certificate of Origin. To confirm that you agree, your commit message must
21 | include a Signed-off-by trailer at the bottom of the commit message.
22 | 
23 | For example, it might look like the following:
24 | 
25 | ```bash
26 | A commit message
27 | 
28 | Closes gh-345
29 | 
30 | Signed-off-by: jane marmot <[email protected]>
31 | ```
32 | 
33 | The Signed-off-by [trailer](https://git-scm.com/docs/git-interpret-trailers) can
34 | be added automatically by using the
35 | [-s or –signoff command line option](https://git-scm.com/docs/git-commit/2.13.7#Documentation/git-commit.txt--s)
36 | when specifying your commit message:
37 | 
38 | ```bash
39 | git commit -s -m
40 | ```
41 | 
42 | If you have chosen the
43 | [Keep my email address private](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#about-commit-email-addresses)
44 | option within GitHub, the Signed-off-by trailer might look something like:
45 | 
46 | ```bash
47 | A commit message
48 | 
49 | Closes gh-345
50 | 
51 | Signed-off-by: jane marmot <[email protected]>
52 | ```
53 | 
```

--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: Release
 2 | 
 3 | on:
 4 |   push:
 5 |     tags:
 6 |       - 'v*'
 7 | 
 8 | jobs:
 9 |   release:
10 |     name: Release Container
11 |     runs-on: ubuntu-latest
12 |     permissions:
13 |       contents: read
14 |       packages: write
15 |       id-token: write
16 |     steps:
17 |       - name: Checkout code
18 |         uses: actions/checkout@v5
19 |         with:
20 |           fetch-depth: 0
21 | 
22 |       - name: Set up Go
23 |         uses: actions/setup-go@v6
24 |         with:
25 |           go-version-file: 'go.mod'
26 |           cache: true
27 | 
28 |       - name: Install Task
29 |         uses: arduino/setup-task@v2
30 |         with:
31 |           version: '3.x'
32 |           repo-token: ${{ secrets.GITHUB_TOKEN }}
33 | 
34 |       - name: Install dependencies
35 |         run: task install
36 | 
37 |       - name: Test
38 |         run: task test
39 | 
40 |       - name: Setup Ko
41 |         uses: ko-build/[email protected]
42 | 
43 |       - name: Log in to GitHub Container Registry
44 |         uses: docker/login-action@v3
45 |         with:
46 |           registry: ghcr.io
47 |           username: ${{ github.actor }}
48 |           password: ${{ secrets.GITHUB_TOKEN }}
49 | 
50 |       - name: Extract tag version
51 |         id: tag
52 |         run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
53 | 
54 |       - name: Set repository owner lowercase
55 |         id: repo_owner
56 |         run: echo "OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
57 | 
58 |       - name: Build and push container
59 |         env:
60 |           KO_DOCKER_REPO: ghcr.io/${{ steps.repo_owner.outputs.OWNER }}/osv-mcp
61 |           VERSION: ${{ steps.tag.outputs.VERSION }}
62 |           CREATION_TIME: $(date -u +'%Y-%m-%dT%H:%M:%SZ')
63 |         run: |
64 |           # Build and push the container with reproducible build flags
65 |           ko build \
66 |             --bare \
67 |             --sbom=spdx \
68 |             --platform=linux/amd64,linux/arm64 \
69 |             --base-import-paths \
70 |             --tags $VERSION,latest \
71 |             ./cmd/server
72 | 
73 |       - name: Install Cosign
74 |         uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
75 | 
76 |       - name: Sign Image with Cosign
77 |         env:
78 |           KO_DOCKER_REPO: ghcr.io/${{ steps.repo_owner.outputs.OWNER }}/osv-mcp
79 |         run: |
80 |           TAG=$(echo "${{ steps.tag.outputs.VERSION }}" | sed 's/+/_/g')
81 |           # Sign the ko image
82 |           cosign sign -y $KO_DOCKER_REPO/server:$TAG
83 | 
84 |           # Sign the latest tag if building from a tag
85 |           if [[ "${{ github.ref }}" == refs/tags/* ]]; then
86 |             cosign sign -y $KO_DOCKER_REPO/server:latest
87 |           fi
88 | 
```

--------------------------------------------------------------------------------
/cmd/server/main.go:
--------------------------------------------------------------------------------

```go
  1 | // Package main provides the entry point for the OSV MCP Server.
  2 | package main
  3 | 
  4 | import (
  5 | 	"flag"
  6 | 	"fmt"
  7 | 	"log"
  8 | 	"os"
  9 | 	"os/signal"
 10 | 	"strconv"
 11 | 	"strings"
 12 | 	"syscall"
 13 | 
 14 | 	"github.com/StacklokLabs/osv-mcp/pkg/mcp"
 15 | 	"github.com/StacklokLabs/osv-mcp/pkg/osv"
 16 | )
 17 | 
 18 | // TransportMode defines the type for transport modes used by the MCP server.
 19 | type TransportMode string
 20 | 
 21 | const (
 22 | 	// TransportSSE represents the Server-Sent Events transport mode.
 23 | 	TransportSSE TransportMode = "sse"
 24 | 	// TransportHTTPStream represents the HTTP streaming transport mode.
 25 | 	TransportHTTPStream TransportMode = "streamable-http"
 26 | )
 27 | 
 28 | func getTransportMode() (TransportMode, error) {
 29 | 	mode := strings.ToLower(strings.TrimSpace(os.Getenv("MCP_TRANSPORT")))
 30 | 	if mode == "" {
 31 | 		return TransportSSE, nil // default
 32 | 	}
 33 | 
 34 | 	switch TransportMode(mode) {
 35 | 	case TransportSSE, TransportHTTPStream:
 36 | 		return TransportMode(mode), nil
 37 | 	default:
 38 | 		return "", fmt.Errorf("invalid MCP_TRANSPORT: %q (allowed: sse, streamable-http)", mode)
 39 | 	}
 40 | }
 41 | 
 42 | // getMCPServerPort returns the port number from MCP_PORT environment variable.
 43 | // If the environment variable is not set or contains an invalid value,
 44 | // it returns the default port 8080.
 45 | func getMCPServerPort() string {
 46 | 	port := "8080"
 47 | 	if envPort := os.Getenv("MCP_PORT"); envPort != "" {
 48 | 		if portNum, err := strconv.Atoi(envPort); err == nil {
 49 | 			if portNum >= 0 && portNum <= 65535 {
 50 | 				port = envPort
 51 | 			} else {
 52 | 				log.Printf("Invalid MCP_PORT value: %s (must be between 0 and 65535), using default port 8080", envPort)
 53 | 			}
 54 | 		} else {
 55 | 			log.Printf("Invalid MCP_PORT value: %s (must be a valid number), using default port 8080", envPort)
 56 | 		}
 57 | 	}
 58 | 	return port
 59 | }
 60 | 
 61 | func main() {
 62 | 	// Get port from environment variable or use default
 63 | 	port := getMCPServerPort()
 64 | 
 65 | 	// Parse command-line flags
 66 | 	addr := flag.String("addr", ":"+port, "Address to listen on")
 67 | 	flag.Parse()
 68 | 
 69 | 	mode, err := getTransportMode()
 70 | 	if err != nil {
 71 | 		log.Fatalf("Error getting transport mode: %v", err)
 72 | 	}
 73 | 
 74 | 	// Create OSV client
 75 | 	osvClient := osv.NewClient()
 76 | 
 77 | 	// Create MCP server
 78 | 	mcpServer := mcp.NewServer(
 79 | 		mcp.WithOSVClient(osvClient),
 80 | 	)
 81 | 
 82 | 	// Handle signals for graceful shutdown
 83 | 	sigChan := make(chan os.Signal, 1)
 84 | 	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
 85 | 
 86 | 	// Start server in a goroutine
 87 | 	errChan := make(chan error, 1)
 88 | 	go func() {
 89 | 		switch mode {
 90 | 		case TransportHTTPStream:
 91 | 			errChan <- mcpServer.ServeHTTPStream(*addr)
 92 | 		case TransportSSE:
 93 | 			errChan <- mcpServer.ServeSSE(*addr)
 94 | 		}
 95 | 	}()
 96 | 
 97 | 	// Wait for signal or error
 98 | 	select {
 99 | 	case err := <-errChan:
100 | 		if err != nil {
101 | 			log.Fatalf("Server error: %v", err)
102 | 		}
103 | 	case sig := <-sigChan:
104 | 		log.Printf("Received signal: %v", sig)
105 | 	}
106 | 
107 | 	log.Println("Shutting down server")
108 | }
109 | 
```

--------------------------------------------------------------------------------
/pkg/osv/client_test.go:
--------------------------------------------------------------------------------

```go
  1 | package osv
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"net/http"
  7 | 	"net/http/httptest"
  8 | 	"testing"
  9 | 	"time"
 10 | 
 11 | 	"github.com/stretchr/testify/assert"
 12 | 	"github.com/stretchr/testify/require"
 13 | )
 14 | 
 15 | func TestQuery(t *testing.T) {
 16 | 	// Setup test server
 17 | 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 18 | 		// Check request method and path
 19 | 		assert.Equal(t, http.MethodPost, r.Method)
 20 | 		assert.Equal(t, "/v1/query", r.URL.Path)
 21 | 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
 22 | 
 23 | 		// Decode request body
 24 | 		var req QueryRequest
 25 | 		err := json.NewDecoder(r.Body).Decode(&req)
 26 | 		require.NoError(t, err)
 27 | 
 28 | 		// Check request fields
 29 | 		assert.Equal(t, "test-package", req.Package.Name)
 30 | 		assert.Equal(t, "npm", req.Package.Ecosystem)
 31 | 		assert.Equal(t, "1.0.0", req.Version)
 32 | 
 33 | 		// Return mock response
 34 | 		w.Header().Set("Content-Type", "application/json")
 35 | 		w.WriteHeader(http.StatusOK)
 36 | 		response := `{
 37 | 			"vulns": [
 38 | 				{
 39 | 					"id": "TEST-2023-001",
 40 | 					"summary": "Test vulnerability",
 41 | 					"details": "This is a test vulnerability",
 42 | 					"modified": "2023-01-01T00:00:00Z",
 43 | 					"published": "2023-01-01T00:00:00Z",
 44 | 					"references": [
 45 | 						{
 46 | 							"type": "ADVISORY",
 47 | 							"url": "https://example.com/advisory/TEST-2023-001"
 48 | 						}
 49 | 					],
 50 | 					"affected": [
 51 | 						{
 52 | 							"package": {
 53 | 								"name": "test-package",
 54 | 								"ecosystem": "npm"
 55 | 							},
 56 | 							"ranges": [
 57 | 								{
 58 | 									"type": "SEMVER",
 59 | 									"events": [
 60 | 										{
 61 | 											"introduced": "0"
 62 | 										},
 63 | 										{
 64 | 											"fixed": "1.0.1"
 65 | 										}
 66 | 									]
 67 | 								}
 68 | 							],
 69 | 							"versions": ["1.0.0"]
 70 | 						}
 71 | 					]
 72 | 				}
 73 | 			]
 74 | 		}`
 75 | 		_, _ = w.Write([]byte(response))
 76 | 	}))
 77 | 	defer server.Close()
 78 | 
 79 | 	// Create client with test server URL
 80 | 	client := NewClient(WithBaseURL(server.URL + "/v1"))
 81 | 
 82 | 	// Create query request
 83 | 	req := QueryRequest{
 84 | 		Version: "1.0.0",
 85 | 		Package: Package{
 86 | 			Name:      "test-package",
 87 | 			Ecosystem: "npm",
 88 | 		},
 89 | 	}
 90 | 
 91 | 	// Execute query
 92 | 	resp, err := client.Query(context.Background(), req)
 93 | 	require.NoError(t, err)
 94 | 	require.NotNil(t, resp)
 95 | 
 96 | 	// Check response
 97 | 	assert.Len(t, resp.Vulns, 1)
 98 | 	vuln := resp.Vulns[0]
 99 | 	assert.Equal(t, "TEST-2023-001", vuln.ID)
100 | 	assert.Equal(t, "Test vulnerability", vuln.Summary)
101 | 	assert.Equal(t, "This is a test vulnerability", vuln.Details)
102 | 
103 | 	expectedModified, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
104 | 	assert.Equal(t, expectedModified, vuln.Modified)
105 | 
106 | 	assert.Len(t, vuln.References, 1)
107 | 	assert.Equal(t, "ADVISORY", vuln.References[0].Type)
108 | 	assert.Equal(t, "https://example.com/advisory/TEST-2023-001", vuln.References[0].URL)
109 | 
110 | 	assert.Len(t, vuln.Affected, 1)
111 | 	assert.Equal(t, "test-package", vuln.Affected[0].Package.Name)
112 | 	assert.Equal(t, "npm", vuln.Affected[0].Package.Ecosystem)
113 | }
114 | 
115 | func TestQueryBatch(t *testing.T) {
116 | 	// Setup test server
117 | 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118 | 		// Check request method and path
119 | 		assert.Equal(t, http.MethodPost, r.Method)
120 | 		assert.Equal(t, "/v1/querybatch", r.URL.Path)
121 | 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
122 | 
123 | 		// Decode request body
124 | 		var req QueryBatchRequest
125 | 		err := json.NewDecoder(r.Body).Decode(&req)
126 | 		require.NoError(t, err)
127 | 
128 | 		// Check request fields
129 | 		assert.Len(t, req.Queries, 2)
130 | 
131 | 		// Return mock response
132 | 		w.Header().Set("Content-Type", "application/json")
133 | 		w.WriteHeader(http.StatusOK)
134 | 		response := `{
135 | 			"results": [
136 | 				{
137 | 					"vulns": [
138 | 						{
139 | 							"id": "TEST-2023-001",
140 | 							"modified": "2023-01-01T00:00:00Z"
141 | 						}
142 | 					]
143 | 				},
144 | 				{
145 | 					"vulns": [
146 | 						{
147 | 							"id": "TEST-2023-002",
148 | 							"modified": "2023-01-02T00:00:00Z"
149 | 						}
150 | 					]
151 | 				}
152 | 			]
153 | 		}`
154 | 		_, _ = w.Write([]byte(response))
155 | 	}))
156 | 	defer server.Close()
157 | 
158 | 	// Create client with test server URL
159 | 	client := NewClient(WithBaseURL(server.URL + "/v1"))
160 | 
161 | 	// Create batch query request
162 | 	req := QueryBatchRequest{
163 | 		Queries: []QueryRequest{
164 | 			{
165 | 				Version: "1.0.0",
166 | 				Package: Package{
167 | 					Name:      "test-package-1",
168 | 					Ecosystem: "npm",
169 | 				},
170 | 			},
171 | 			{
172 | 				Version: "2.0.0",
173 | 				Package: Package{
174 | 					Name:      "test-package-2",
175 | 					Ecosystem: "npm",
176 | 				},
177 | 			},
178 | 		},
179 | 	}
180 | 
181 | 	// Execute batch query
182 | 	resp, err := client.QueryBatch(context.Background(), req)
183 | 	require.NoError(t, err)
184 | 	require.NotNil(t, resp)
185 | 
186 | 	// Check response
187 | 	assert.Len(t, resp.Results, 2)
188 | 
189 | 	assert.Len(t, resp.Results[0].Vulns, 1)
190 | 	assert.Equal(t, "TEST-2023-001", resp.Results[0].Vulns[0].ID)
191 | 
192 | 	expectedModified1, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
193 | 	assert.Equal(t, expectedModified1, resp.Results[0].Vulns[0].Modified)
194 | 
195 | 	assert.Len(t, resp.Results[1].Vulns, 1)
196 | 	assert.Equal(t, "TEST-2023-002", resp.Results[1].Vulns[0].ID)
197 | 
198 | 	expectedModified2, _ := time.Parse(time.RFC3339, "2023-01-02T00:00:00Z")
199 | 	assert.Equal(t, expectedModified2, resp.Results[1].Vulns[0].Modified)
200 | }
201 | 
202 | func TestGetVulnerability(t *testing.T) {
203 | 	// Setup test server
204 | 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205 | 		// Check request method and path
206 | 		assert.Equal(t, http.MethodGet, r.Method)
207 | 		assert.Equal(t, "/v1/vulns/TEST-2023-001", r.URL.Path)
208 | 
209 | 		// Return mock response
210 | 		w.Header().Set("Content-Type", "application/json")
211 | 		w.WriteHeader(http.StatusOK)
212 | 		response := `{
213 | 			"id": "TEST-2023-001",
214 | 			"summary": "Test vulnerability",
215 | 			"details": "This is a test vulnerability",
216 | 			"modified": "2023-01-01T00:00:00Z",
217 | 			"published": "2023-01-01T00:00:00Z",
218 | 			"references": [
219 | 				{
220 | 					"type": "ADVISORY",
221 | 					"url": "https://example.com/advisory/TEST-2023-001"
222 | 				}
223 | 			],
224 | 			"affected": [
225 | 				{
226 | 					"package": {
227 | 						"name": "test-package",
228 | 						"ecosystem": "npm"
229 | 					},
230 | 					"ranges": [
231 | 						{
232 | 							"type": "SEMVER",
233 | 							"events": [
234 | 								{
235 | 									"introduced": "0"
236 | 								},
237 | 								{
238 | 									"fixed": "1.0.1"
239 | 								}
240 | 							]
241 | 						}
242 | 					],
243 | 					"versions": ["1.0.0"]
244 | 				}
245 | 			]
246 | 		}`
247 | 		_, _ = w.Write([]byte(response))
248 | 	}))
249 | 	defer server.Close()
250 | 
251 | 	// Create client with test server URL
252 | 	client := NewClient(WithBaseURL(server.URL + "/v1"))
253 | 
254 | 	// Execute get vulnerability
255 | 	vuln, err := client.GetVulnerability(context.Background(), "TEST-2023-001")
256 | 	require.NoError(t, err)
257 | 	require.NotNil(t, vuln)
258 | 
259 | 	// Check response
260 | 	assert.Equal(t, "TEST-2023-001", vuln.ID)
261 | 	assert.Equal(t, "Test vulnerability", vuln.Summary)
262 | 	assert.Equal(t, "This is a test vulnerability", vuln.Details)
263 | 
264 | 	expectedModified, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
265 | 	assert.Equal(t, expectedModified, vuln.Modified)
266 | 
267 | 	assert.Len(t, vuln.References, 1)
268 | 	assert.Equal(t, "ADVISORY", vuln.References[0].Type)
269 | 	assert.Equal(t, "https://example.com/advisory/TEST-2023-001", vuln.References[0].URL)
270 | 
271 | 	assert.Len(t, vuln.Affected, 1)
272 | 	assert.Equal(t, "test-package", vuln.Affected[0].Package.Name)
273 | 	assert.Equal(t, "npm", vuln.Affected[0].Package.Ecosystem)
274 | }
275 | 
```

--------------------------------------------------------------------------------
/pkg/osv/client.go:
--------------------------------------------------------------------------------

```go
  1 | // Package osv provides functionality for interacting with osv database.
  2 | package osv
  3 | 
  4 | import (
  5 | 	"bytes"
  6 | 	"context"
  7 | 	"encoding/json"
  8 | 	"fmt"
  9 | 	"net/http"
 10 | 	"time"
 11 | )
 12 | 
 13 | const (
 14 | 	// BaseURL is the base URL for the OSV API
 15 | 	BaseURL = "https://api.osv.dev/v1"
 16 | 
 17 | 	// QueryEndpoint is the endpoint for querying vulnerabilities
 18 | 	QueryEndpoint = "/query"
 19 | 
 20 | 	// QueryBatchEndpoint is the endpoint for batch querying vulnerabilities
 21 | 	QueryBatchEndpoint = "/querybatch"
 22 | 
 23 | 	// VulnEndpoint is the endpoint for getting vulnerability details
 24 | 	VulnEndpoint = "/vulns"
 25 | )
 26 | 
 27 | // OSVClient is the interface for the OSV API client
 28 | //
 29 | //nolint:revive // OSVClient is intentionally named to provide clarity on the client's domain
 30 | type OSVClient interface {
 31 | 	Query(ctx context.Context, req QueryRequest) (*QueryResponse, error)
 32 | 	QueryBatch(ctx context.Context, req QueryBatchRequest) (*QueryBatchResponse, error)
 33 | 	GetVulnerability(ctx context.Context, id string) (*Vulnerability, error)
 34 | }
 35 | 
 36 | // Client is a client for the OSV API
 37 | type Client struct {
 38 | 	httpClient *http.Client
 39 | 	baseURL    string
 40 | }
 41 | 
 42 | // NewClient creates a new OSV API client
 43 | func NewClient(opts ...ClientOption) *Client {
 44 | 	client := &Client{
 45 | 		httpClient: &http.Client{
 46 | 			Timeout: 60 * time.Second, // Increased timeout to 60 seconds
 47 | 		},
 48 | 		baseURL: BaseURL,
 49 | 	}
 50 | 
 51 | 	for _, opt := range opts {
 52 | 		opt(client)
 53 | 	}
 54 | 
 55 | 	return client
 56 | }
 57 | 
 58 | // ClientOption is a function that configures a Client
 59 | type ClientOption func(*Client)
 60 | 
 61 | // WithHTTPClient sets the HTTP client to use
 62 | func WithHTTPClient(httpClient *http.Client) ClientOption {
 63 | 	return func(c *Client) {
 64 | 		c.httpClient = httpClient
 65 | 	}
 66 | }
 67 | 
 68 | // WithBaseURL sets the base URL to use
 69 | func WithBaseURL(baseURL string) ClientOption {
 70 | 	return func(c *Client) {
 71 | 		c.baseURL = baseURL
 72 | 	}
 73 | }
 74 | 
 75 | // Package represents a package in the OSV API
 76 | type Package struct {
 77 | 	Name      string `json:"name,omitempty"`
 78 | 	Ecosystem string `json:"ecosystem,omitempty"`
 79 | 	PURL      string `json:"purl,omitempty"`
 80 | }
 81 | 
 82 | // QueryRequest represents a request to the OSV API query endpoint
 83 | type QueryRequest struct {
 84 | 	Commit    string  `json:"commit,omitempty"`
 85 | 	Version   string  `json:"version,omitempty"`
 86 | 	Package   Package `json:"package,omitempty"`
 87 | 	PageToken string  `json:"page_token,omitempty"`
 88 | }
 89 | 
 90 | // QueryBatchRequest represents a request to the OSV API batch query endpoint
 91 | type QueryBatchRequest struct {
 92 | 	Queries []QueryRequest `json:"queries"`
 93 | }
 94 | 
 95 | // Reference represents a reference in a vulnerability
 96 | type Reference struct {
 97 | 	Type string `json:"type"`
 98 | 	URL  string `json:"url"`
 99 | }
100 | 
101 | // Event represents an event in a vulnerability's timeline
102 | type Event struct {
103 | 	Introduced string `json:"introduced,omitempty"`
104 | 	Fixed      string `json:"fixed,omitempty"`
105 | 	Limit      string `json:"limit,omitempty"`
106 | }
107 | 
108 | // Range represents a range of versions affected by a vulnerability
109 | type Range struct {
110 | 	Type   string  `json:"type"`
111 | 	Repo   string  `json:"repo,omitempty"`
112 | 	Events []Event `json:"events"`
113 | }
114 | 
115 | // Affected represents a package affected by a vulnerability
116 | type Affected struct {
117 | 	Package           Package                `json:"package"`
118 | 	Ranges            []Range                `json:"ranges,omitempty"`
119 | 	Versions          []string               `json:"versions,omitempty"`
120 | 	EcosystemSpecific map[string]interface{} `json:"ecosystem_specific,omitempty"`
121 | 	DatabaseSpecific  map[string]interface{} `json:"database_specific,omitempty"`
122 | }
123 | 
124 | // Vulnerability represents a vulnerability in the OSV API
125 | type Vulnerability struct {
126 | 	ID            string      `json:"id"`
127 | 	Summary       string      `json:"summary,omitempty"`
128 | 	Details       string      `json:"details,omitempty"`
129 | 	Modified      time.Time   `json:"modified"`
130 | 	Published     time.Time   `json:"published,omitempty"`
131 | 	References    []Reference `json:"references,omitempty"`
132 | 	Affected      []Affected  `json:"affected,omitempty"`
133 | 	SchemaVersion string      `json:"schema_version,omitempty"`
134 | }
135 | 
136 | // QueryResponse represents a response from the OSV API query endpoint
137 | type QueryResponse struct {
138 | 	Vulns         []Vulnerability `json:"vulns"`
139 | 	NextPageToken string          `json:"next_page_token,omitempty"`
140 | }
141 | 
142 | // BatchQueryResult represents a single result in a batch query response
143 | type BatchQueryResult struct {
144 | 	Vulns []struct {
145 | 		ID       string    `json:"id"`
146 | 		Modified time.Time `json:"modified"`
147 | 	} `json:"vulns"`
148 | 	NextPageToken string `json:"next_page_token,omitempty"`
149 | }
150 | 
151 | // QueryBatchResponse represents a response from the OSV API batch query endpoint
152 | type QueryBatchResponse struct {
153 | 	Results []BatchQueryResult `json:"results"`
154 | }
155 | 
156 | // Query queries the OSV API for vulnerabilities matching the given request
157 | func (c *Client) Query(ctx context.Context, req QueryRequest) (*QueryResponse, error) {
158 | 	_ = ctx
159 | 	url := fmt.Sprintf("%s%s", c.baseURL, QueryEndpoint)
160 | 
161 | 	reqBody, err := json.Marshal(req)
162 | 	if err != nil {
163 | 		return nil, fmt.Errorf("failed to marshal request: %w", err)
164 | 	}
165 | 
166 | 	// Create a new context with a 30-second timeout
167 | 	reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
168 | 	defer cancel()
169 | 
170 | 	httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(reqBody))
171 | 	if err != nil {
172 | 		return nil, fmt.Errorf("failed to create request: %w", err)
173 | 	}
174 | 
175 | 	httpReq.Header.Set("Content-Type", "application/json")
176 | 
177 | 	resp, err := c.httpClient.Do(httpReq)
178 | 	if err != nil {
179 | 		return nil, fmt.Errorf("failed to send request: %w", err)
180 | 	}
181 | 	defer func() {
182 | 		if err := resp.Body.Close(); err != nil {
183 | 			// Log the error or handle it as appropriate for your application
184 | 			fmt.Printf("Error closing response body: %v\n", err)
185 | 		}
186 | 	}()
187 | 
188 | 	if resp.StatusCode != http.StatusOK {
189 | 		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
190 | 	}
191 | 
192 | 	var queryResp QueryResponse
193 | 	if err := json.NewDecoder(resp.Body).Decode(&queryResp); err != nil {
194 | 		return nil, fmt.Errorf("failed to decode response: %w", err)
195 | 	}
196 | 
197 | 	return &queryResp, nil
198 | }
199 | 
200 | // QueryBatch queries the OSV API for vulnerabilities matching the given batch request
201 | func (c *Client) QueryBatch(ctx context.Context, req QueryBatchRequest) (*QueryBatchResponse, error) {
202 | 	_ = ctx
203 | 	url := fmt.Sprintf("%s%s", c.baseURL, QueryBatchEndpoint)
204 | 
205 | 	reqBody, err := json.Marshal(req)
206 | 	if err != nil {
207 | 		return nil, fmt.Errorf("failed to marshal request: %w", err)
208 | 	}
209 | 
210 | 	// Create a new context with a 30-second timeout
211 | 	reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
212 | 	defer cancel()
213 | 
214 | 	httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(reqBody))
215 | 	if err != nil {
216 | 		return nil, fmt.Errorf("failed to create request: %w", err)
217 | 	}
218 | 
219 | 	httpReq.Header.Set("Content-Type", "application/json")
220 | 
221 | 	resp, err := c.httpClient.Do(httpReq)
222 | 	if err != nil {
223 | 		return nil, fmt.Errorf("failed to send request: %w", err)
224 | 	}
225 | 	defer func() {
226 | 		if err := resp.Body.Close(); err != nil {
227 | 			// Log the error or handle it as appropriate for your application
228 | 			fmt.Printf("Error closing response body: %v\n", err)
229 | 		}
230 | 	}()
231 | 
232 | 	if resp.StatusCode != http.StatusOK {
233 | 		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
234 | 	}
235 | 
236 | 	var batchResp QueryBatchResponse
237 | 	if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
238 | 		return nil, fmt.Errorf("failed to decode response: %w", err)
239 | 	}
240 | 
241 | 	return &batchResp, nil
242 | }
243 | 
244 | // GetVulnerability gets a vulnerability by ID
245 | func (c *Client) GetVulnerability(ctx context.Context, id string) (*Vulnerability, error) {
246 | 	_ = ctx
247 | 	url := fmt.Sprintf("%s%s/%s", c.baseURL, VulnEndpoint, id)
248 | 
249 | 	// Create a new context with a 30-second timeout
250 | 	reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
251 | 	defer cancel()
252 | 
253 | 	httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
254 | 	if err != nil {
255 | 		return nil, fmt.Errorf("failed to create request: %w", err)
256 | 	}
257 | 
258 | 	resp, err := c.httpClient.Do(httpReq)
259 | 	if err != nil {
260 | 		return nil, fmt.Errorf("failed to send request: %w", err)
261 | 	}
262 | 	defer func() {
263 | 		if err := resp.Body.Close(); err != nil {
264 | 			// Log the error or handle it as appropriate for your application
265 | 			fmt.Printf("Error closing response body: %v\n", err)
266 | 		}
267 | 	}()
268 | 
269 | 	if resp.StatusCode != http.StatusOK {
270 | 		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
271 | 	}
272 | 
273 | 	var vuln Vulnerability
274 | 	if err := json.NewDecoder(resp.Body).Decode(&vuln); err != nil {
275 | 		return nil, fmt.Errorf("failed to decode response: %w", err)
276 | 	}
277 | 
278 | 	return &vuln, nil
279 | }
280 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/server.go:
--------------------------------------------------------------------------------

```go
  1 | // Package mcp provides MCP server tools for OSV.
  2 | package mcp
  3 | 
  4 | import (
  5 | 	"context"
  6 | 	"encoding/json"
  7 | 	"fmt"
  8 | 	"log"
  9 | 	"time"
 10 | 
 11 | 	"github.com/mark3labs/mcp-go/mcp"
 12 | 	"github.com/mark3labs/mcp-go/server"
 13 | 
 14 | 	"github.com/StacklokLabs/osv-mcp/pkg/osv"
 15 | )
 16 | 
 17 | const (
 18 | 	// ServerName is the name of the MCP server
 19 | 	ServerName = "osv-mcp"
 20 | 
 21 | 	// ServerVersion is the version of the MCP server
 22 | 	ServerVersion = "0.1.0"
 23 | 	// ServerPort is the port the MCP server will listen on
 24 | 	ServerPort = "8080"
 25 | )
 26 | 
 27 | // Server is an MCP server that provides OSV vulnerability information
 28 | type Server struct {
 29 | 	mcpServer *server.MCPServer
 30 | 	osvClient osv.OSVClient
 31 | }
 32 | 
 33 | // NewServer creates a new OSV MCP server
 34 | func NewServer(opts ...ServerOption) *Server {
 35 | 	s := &Server{
 36 | 		osvClient: osv.NewClient(),
 37 | 	}
 38 | 
 39 | 	for _, opt := range opts {
 40 | 		opt(s)
 41 | 	}
 42 | 
 43 | 	mcpServer := server.NewMCPServer(ServerName, ServerVersion)
 44 | 
 45 | 	// Register tools
 46 | 	mcpServer.AddTool(
 47 | 		mcp.NewToolWithRawSchema(
 48 | 			"query_vulnerability",
 49 | 			"Query for vulnerabilities affecting a specific package version or commit",
 50 | 			json.RawMessage(`{
 51 | 				"type": "object",
 52 | 				"properties": {
 53 | 					"commit": {
 54 | 						"type": "string",
 55 | 						"description": "The commit hash to query for. If specified, version should not be set."
 56 | 					},
 57 | 					"version": {
 58 | 						"type": "string",
 59 | 						"description": "The version string to query for. If specified, commit should not be set."
 60 | 					},
 61 | 					"package_name": {
 62 | 						"type": "string",
 63 | 						"description": "The name of the package."
 64 | 					},
 65 | 					"ecosystem": {
 66 | 						"type": "string",
 67 | 						"description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
 68 | 					},
 69 | 					"purl": {
 70 | 						"type": "string",
 71 | 						"description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
 72 | 					}
 73 | 				},
 74 | 				"required": []
 75 | 			}`),
 76 | 		),
 77 | 		s.handleQueryVulnerability,
 78 | 	)
 79 | 
 80 | 	mcpServer.AddTool(
 81 | 		mcp.NewToolWithRawSchema(
 82 | 			"query_vulnerabilities_batch",
 83 | 			"Query for vulnerabilities affecting multiple packages or commits at once",
 84 | 			json.RawMessage(`{
 85 | 				"type": "object",
 86 | 				"properties": {
 87 | 					"queries": {
 88 | 						"type": "array",
 89 | 						"description": "Array of query objects",
 90 | 						"items": {
 91 | 							"type": "object",
 92 | 							"properties": {
 93 | 								"commit": {
 94 | 									"type": "string",
 95 | 									"description": "The commit hash to query for. If specified, version should not be set."
 96 | 								},
 97 | 								"version": {
 98 | 									"type": "string",
 99 | 									"description": "The version string to query for. If specified, commit should not be set."
100 | 								},
101 | 								"package_name": {
102 | 									"type": "string",
103 | 									"description": "The name of the package."
104 | 								},
105 | 								"ecosystem": {
106 | 									"type": "string",
107 | 									"description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
108 | 								},
109 | 								"purl": {
110 | 									"type": "string",
111 | 									"description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
112 | 								}
113 | 							}
114 | 						}
115 | 					}
116 | 				},
117 | 				"required": ["queries"]
118 | 			}`),
119 | 		),
120 | 		s.handleQueryVulnerabilitiesBatch,
121 | 	)
122 | 
123 | 	mcpServer.AddTool(
124 | 		mcp.NewToolWithRawSchema(
125 | 			"get_vulnerability",
126 | 			"Get details for a specific vulnerability by ID",
127 | 			json.RawMessage(`{
128 | 				"type": "object",
129 | 				"properties": {
130 | 					"id": {
131 | 						"type": "string",
132 | 						"description": "The OSV vulnerability ID"
133 | 					}
134 | 				},
135 | 				"required": ["id"]
136 | 			}`),
137 | 		),
138 | 		s.handleGetVulnerability,
139 | 	)
140 | 
141 | 	s.mcpServer = mcpServer
142 | 	return s
143 | }
144 | 
145 | // ServerOption is a function that configures a Server
146 | type ServerOption func(*Server)
147 | 
148 | // WithOSVClient sets the OSV client to use
149 | func WithOSVClient(client osv.OSVClient) ServerOption {
150 | 	return func(s *Server) {
151 | 		s.osvClient = client
152 | 	}
153 | }
154 | 
155 | // ServeSSE starts the MCP server using SSE
156 | func (s *Server) ServeSSE(addr string) error {
157 | 	log.Printf("Starting OSV MCP server (SSE) on %s", addr)
158 | 	sseServer := server.NewSSEServer(s.mcpServer)
159 | 	return sseServer.Start(addr)
160 | }
161 | 
162 | // ServeHTTPStream starts the MCP server using Streamable HTTP transport
163 | func (s *Server) ServeHTTPStream(addr string) error {
164 | 	log.Printf("Starting OSV MCP server (Streamable HTTP) on %s", addr)
165 | 
166 | 	httpSrv := server.NewStreamableHTTPServer(s.mcpServer,
167 | 		server.WithEndpointPath("/mcp/"),
168 | 		server.WithStateLess(true), // stateless mode
169 | 		server.WithHeartbeatInterval(30*time.Second),
170 | 	)
171 | 
172 | 	return httpSrv.Start(addr)
173 | }
174 | 
175 | // handleQueryVulnerability handles the query_vulnerability tool
176 | func (s *Server) handleQueryVulnerability(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
177 | 	commit := mcp.ParseString(request, "commit", "")
178 | 	version := mcp.ParseString(request, "version", "")
179 | 	packageName := mcp.ParseString(request, "package_name", "")
180 | 	ecosystem := mcp.ParseString(request, "ecosystem", "")
181 | 	purl := mcp.ParseString(request, "purl", "")
182 | 
183 | 	// Validate input
184 | 	if commit != "" && version != "" {
185 | 		return mcp.NewToolResultError("Both commit and version cannot be specified"), nil
186 | 	}
187 | 
188 | 	if purl != "" && (packageName != "" || ecosystem != "") {
189 | 		return mcp.NewToolResultError("If purl is specified, package_name and ecosystem should not be specified"), nil
190 | 	}
191 | 
192 | 	if purl == "" && (packageName == "" || ecosystem == "") && commit == "" {
193 | 		return mcp.NewToolResultError("Either purl, or both package_name and ecosystem, or commit must be specified"), nil
194 | 	}
195 | 
196 | 	// Create query request
197 | 	queryReq := osv.QueryRequest{
198 | 		Commit:  commit,
199 | 		Version: version,
200 | 	}
201 | 
202 | 	if purl != "" {
203 | 		queryReq.Package = osv.Package{
204 | 			PURL: purl,
205 | 		}
206 | 	} else if packageName != "" && ecosystem != "" {
207 | 		queryReq.Package = osv.Package{
208 | 			Name:      packageName,
209 | 			Ecosystem: ecosystem,
210 | 		}
211 | 	}
212 | 
213 | 	// Query OSV API
214 | 	resp, err := s.osvClient.Query(ctx, queryReq)
215 | 	if err != nil {
216 | 		return mcp.NewToolResultErrorFromErr("Failed to query OSV API", err), nil
217 | 	}
218 | 
219 | 	// Format response
220 | 	result, err := json.MarshalIndent(resp, "", "  ")
221 | 	if err != nil {
222 | 		return mcp.NewToolResultErrorFromErr("Failed to marshal response", err), nil
223 | 	}
224 | 
225 | 	return mcp.NewToolResultText(string(result)), nil
226 | }
227 | 
228 | // handleQueryVulnerabilitiesBatch handles the query_vulnerabilities_batch tool
229 | func (s *Server) handleQueryVulnerabilitiesBatch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
230 | 	args, ok := request.Params.Arguments.(map[string]interface{})
231 | 	if !ok {
232 | 		return mcp.NewToolResultError("Invalid arguments format"), nil
233 | 	}
234 | 
235 | 	queriesRaw, ok := args["queries"].([]interface{})
236 | 	if !ok {
237 | 		return mcp.NewToolResultError("Invalid 'queries' parameter: must be array"), nil
238 | 	}
239 | 
240 | 	// Convert queries to QueryRequest objects
241 | 	var queries []osv.QueryRequest
242 | 	for i, queryRaw := range queriesRaw {
243 | 		queryMap, ok := queryRaw.(map[string]interface{})
244 | 		if !ok {
245 | 			return mcp.NewToolResultError(fmt.Sprintf("Invalid query at index %d", i)), nil
246 | 		}
247 | 
248 | 		commit, _ := queryMap["commit"].(string)
249 | 		version, _ := queryMap["version"].(string)
250 | 		packageName, _ := queryMap["package_name"].(string)
251 | 		ecosystem, _ := queryMap["ecosystem"].(string)
252 | 		purl, _ := queryMap["purl"].(string)
253 | 
254 | 		// Validate input
255 | 		if commit != "" && version != "" {
256 | 			return mcp.NewToolResultError(fmt.Sprintf("Both commit and version cannot be specified in query %d", i)), nil
257 | 		}
258 | 
259 | 		if purl != "" && (packageName != "" || ecosystem != "") {
260 | 			return mcp.NewToolResultError(
261 | 				fmt.Sprintf("If purl is specified, package_name and ecosystem should not be specified in query %d", i),
262 | 			), nil
263 | 		}
264 | 
265 | 		// Create query request
266 | 		queryReq := osv.QueryRequest{
267 | 			Commit:  commit,
268 | 			Version: version,
269 | 		}
270 | 
271 | 		if purl != "" {
272 | 			queryReq.Package = osv.Package{
273 | 				PURL: purl,
274 | 			}
275 | 		} else if packageName != "" && ecosystem != "" {
276 | 			queryReq.Package = osv.Package{
277 | 				Name:      packageName,
278 | 				Ecosystem: ecosystem,
279 | 			}
280 | 		}
281 | 
282 | 		queries = append(queries, queryReq)
283 | 	}
284 | 
285 | 	// Query OSV API
286 | 	resp, err := s.osvClient.QueryBatch(ctx, osv.QueryBatchRequest{Queries: queries})
287 | 	if err != nil {
288 | 		return mcp.NewToolResultErrorFromErr("Failed to query OSV API", err), nil
289 | 	}
290 | 
291 | 	// Format response
292 | 	result, err := json.MarshalIndent(resp, "", "  ")
293 | 	if err != nil {
294 | 		return mcp.NewToolResultErrorFromErr("Failed to marshal response", err), nil
295 | 	}
296 | 
297 | 	return mcp.NewToolResultText(string(result)), nil
298 | }
299 | 
300 | // handleGetVulnerability handles the get_vulnerability tool
301 | func (s *Server) handleGetVulnerability(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
302 | 	id := mcp.ParseString(request, "id", "")
303 | 	if id == "" {
304 | 		return mcp.NewToolResultError("Vulnerability ID is required"), nil
305 | 	}
306 | 
307 | 	// Get vulnerability from OSV API
308 | 	vuln, err := s.osvClient.GetVulnerability(ctx, id)
309 | 	if err != nil {
310 | 		return mcp.NewToolResultErrorFromErr("Failed to get vulnerability", err), nil
311 | 	}
312 | 
313 | 	// Format response
314 | 	result, err := json.MarshalIndent(vuln, "", "  ")
315 | 	if err != nil {
316 | 		return mcp.NewToolResultErrorFromErr("Failed to marshal response", err), nil
317 | 	}
318 | 
319 | 	return mcp.NewToolResultText(string(result)), nil
320 | }
321 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/server_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"testing"
  7 | 	"time"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/stretchr/testify/assert"
 11 | 	"github.com/stretchr/testify/require"
 12 | 
 13 | 	"github.com/StacklokLabs/osv-mcp/pkg/osv"
 14 | )
 15 | 
 16 | // mockOSVClient is a mock implementation of the OSV client for testing
 17 | type mockOSVClient struct {
 18 | 	queryFunc            func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error)
 19 | 	queryBatchFunc       func(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error)
 20 | 	getVulnerabilityFunc func(ctx context.Context, id string) (*osv.Vulnerability, error)
 21 | }
 22 | 
 23 | func (m *mockOSVClient) Query(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
 24 | 	return m.queryFunc(ctx, req)
 25 | }
 26 | 
 27 | func (m *mockOSVClient) QueryBatch(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error) {
 28 | 	return m.queryBatchFunc(ctx, req)
 29 | }
 30 | 
 31 | func (m *mockOSVClient) GetVulnerability(ctx context.Context, id string) (*osv.Vulnerability, error) {
 32 | 	return m.getVulnerabilityFunc(ctx, id)
 33 | }
 34 | 
 35 | // newMockOSVClient creates a new mock OSV client with default implementations
 36 | func newMockOSVClient() *mockOSVClient {
 37 | 	return &mockOSVClient{
 38 | 		queryFunc: func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
 39 | 			_, _ = ctx, req
 40 | 			return &osv.QueryResponse{
 41 | 				Vulns: []osv.Vulnerability{
 42 | 					{
 43 | 						ID:       "TEST-2023-001",
 44 | 						Summary:  "Test vulnerability",
 45 | 						Modified: time.Now(),
 46 | 					},
 47 | 				},
 48 | 			}, nil
 49 | 		},
 50 | 		queryBatchFunc: func(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error) {
 51 | 			_, _ = ctx, req
 52 | 			return &osv.QueryBatchResponse{
 53 | 				Results: []osv.BatchQueryResult{
 54 | 					{
 55 | 						Vulns: []struct {
 56 | 							ID       string    `json:"id"`
 57 | 							Modified time.Time `json:"modified"`
 58 | 						}{
 59 | 							{
 60 | 								ID:       "TEST-2023-001",
 61 | 								Modified: time.Now(),
 62 | 							},
 63 | 						},
 64 | 					},
 65 | 				},
 66 | 			}, nil
 67 | 		},
 68 | 		getVulnerabilityFunc: func(ctx context.Context, id string) (*osv.Vulnerability, error) {
 69 | 			_, _ = ctx, id
 70 | 			return &osv.Vulnerability{
 71 | 				ID:       "TEST-2023-001",
 72 | 				Summary:  "Test vulnerability",
 73 | 				Modified: time.Now(),
 74 | 			}, nil
 75 | 		},
 76 | 	}
 77 | }
 78 | 
 79 | // getTextContent extracts the text content from a CallToolResult
 80 | func getTextContent(result *mcp.CallToolResult) string {
 81 | 	if len(result.Content) == 0 {
 82 | 		return ""
 83 | 	}
 84 | 
 85 | 	textContent, ok := mcp.AsTextContent(result.Content[0])
 86 | 	if !ok {
 87 | 		return ""
 88 | 	}
 89 | 
 90 | 	return textContent.Text
 91 | }
 92 | 
 93 | func TestHandleQueryVulnerability(t *testing.T) {
 94 | 	// Create mock OSV client
 95 | 	mockClient := newMockOSVClient()
 96 | 
 97 | 	// Set up expected query parameters
 98 | 	expectedPackageName := "test-package"
 99 | 	expectedEcosystem := "npm"
100 | 	expectedVersion := "1.0.0"
101 | 
102 | 	// Override query function to check parameters
103 | 	mockClient.queryFunc = func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
104 | 		_, _ = ctx, req
105 | 		assert.Equal(t, expectedPackageName, req.Package.Name)
106 | 		assert.Equal(t, expectedEcosystem, req.Package.Ecosystem)
107 | 		assert.Equal(t, expectedVersion, req.Version)
108 | 
109 | 		return &osv.QueryResponse{
110 | 			Vulns: []osv.Vulnerability{
111 | 				{
112 | 					ID:       "TEST-2023-001",
113 | 					Summary:  "Test vulnerability",
114 | 					Modified: time.Now(),
115 | 				},
116 | 			},
117 | 		}, nil
118 | 	}
119 | 
120 | 	// Create server with mock client
121 | 	server := NewServer(WithOSVClient(mockClient))
122 | 
123 | 	// Create tool request
124 | 	request := mcp.CallToolRequest{}
125 | 	request.Params.Arguments = map[string]interface{}{
126 | 		"package_name": expectedPackageName,
127 | 		"ecosystem":    expectedEcosystem,
128 | 		"version":      expectedVersion,
129 | 	}
130 | 
131 | 	// Call handler
132 | 	result, err := server.handleQueryVulnerability(context.Background(), request)
133 | 
134 | 	// Check result
135 | 	require.NoError(t, err)
136 | 	require.NotNil(t, result)
137 | 	assert.False(t, result.IsError)
138 | 
139 | 	// Get text content
140 | 	text := getTextContent(result)
141 | 	assert.NotEmpty(t, text)
142 | 
143 | 	// Parse result text as JSON
144 | 	var response osv.QueryResponse
145 | 	err = json.Unmarshal([]byte(text), &response)
146 | 	require.NoError(t, err)
147 | 
148 | 	// Check response
149 | 	assert.Len(t, response.Vulns, 1)
150 | 	assert.Equal(t, "TEST-2023-001", response.Vulns[0].ID)
151 | 	assert.Equal(t, "Test vulnerability", response.Vulns[0].Summary)
152 | }
153 | 
154 | func TestHandleQueryVulnerabilityWithPURL(t *testing.T) {
155 | 	// Create mock OSV client
156 | 	mockClient := newMockOSVClient()
157 | 
158 | 	// Set up expected query parameters
159 | 	expectedPURL := "pkg:npm/[email protected]"
160 | 
161 | 	// Override query function to check parameters
162 | 	mockClient.queryFunc = func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
163 | 		_, _ = ctx, req
164 | 		assert.Equal(t, expectedPURL, req.Package.PURL)
165 | 		assert.Empty(t, req.Package.Name)
166 | 		assert.Empty(t, req.Package.Ecosystem)
167 | 
168 | 		return &osv.QueryResponse{
169 | 			Vulns: []osv.Vulnerability{
170 | 				{
171 | 					ID:       "TEST-2023-001",
172 | 					Summary:  "Test vulnerability",
173 | 					Modified: time.Now(),
174 | 				},
175 | 			},
176 | 		}, nil
177 | 	}
178 | 
179 | 	// Create server with mock client
180 | 	server := NewServer(WithOSVClient(mockClient))
181 | 
182 | 	// Create tool request
183 | 	request := mcp.CallToolRequest{}
184 | 	request.Params.Arguments = map[string]interface{}{
185 | 		"purl": expectedPURL,
186 | 	}
187 | 
188 | 	// Call handler
189 | 	result, err := server.handleQueryVulnerability(context.Background(), request)
190 | 
191 | 	// Check result
192 | 	require.NoError(t, err)
193 | 	require.NotNil(t, result)
194 | 	assert.False(t, result.IsError)
195 | 	assert.NotEmpty(t, getTextContent(result))
196 | }
197 | 
198 | func TestHandleQueryVulnerabilityWithCommit(t *testing.T) {
199 | 	// Create mock OSV client
200 | 	mockClient := newMockOSVClient()
201 | 
202 | 	// Set up expected query parameters
203 | 	expectedCommit := "abcdef1234567890"
204 | 
205 | 	// Override query function to check parameters
206 | 	mockClient.queryFunc = func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
207 | 		_, _ = ctx, req
208 | 		assert.Equal(t, expectedCommit, req.Commit)
209 | 		assert.Empty(t, req.Version)
210 | 
211 | 		return &osv.QueryResponse{
212 | 			Vulns: []osv.Vulnerability{
213 | 				{
214 | 					ID:       "TEST-2023-001",
215 | 					Summary:  "Test vulnerability",
216 | 					Modified: time.Now(),
217 | 				},
218 | 			},
219 | 		}, nil
220 | 	}
221 | 
222 | 	// Create server with mock client
223 | 	server := NewServer(WithOSVClient(mockClient))
224 | 
225 | 	// Create tool request
226 | 	request := mcp.CallToolRequest{}
227 | 	request.Params.Arguments = map[string]interface{}{
228 | 		"commit": expectedCommit,
229 | 	}
230 | 
231 | 	// Call handler
232 | 	result, err := server.handleQueryVulnerability(context.Background(), request)
233 | 
234 | 	// Check result
235 | 	require.NoError(t, err)
236 | 	require.NotNil(t, result)
237 | 	assert.False(t, result.IsError)
238 | 	assert.NotEmpty(t, getTextContent(result))
239 | }
240 | 
241 | func TestHandleQueryVulnerabilitiesBatch(t *testing.T) {
242 | 	// Create mock OSV client
243 | 	mockClient := newMockOSVClient()
244 | 
245 | 	// Override query batch function to check parameters
246 | 	mockClient.queryBatchFunc = func(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error) {
247 | 		_, _ = ctx, req
248 | 		assert.Len(t, req.Queries, 2)
249 | 		assert.Equal(t, "test-package-1", req.Queries[0].Package.Name)
250 | 		assert.Equal(t, "npm", req.Queries[0].Package.Ecosystem)
251 | 		assert.Equal(t, "1.0.0", req.Queries[0].Version)
252 | 		assert.Equal(t, "test-package-2", req.Queries[1].Package.Name)
253 | 		assert.Equal(t, "npm", req.Queries[1].Package.Ecosystem)
254 | 		assert.Equal(t, "2.0.0", req.Queries[1].Version)
255 | 
256 | 		return &osv.QueryBatchResponse{
257 | 			Results: []osv.BatchQueryResult{
258 | 				{
259 | 					Vulns: []struct {
260 | 						ID       string    `json:"id"`
261 | 						Modified time.Time `json:"modified"`
262 | 					}{
263 | 						{
264 | 							ID:       "TEST-2023-001",
265 | 							Modified: time.Now(),
266 | 						},
267 | 					},
268 | 				},
269 | 				{
270 | 					Vulns: []struct {
271 | 						ID       string    `json:"id"`
272 | 						Modified time.Time `json:"modified"`
273 | 					}{
274 | 						{
275 | 							ID:       "TEST-2023-002",
276 | 							Modified: time.Now(),
277 | 						},
278 | 					},
279 | 				},
280 | 			},
281 | 		}, nil
282 | 	}
283 | 
284 | 	// Create server with mock client
285 | 	server := NewServer(WithOSVClient(mockClient))
286 | 
287 | 	// Create tool request
288 | 	request := mcp.CallToolRequest{}
289 | 	request.Params.Arguments = map[string]interface{}{
290 | 		"queries": []interface{}{
291 | 			map[string]interface{}{
292 | 				"package_name": "test-package-1",
293 | 				"ecosystem":    "npm",
294 | 				"version":      "1.0.0",
295 | 			},
296 | 			map[string]interface{}{
297 | 				"package_name": "test-package-2",
298 | 				"ecosystem":    "npm",
299 | 				"version":      "2.0.0",
300 | 			},
301 | 		},
302 | 	}
303 | 
304 | 	// Call handler
305 | 	result, err := server.handleQueryVulnerabilitiesBatch(context.Background(), request)
306 | 
307 | 	// Check result
308 | 	require.NoError(t, err)
309 | 	require.NotNil(t, result)
310 | 	assert.False(t, result.IsError)
311 | 
312 | 	// Get text content
313 | 	text := getTextContent(result)
314 | 	assert.NotEmpty(t, text)
315 | 
316 | 	// Parse result text as JSON
317 | 	var response osv.QueryBatchResponse
318 | 	err = json.Unmarshal([]byte(text), &response)
319 | 	require.NoError(t, err)
320 | 
321 | 	// Check response
322 | 	assert.Len(t, response.Results, 2)
323 | 	assert.Len(t, response.Results[0].Vulns, 1)
324 | 	assert.Equal(t, "TEST-2023-001", response.Results[0].Vulns[0].ID)
325 | 	assert.Len(t, response.Results[1].Vulns, 1)
326 | 	assert.Equal(t, "TEST-2023-002", response.Results[1].Vulns[0].ID)
327 | }
328 | 
329 | func TestHandleGetVulnerability(t *testing.T) {
330 | 	// Create mock OSV client
331 | 	mockClient := newMockOSVClient()
332 | 
333 | 	// Set up expected query parameters
334 | 	expectedID := "TEST-2023-001"
335 | 
336 | 	// Override get vulnerability function to check parameters
337 | 	mockClient.getVulnerabilityFunc = func(ctx context.Context, id string) (*osv.Vulnerability, error) {
338 | 		_ = ctx
339 | 		assert.Equal(t, expectedID, id)
340 | 
341 | 		return &osv.Vulnerability{
342 | 			ID:       expectedID,
343 | 			Summary:  "Test vulnerability",
344 | 			Details:  "This is a test vulnerability",
345 | 			Modified: time.Now(),
346 | 		}, nil
347 | 	}
348 | 
349 | 	// Create server with mock client
350 | 	server := NewServer(WithOSVClient(mockClient))
351 | 
352 | 	// Create tool request
353 | 	request := mcp.CallToolRequest{}
354 | 	request.Params.Arguments = map[string]interface{}{
355 | 		"id": expectedID,
356 | 	}
357 | 
358 | 	// Call handler
359 | 	result, err := server.handleGetVulnerability(context.Background(), request)
360 | 
361 | 	// Check result
362 | 	require.NoError(t, err)
363 | 	require.NotNil(t, result)
364 | 	assert.False(t, result.IsError)
365 | 
366 | 	// Get text content
367 | 	text := getTextContent(result)
368 | 	assert.NotEmpty(t, text)
369 | 
370 | 	// Parse result text as JSON
371 | 	var vuln osv.Vulnerability
372 | 	err = json.Unmarshal([]byte(text), &vuln)
373 | 	require.NoError(t, err)
374 | 
375 | 	// Check response
376 | 	assert.Equal(t, expectedID, vuln.ID)
377 | 	assert.Equal(t, "Test vulnerability", vuln.Summary)
378 | 	assert.Equal(t, "This is a test vulnerability", vuln.Details)
379 | }
380 | 
```