# 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 | [](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 |
```