# 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:
--------------------------------------------------------------------------------
```
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Build artifacts
/build/
# IDE files
.idea/
.vscode/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# ko build artifacts
.ko.local/
# task
.task
```
--------------------------------------------------------------------------------
/.ko.yaml:
--------------------------------------------------------------------------------
```yaml
builds:
- id: osv-mcp-server
dir: .
main: ./cmd/server
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X main.version={{.Env.VERSION}}
labels:
org.opencontainers.image.created: "{{.Env.CREATION_TIME}}"
org.opencontainers.image.description: "osv-mcp - An OSV MCP Server"
org.opencontainers.image.licenses: "Apache-2.0"
org.opencontainers.image.revision: "{{.Env.GITHUB_SHA}}"
org.opencontainers.image.source: "{{.Env.GITHUB_SERVER_URL}}/{{.Env.GITHUB_REPOSITORY}}"
org.opencontainers.image.title: "osv-mcp"
org.opencontainers.image.url: "{{.Env.GITHUB_SERVER_URL}}/{{.Env.GITHUB_REPOSITORY}}"
org.opencontainers.image.version: "{{.Env.VERSION}}"
```
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
```yaml
version: "2"
run:
issues-exit-code: 1
output:
formats:
text:
path: stdout
print-linter-name: true
print-issued-lines: true
linters:
default: none
enable:
- depguard
- exhaustive
- goconst
- gocyclo
- gosec
- govet
- ineffassign
- lll
- paralleltest
- promlinter
- revive
- staticcheck
- thelper
- tparallel
- unparam
- unused
settings:
depguard:
rules:
prevent_unmaintained_packages:
list-mode: lax
files:
- $all
- '!$test'
deny:
- pkg: io/ioutil
desc: this is deprecated
gocyclo:
min-complexity: 15
gosec:
excludes:
- G601
lll:
line-length: 130
revive:
severity: warning
rules:
- name: blank-imports
severity: warning
- name: context-as-argument
- name: context-keys-type
- name: duplicated-imports
- name: error-naming
- name: error-return
- name: exported
severity: error
- name: if-return
- name: identical-branches
- name: indent-error-flow
- name: import-shadowing
- name: package-comments
- name: redefines-builtin-id
- name: struct-tag
- name: unconditional-recursion
- name: unnecessary-stmt
- name: unreachable-code
- name: unused-parameter
- name: unused-receiver
- name: unhandled-error
disabled: true
exclusions:
generated: lax
rules:
- linters:
- lll
- gocyclo
- errcheck
- dupl
- gosec
- paralleltest
path: (.+)_test\.go
- linters:
- lll
path: .golangci.yml
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
- gofmt
settings:
gci:
sections:
- standard
- default
- prefix(github.com/StacklokLabs/osv-mcp)
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# OSV MCP Server
[](https://archestra.ai/mcp-catalog/stackloklabs__osv-mcp)
An MCP (Model Context Protocol) server that provides access to the
[OSV (Open Source Vulnerabilities) database](https://osv.dev/).
## Overview
This project implements an SSE-based MCP server that allows LLM-powered
applications to query the OSV database for vulnerability information. The server
provides tools for:
1. Querying vulnerabilities for a specific package version or commit
2. Batch querying vulnerabilities for multiple packages or commits
3. Getting detailed information about a specific vulnerability by ID
## Installation
### Prerequisites
- Go 1.21 or later
- [Task](https://taskfile.dev/) (optional, for running tasks)
- [ko](https://ko.build/) (optional, for building container images)
### Building from source
```bash
# Clone the repository
git clone https://github.com/StacklokLabs/osv-mcp.git
cd osv-mcp
# Build the server
task build
```
## Usage
### Running with ToolHive (Recommended)
The easiest way to run the OSV MCP server is using
[ToolHive](https://github.com/stacklok/toolhive), which provides secure,
containerized deployment of MCP servers:
```bash
# Install ToolHive (if not already installed)
# See: https://docs.stacklok.com/toolhive/guides-cli/install
# Register a supported client so ToolHive can auto-configure your environment
thv client setup
# Run the OSV MCP server (packaged as 'osv' in ToolHive)
thv run osv
# List running servers
thv list
# Get detailed information about the server
thv registry info osv
```
The server will be available to your MCP-compatible clients and can query the
OSV database for vulnerability information.
### Running from Source
### Server Configuration
The server can be configured using environment variables:
- `MCP_PORT`: The port number to run the server on (default: 8080)
- Must be a valid integer between 0 and 65535
- If invalid or not set, the server will use port 8080
- `MCP_TRANSPORT`: The transport mode for the server (default: `sse`)
- Supported values: `sse`, `streamable-http`
- If invalid or not set, the server will use SSE transport mode
Example:
```bash
# Run on port 3000
MCP_PORT=3000 ./build/osv-mcp-server
# Run on default port 8080
./build/osv-mcp-server
```
### MCP Tools
The server provides the following MCP tools:
#### query_vulnerability
Query for vulnerabilities affecting a specific package version or commit.
**Input Schema:**
```json
{
"type": "object",
"properties": {
"commit": {
"type": "string",
"description": "The commit hash to query for. If specified, version should not be set."
},
"version": {
"type": "string",
"description": "The version string to query for. If specified, commit should not be set."
},
"package_name": {
"type": "string",
"description": "The name of the package."
},
"ecosystem": {
"type": "string",
"description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
},
"purl": {
"type": "string",
"description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
}
}
}
```
#### query_vulnerabilities_batch
Query for vulnerabilities affecting multiple packages or commits at once.
**Input Schema:**
```json
{
"type": "object",
"properties": {
"queries": {
"type": "array",
"description": "Array of query objects",
"items": {
"type": "object",
"properties": {
"commit": {
"type": "string",
"description": "The commit hash to query for. If specified, version should not be set."
},
"version": {
"type": "string",
"description": "The version string to query for. If specified, commit should not be set."
},
"package_name": {
"type": "string",
"description": "The name of the package."
},
"ecosystem": {
"type": "string",
"description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
},
"purl": {
"type": "string",
"description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
}
}
}
}
},
"required": ["queries"]
}
```
#### get_vulnerability
Get details for a specific vulnerability by ID.
**Input Schema:**
```json
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The OSV vulnerability ID"
}
},
"required": ["id"]
}
```
## Examples
### Querying vulnerabilities for a package
```json
{
"package_name": "lodash",
"ecosystem": "npm",
"version": "4.17.15"
}
```
### Querying vulnerabilities for a commit
```json
{
"commit": "6879efc2c1596d11a6a6ad296f80063b558d5e0f"
}
```
### Batch querying vulnerabilities
```json
{
"queries": [
{
"package_name": "lodash",
"ecosystem": "npm",
"version": "4.17.15"
},
{
"package_name": "jinja2",
"ecosystem": "PyPI",
"version": "2.4.1"
}
]
}
```
### Getting vulnerability details
```json
{
"id": "GHSA-vqj2-4v8m-8vrq"
}
```
## Development
### Running tests
```bash
task test
```
### Linting
```bash
task lint
```
### Formatting code
```bash
task fmt
```
## Contributing
We welcome contributions to this MCP server! If you'd like to contribute, please
review the [CONTRIBUTING guide](./CONTRIBUTING.md) for details on how to get
started.
If you run into a bug or have a feature request, please
[open an issue](https://github.com/StacklokLabs/osv-mcp/issues) in the
repository or join us in the `#mcp-servers` channel on our
[community Discord server](https://discord.gg/stacklok).
## License
This project is licensed under the Apache v2 License - see the LICENSE file for
details.
```
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
```markdown
# Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at <[email protected]>. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
# Contributing to `osv-mcp` <!-- omit from toc -->
First off, thank you for taking the time to contribute to osv-mcp! :+1: :tada:
osv-mcp is released under the Apache 2.0 license. If you would like to
contribute something or want to hack on the code, this document should help you
get started. You can find some hints for starting development in osv-mcp's
[README](https://github.com/StacklokLabs/osv-mcp/blob/main/README.md).
## Table of contents <!-- omit from toc -->
- [Code of conduct](#code-of-conduct)
- [Reporting security vulnerabilities](#reporting-security-vulnerabilities)
- [How to contribute](#how-to-contribute)
- [Using GitHub Issues](#using-github-issues)
- [Not sure how to start contributing?](#not-sure-how-to-start-contributing)
- [Pull request process](#pull-request-process)
- [Commit message guidelines](#commit-message-guidelines)
## Code of conduct
This project adheres to the
[Contributor Covenant](https://github.com/StacklokLabs/osv-mcp/blob/main/CODE_OF_CONDUCT.md)
code of conduct. By participating, you are expected to uphold this code. Please
report unacceptable behavior to
[[email protected]](mailto:[email protected]).
## Reporting security vulnerabilities
If you think you have found a security vulnerability in osv-mcp please DO NOT
disclose it publicly until we've had a chance to fix it. Please don't report
security vulnerabilities using GitHub issues; instead, please follow this
[process](https://github.com/StacklokLabs/osv-mcp/blob/main/SECURITY.md)
## How to contribute
### Using GitHub Issues
We use GitHub issues to track bugs and enhancements. If you have a general usage
question, please ask in the #mcp-servers channel of the
[Stacklok Discord server](https://discord.gg/stacklok).
If you are reporting a bug, please help to speed up problem diagnosis by
providing as much information as possible. Ideally, that would include a small
sample project that reproduces the problem.
### Not sure how to start contributing?
PRs to resolve existing issues are greatly appreciated and issues labeled as
["good first issue"](https://github.com/StacklokLabs/osv-mcp/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
are a great place to start!
### Pull request process
-All commits must include a Signed-off-by trailer at the end of each commit
message to indicate that the contributor agrees to the Developer Certificate of
Origin. For additional details, check out the [DCO instructions](dco.md).
- Create an issue outlining the fix or feature.
- Fork the osv-mcp repository to your own GitHub account and clone it locally.
- Hack on your changes.
- Correctly format your commit messages, see
[Commit message guidelines](#commit-message-guidelines) below.
- Open a PR by ensuring the title and its description reflect the content of the
PR.
- Ensure that CI passes, if it fails, fix the failures.
- Every pull request requires a review from the core osv-mcp team before
merging.
- Once approved, all of your commits will be squashed into a single commit with
your PR title.
### Commit message guidelines
We follow the commit formatting recommendations found on
[Chris Beams' How to Write a Git Commit Message article](https://chris.beams.io/posts/git-commit/):
1. Separate subject from body with a blank line
1. Limit the subject line to 50 characters
1. Capitalize the subject line
1. Do not end the subject line with a period
1. Use the imperative mood in the subject line
1. Use the body to explain what and why vs. how
```
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
```markdown
# Security Policy
The StacklokLabs community take security seriously! We appreciate your efforts
to disclose your findings responsibly and will make every effort to acknowledge
your contributions.
## Reporting a vulnerability
To report a security issue, please use the GitHub Security Advisory
["Report a Vulnerability"](https://github.com/StacklokLabs/osv-mcp/security/advisories/new)
tab.
If you are unable to access GitHub you can also email us at
[[email protected]](mailto:[email protected]).
Include steps to reproduce the vulnerability, the vulnerable versions, and any
additional files to reproduce the vulnerability.
If you are only comfortable sharing under GPG, please start by sending an email
requesting a public PGP key to use for encryption.
### Contacting the StacklokLabs security team
Contact the team by sending email to
[[email protected]](mailto:[email protected]).
## Disclosures
### Private disclosure processes
The StacklokLabs community asks that all suspected vulnerabilities be handled in
accordance with
[Responsible Disclosure model](https://en.wikipedia.org/wiki/Responsible_disclosure).
### Public disclosure processes
If anyone knows of a publicly disclosed security vulnerability please
IMMEDIATELY email [[email protected]](mailto:[email protected]) to
inform us about the vulnerability so that we may start the patch, release, and
communication process.
If a reporter contacts the us to express intent to make an issue public before a
fix is available, we will request if the issue can be handled via a private
disclosure process. If the reporter denies the request, we will move swiftly
with the fix and release process.
## Patch, release, and public communication
For each vulnerability, the StacklokLabs security team will coordinate to create
the fix and release, and notify the rest of the community.
All of the timelines below are suggestions and assume a Private Disclosure.
- The security team drives the schedule using their best judgment based on
severity, development time, and release work.
- If the security team is dealing with a Public Disclosure all timelines become
ASAP.
- If the fix relies on another upstream project's disclosure timeline, that will
adjust the process as well.
- We will work with the upstream project to fit their timeline and best protect
StacklokLabs users.
- The Security team will give advance notice to the Private Distributors list
before the fix is released.
### Fix team organization
These steps should be completed within the first 24 hours of Disclosure.
- The security team will work quickly to identify relevant engineers from the
affected projects and packages and being those engineers into the
[security advisory](https://docs.github.com/en/code-security/security-advisories/)
thread.
- These selected developers become the "Fix Team" (the fix team is often drawn
from the projects MAINTAINERS)
### Fix development process
These steps should be completed within the 1-7 days of Disclosure.
- Create a new
[security advisory](https://docs.github.com/en/code-security/security-advisories/)
in affected repository by visiting
`https://github.com/StacklokLabs/osv-mcp/security/advisories/new`
- As many details as possible should be entered such as versions affected, CVE
(if available yet). As more information is discovered, edit and update the
advisory accordingly.
- Use the CVSS calculator to score a severity level.
- Add collaborators from codeowners team only (outside members can only be added
after approval from the security team)
- The reporter may be added to the issue to assist with review, but **only
reporters who have contacted the security team using a private channel**.
- Select 'Request CVE'
- The security team / Fix Team create a private temporary fork
- The Fix team performs all work in a 'security advisory' within its temporary
fork
- CI can be checked locally using the [act](https://github.com/nektos/act)
project
- All communication happens within the security advisory, it is _not_ discussed
in slack channels or non private issues.
- The Fix Team will notify the security team that work on the fix branch is
completed, this can be done by tagging names in the advisory
- The Fix team and the security team will agree on fix release day
- The recommended release time is 4pm UTC on a non-Friday weekday. This means
the announcement will be seen morning Pacific, early evening Europe, and late
evening Asia.
If the CVSS score is under ~4.0
([a low severity score](https://www.first.org/cvss/specification-document#i5))
or the assessed risk is low the Fix Team can decide to slow the release process
down in the face of holidays, developer bandwidth, etc.
Note: CVSS is convenient but imperfect. Ultimately, the security team has
discretion on classifying the severity of a vulnerability.
The severity of the bug and related handling decisions must be discussed on in
the security advisory, never in public repos.
### Fix disclosure process
With the Fix Development underway, the security team needs to come up with an
overall communication plan for the wider community. This Disclosure process
should begin after the Fix Team has developed a Fix or mitigation so that a
realistic timeline can be communicated to users.
**Fix release day** (Completed within 1-21 days of Disclosure)
- The Fix Team will approve the related pull requests in the private temporary
branch of the security advisory
- The security team will merge the security advisory / temporary fork and its
commits into the main branch of the affected repository
- The security team will ensure all the binaries are built, signed, publicly
available, and functional.
- The security team will announce the new releases, the CVE number, severity,
and impact, and the location of the binaries to get wide distribution and user
action. As much as possible this announcement should be actionable, and
include any mitigating steps users can take prior to upgrading to a fixed
version. An announcement template is available below. The announcement will be
sent to the following channels:
- A link to fix will be posted to the
[Stacklok Discord Server](https://discord.gg/stacklok) in the #mcp-servers
channel.
## Retrospective
These steps should be completed 1-3 days after the Release Date. The
retrospective process
[should be blameless](https://landing.google.com/sre/book/chapters/postmortem-culture.html).
- The security team will send a retrospective of the process to the
[Stacklok Discord Server](https://discord.gg/stacklok) including details on
everyone involved, the timeline of the process, links to relevant PRs that
introduced the issue, if relevant, and any critiques of the response and
release process.
```
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
```json
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
```
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
```yaml
version: 2
updates:
# Enable version updates for Go modules
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: latest
build:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Install Task
uses: arduino/setup-task@v2
with:
version: '3.x'
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: task install
- name: Build
run: task build
- name: Test
run: task test
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: osv-mcp-server
path: build/osv-mcp-server
retention-days: 7
```
--------------------------------------------------------------------------------
/.github/workflows/security.yml:
--------------------------------------------------------------------------------
```yaml
name: Security Scan
on:
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 0' # Run weekly on Sundays at midnight
jobs:
trivy-scan:
name: Trivy Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Run Trivy vulnerability scanner in repo mode
uses: aquasecurity/[email protected]
with:
scan-type: 'fs'
ignore-unfixed: true
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: 'trivy-results.sarif'
category: 'trivy-fs'
- name: Run Trivy vulnerability scanner in IaC mode
uses: aquasecurity/[email protected]
with:
scan-type: 'config'
hide-progress: false
format: 'sarif'
output: 'trivy-config-results.sarif'
exit-code: '1'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy IaC scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: 'trivy-config-results.sarif'
category: 'trivy-config'
```
--------------------------------------------------------------------------------
/cmd/server/main_test.go:
--------------------------------------------------------------------------------
```go
package main
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/StacklokLabs/osv-mcp/pkg/mcp"
"github.com/StacklokLabs/osv-mcp/pkg/osv"
)
func TestCreateServer(t *testing.T) {
// Create OSV client
osvClient := osv.NewClient()
require.NotNil(t, osvClient)
// Create MCP server
mcpServer := mcp.NewServer(
mcp.WithOSVClient(osvClient),
)
require.NotNil(t, mcpServer)
// Verify server properties
assert.Equal(t, mcp.ServerName, "osv-mcp")
assert.Equal(t, mcp.ServerVersion, "0.1.0")
}
func TestGetMCPServerPort(t *testing.T) {
// Save original env value and restore it after the test
originalPort := os.Getenv("MCP_PORT")
defer func() {
if originalPort != "" {
os.Setenv("MCP_PORT", originalPort)
} else {
os.Unsetenv("MCP_PORT")
}
}()
tests := []struct {
name string
envPort string
expected string
}{
{
name: "No environment variable set",
envPort: "",
expected: "8080",
},
{
name: "Valid port number",
envPort: "3000",
expected: "3000",
},
{
name: "Invalid port (non-numeric)",
envPort: "abc",
expected: "8080",
},
{
name: "Invalid port (negative number)",
envPort: "-1",
expected: "8080",
},
{
name: "Invalid port (too large)",
envPort: "70000",
expected: "8080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment
if tt.envPort != "" {
os.Setenv("MCP_PORT", tt.envPort)
} else {
os.Unsetenv("MCP_PORT")
}
// Test the function
port := getMCPServerPort()
assert.Equal(t, tt.expected, port)
})
}
}
```
--------------------------------------------------------------------------------
/Taskfile.yml:
--------------------------------------------------------------------------------
```yaml
version: '3'
vars:
BINARY_NAME: osv-mcp-server
BUILD_DIR: build
MAIN_PACKAGE: ./cmd/server
KO_DOCKER_REPO: stackloklabs/osv-mcp
tasks:
default:
desc: Run tests and build the application
deps: [test, build]
build:
desc: Build the application
cmds:
- mkdir -p {{.BUILD_DIR}}
- go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.MAIN_PACKAGE}}
run:
desc: Run the application
deps: [build]
cmds:
- ./{{.BUILD_DIR}}/{{.BINARY_NAME}} {{.CLI_ARGS}}
test:
desc: Run tests
cmds:
- go test -v ./...
clean:
desc: Clean the build directory
cmds:
- rm -rf {{.BUILD_DIR}}
fmt:
desc: Format the code
cmds:
- go fmt ./...
- golangci-lint run --fix
lint:
desc: Lint the code
cmds:
- golangci-lint run
deps:
desc: Update dependencies
cmds:
- go mod tidy
install:
desc: Install dependencies
cmds:
- go mod download
ko-build:
desc: Build container image with ko
env:
KO_DOCKER_REPO: '{{.KO_DOCKER_REPO}}'
cmds:
- ko build --platform=linux/amd64 --base-import-paths {{.MAIN_PACKAGE}} -t latest
ko-run:
desc: Run container built with ko
env:
KO_DOCKER_REPO: '{{.KO_DOCKER_REPO}}'
cmds:
- ko run --platform=linux/amd64 --base-import-paths {{.MAIN_PACKAGE}} -- {{.CLI_ARGS}}
ko-publish:
desc: Publish container image with ko
env:
KO_DOCKER_REPO: '{{.KO_DOCKER_REPO}}'
cmds:
- ko publish --platform=linux/amd64 --base-import-paths {{.MAIN_PACKAGE}} -t latest
all:
desc: Run all tasks (fmt, lint, test, build)
cmds:
- task: fmt
- task: lint
- task: test
- task: build
```
--------------------------------------------------------------------------------
/dco.md:
--------------------------------------------------------------------------------
```markdown
# Developer Certificate of Origin (DCO)
In order to contribute to the project, you must agree to the Developer
Certificate of Origin. A
[Developer Certificate of Origin (DCO)](https://developercertificate.org/) is an
affirmation that the developer contributing the proposed changes has the
necessary rights to submit those changes. A DCO provides some additional legal
protections while being relatively easy to do.
The entire DCO can be summarized as:
- Certify that the submitted code can be submitted under the open source license
of the project (e.g. MIT)
- I understand that what I am contributing is public and will be redistributed
indefinitely
## How to Use Developer Certificate of Origin
In order to contribute to the project, you must agree to the Developer
Certificate of Origin. To confirm that you agree, your commit message must
include a Signed-off-by trailer at the bottom of the commit message.
For example, it might look like the following:
```bash
A commit message
Closes gh-345
Signed-off-by: jane marmot <[email protected]>
```
The Signed-off-by [trailer](https://git-scm.com/docs/git-interpret-trailers) can
be added automatically by using the
[-s or –signoff command line option](https://git-scm.com/docs/git-commit/2.13.7#Documentation/git-commit.txt--s)
when specifying your commit message:
```bash
git commit -s -m
```
If you have chosen the
[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)
option within GitHub, the Signed-off-by trailer might look something like:
```bash
A commit message
Closes gh-345
Signed-off-by: jane marmot <[email protected]>
```
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Release Container
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Install Task
uses: arduino/setup-task@v2
with:
version: '3.x'
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: task install
- name: Test
run: task test
- name: Setup Ko
uses: ko-build/[email protected]
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract tag version
id: tag
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Set repository owner lowercase
id: repo_owner
run: echo "OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Build and push container
env:
KO_DOCKER_REPO: ghcr.io/${{ steps.repo_owner.outputs.OWNER }}/osv-mcp
VERSION: ${{ steps.tag.outputs.VERSION }}
CREATION_TIME: $(date -u +'%Y-%m-%dT%H:%M:%SZ')
run: |
# Build and push the container with reproducible build flags
ko build \
--bare \
--sbom=spdx \
--platform=linux/amd64,linux/arm64 \
--base-import-paths \
--tags $VERSION,latest \
./cmd/server
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Sign Image with Cosign
env:
KO_DOCKER_REPO: ghcr.io/${{ steps.repo_owner.outputs.OWNER }}/osv-mcp
run: |
TAG=$(echo "${{ steps.tag.outputs.VERSION }}" | sed 's/+/_/g')
# Sign the ko image
cosign sign -y $KO_DOCKER_REPO/server:$TAG
# Sign the latest tag if building from a tag
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
cosign sign -y $KO_DOCKER_REPO/server:latest
fi
```
--------------------------------------------------------------------------------
/cmd/server/main.go:
--------------------------------------------------------------------------------
```go
// Package main provides the entry point for the OSV MCP Server.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/StacklokLabs/osv-mcp/pkg/mcp"
"github.com/StacklokLabs/osv-mcp/pkg/osv"
)
// TransportMode defines the type for transport modes used by the MCP server.
type TransportMode string
const (
// TransportSSE represents the Server-Sent Events transport mode.
TransportSSE TransportMode = "sse"
// TransportHTTPStream represents the HTTP streaming transport mode.
TransportHTTPStream TransportMode = "streamable-http"
)
func getTransportMode() (TransportMode, error) {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("MCP_TRANSPORT")))
if mode == "" {
return TransportSSE, nil // default
}
switch TransportMode(mode) {
case TransportSSE, TransportHTTPStream:
return TransportMode(mode), nil
default:
return "", fmt.Errorf("invalid MCP_TRANSPORT: %q (allowed: sse, streamable-http)", mode)
}
}
// getMCPServerPort returns the port number from MCP_PORT environment variable.
// If the environment variable is not set or contains an invalid value,
// it returns the default port 8080.
func getMCPServerPort() string {
port := "8080"
if envPort := os.Getenv("MCP_PORT"); envPort != "" {
if portNum, err := strconv.Atoi(envPort); err == nil {
if portNum >= 0 && portNum <= 65535 {
port = envPort
} else {
log.Printf("Invalid MCP_PORT value: %s (must be between 0 and 65535), using default port 8080", envPort)
}
} else {
log.Printf("Invalid MCP_PORT value: %s (must be a valid number), using default port 8080", envPort)
}
}
return port
}
func main() {
// Get port from environment variable or use default
port := getMCPServerPort()
// Parse command-line flags
addr := flag.String("addr", ":"+port, "Address to listen on")
flag.Parse()
mode, err := getTransportMode()
if err != nil {
log.Fatalf("Error getting transport mode: %v", err)
}
// Create OSV client
osvClient := osv.NewClient()
// Create MCP server
mcpServer := mcp.NewServer(
mcp.WithOSVClient(osvClient),
)
// Handle signals for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Start server in a goroutine
errChan := make(chan error, 1)
go func() {
switch mode {
case TransportHTTPStream:
errChan <- mcpServer.ServeHTTPStream(*addr)
case TransportSSE:
errChan <- mcpServer.ServeSSE(*addr)
}
}()
// Wait for signal or error
select {
case err := <-errChan:
if err != nil {
log.Fatalf("Server error: %v", err)
}
case sig := <-sigChan:
log.Printf("Received signal: %v", sig)
}
log.Println("Shutting down server")
}
```
--------------------------------------------------------------------------------
/pkg/osv/client_test.go:
--------------------------------------------------------------------------------
```go
package osv
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestQuery(t *testing.T) {
// Setup test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request method and path
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/query", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Decode request body
var req QueryRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
// Check request fields
assert.Equal(t, "test-package", req.Package.Name)
assert.Equal(t, "npm", req.Package.Ecosystem)
assert.Equal(t, "1.0.0", req.Version)
// Return mock response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
response := `{
"vulns": [
{
"id": "TEST-2023-001",
"summary": "Test vulnerability",
"details": "This is a test vulnerability",
"modified": "2023-01-01T00:00:00Z",
"published": "2023-01-01T00:00:00Z",
"references": [
{
"type": "ADVISORY",
"url": "https://example.com/advisory/TEST-2023-001"
}
],
"affected": [
{
"package": {
"name": "test-package",
"ecosystem": "npm"
},
"ranges": [
{
"type": "SEMVER",
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.1"
}
]
}
],
"versions": ["1.0.0"]
}
]
}
]
}`
_, _ = w.Write([]byte(response))
}))
defer server.Close()
// Create client with test server URL
client := NewClient(WithBaseURL(server.URL + "/v1"))
// Create query request
req := QueryRequest{
Version: "1.0.0",
Package: Package{
Name: "test-package",
Ecosystem: "npm",
},
}
// Execute query
resp, err := client.Query(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, resp)
// Check response
assert.Len(t, resp.Vulns, 1)
vuln := resp.Vulns[0]
assert.Equal(t, "TEST-2023-001", vuln.ID)
assert.Equal(t, "Test vulnerability", vuln.Summary)
assert.Equal(t, "This is a test vulnerability", vuln.Details)
expectedModified, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
assert.Equal(t, expectedModified, vuln.Modified)
assert.Len(t, vuln.References, 1)
assert.Equal(t, "ADVISORY", vuln.References[0].Type)
assert.Equal(t, "https://example.com/advisory/TEST-2023-001", vuln.References[0].URL)
assert.Len(t, vuln.Affected, 1)
assert.Equal(t, "test-package", vuln.Affected[0].Package.Name)
assert.Equal(t, "npm", vuln.Affected[0].Package.Ecosystem)
}
func TestQueryBatch(t *testing.T) {
// Setup test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request method and path
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/querybatch", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Decode request body
var req QueryBatchRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
// Check request fields
assert.Len(t, req.Queries, 2)
// Return mock response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
response := `{
"results": [
{
"vulns": [
{
"id": "TEST-2023-001",
"modified": "2023-01-01T00:00:00Z"
}
]
},
{
"vulns": [
{
"id": "TEST-2023-002",
"modified": "2023-01-02T00:00:00Z"
}
]
}
]
}`
_, _ = w.Write([]byte(response))
}))
defer server.Close()
// Create client with test server URL
client := NewClient(WithBaseURL(server.URL + "/v1"))
// Create batch query request
req := QueryBatchRequest{
Queries: []QueryRequest{
{
Version: "1.0.0",
Package: Package{
Name: "test-package-1",
Ecosystem: "npm",
},
},
{
Version: "2.0.0",
Package: Package{
Name: "test-package-2",
Ecosystem: "npm",
},
},
},
}
// Execute batch query
resp, err := client.QueryBatch(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, resp)
// Check response
assert.Len(t, resp.Results, 2)
assert.Len(t, resp.Results[0].Vulns, 1)
assert.Equal(t, "TEST-2023-001", resp.Results[0].Vulns[0].ID)
expectedModified1, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
assert.Equal(t, expectedModified1, resp.Results[0].Vulns[0].Modified)
assert.Len(t, resp.Results[1].Vulns, 1)
assert.Equal(t, "TEST-2023-002", resp.Results[1].Vulns[0].ID)
expectedModified2, _ := time.Parse(time.RFC3339, "2023-01-02T00:00:00Z")
assert.Equal(t, expectedModified2, resp.Results[1].Vulns[0].Modified)
}
func TestGetVulnerability(t *testing.T) {
// Setup test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request method and path
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/v1/vulns/TEST-2023-001", r.URL.Path)
// Return mock response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
response := `{
"id": "TEST-2023-001",
"summary": "Test vulnerability",
"details": "This is a test vulnerability",
"modified": "2023-01-01T00:00:00Z",
"published": "2023-01-01T00:00:00Z",
"references": [
{
"type": "ADVISORY",
"url": "https://example.com/advisory/TEST-2023-001"
}
],
"affected": [
{
"package": {
"name": "test-package",
"ecosystem": "npm"
},
"ranges": [
{
"type": "SEMVER",
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.1"
}
]
}
],
"versions": ["1.0.0"]
}
]
}`
_, _ = w.Write([]byte(response))
}))
defer server.Close()
// Create client with test server URL
client := NewClient(WithBaseURL(server.URL + "/v1"))
// Execute get vulnerability
vuln, err := client.GetVulnerability(context.Background(), "TEST-2023-001")
require.NoError(t, err)
require.NotNil(t, vuln)
// Check response
assert.Equal(t, "TEST-2023-001", vuln.ID)
assert.Equal(t, "Test vulnerability", vuln.Summary)
assert.Equal(t, "This is a test vulnerability", vuln.Details)
expectedModified, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
assert.Equal(t, expectedModified, vuln.Modified)
assert.Len(t, vuln.References, 1)
assert.Equal(t, "ADVISORY", vuln.References[0].Type)
assert.Equal(t, "https://example.com/advisory/TEST-2023-001", vuln.References[0].URL)
assert.Len(t, vuln.Affected, 1)
assert.Equal(t, "test-package", vuln.Affected[0].Package.Name)
assert.Equal(t, "npm", vuln.Affected[0].Package.Ecosystem)
}
```
--------------------------------------------------------------------------------
/pkg/osv/client.go:
--------------------------------------------------------------------------------
```go
// Package osv provides functionality for interacting with osv database.
package osv
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
// BaseURL is the base URL for the OSV API
BaseURL = "https://api.osv.dev/v1"
// QueryEndpoint is the endpoint for querying vulnerabilities
QueryEndpoint = "/query"
// QueryBatchEndpoint is the endpoint for batch querying vulnerabilities
QueryBatchEndpoint = "/querybatch"
// VulnEndpoint is the endpoint for getting vulnerability details
VulnEndpoint = "/vulns"
)
// OSVClient is the interface for the OSV API client
//
//nolint:revive // OSVClient is intentionally named to provide clarity on the client's domain
type OSVClient interface {
Query(ctx context.Context, req QueryRequest) (*QueryResponse, error)
QueryBatch(ctx context.Context, req QueryBatchRequest) (*QueryBatchResponse, error)
GetVulnerability(ctx context.Context, id string) (*Vulnerability, error)
}
// Client is a client for the OSV API
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new OSV API client
func NewClient(opts ...ClientOption) *Client {
client := &Client{
httpClient: &http.Client{
Timeout: 60 * time.Second, // Increased timeout to 60 seconds
},
baseURL: BaseURL,
}
for _, opt := range opts {
opt(client)
}
return client
}
// ClientOption is a function that configures a Client
type ClientOption func(*Client)
// WithHTTPClient sets the HTTP client to use
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(c *Client) {
c.httpClient = httpClient
}
}
// WithBaseURL sets the base URL to use
func WithBaseURL(baseURL string) ClientOption {
return func(c *Client) {
c.baseURL = baseURL
}
}
// Package represents a package in the OSV API
type Package struct {
Name string `json:"name,omitempty"`
Ecosystem string `json:"ecosystem,omitempty"`
PURL string `json:"purl,omitempty"`
}
// QueryRequest represents a request to the OSV API query endpoint
type QueryRequest struct {
Commit string `json:"commit,omitempty"`
Version string `json:"version,omitempty"`
Package Package `json:"package,omitempty"`
PageToken string `json:"page_token,omitempty"`
}
// QueryBatchRequest represents a request to the OSV API batch query endpoint
type QueryBatchRequest struct {
Queries []QueryRequest `json:"queries"`
}
// Reference represents a reference in a vulnerability
type Reference struct {
Type string `json:"type"`
URL string `json:"url"`
}
// Event represents an event in a vulnerability's timeline
type Event struct {
Introduced string `json:"introduced,omitempty"`
Fixed string `json:"fixed,omitempty"`
Limit string `json:"limit,omitempty"`
}
// Range represents a range of versions affected by a vulnerability
type Range struct {
Type string `json:"type"`
Repo string `json:"repo,omitempty"`
Events []Event `json:"events"`
}
// Affected represents a package affected by a vulnerability
type Affected struct {
Package Package `json:"package"`
Ranges []Range `json:"ranges,omitempty"`
Versions []string `json:"versions,omitempty"`
EcosystemSpecific map[string]interface{} `json:"ecosystem_specific,omitempty"`
DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
}
// Vulnerability represents a vulnerability in the OSV API
type Vulnerability struct {
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
Details string `json:"details,omitempty"`
Modified time.Time `json:"modified"`
Published time.Time `json:"published,omitempty"`
References []Reference `json:"references,omitempty"`
Affected []Affected `json:"affected,omitempty"`
SchemaVersion string `json:"schema_version,omitempty"`
}
// QueryResponse represents a response from the OSV API query endpoint
type QueryResponse struct {
Vulns []Vulnerability `json:"vulns"`
NextPageToken string `json:"next_page_token,omitempty"`
}
// BatchQueryResult represents a single result in a batch query response
type BatchQueryResult struct {
Vulns []struct {
ID string `json:"id"`
Modified time.Time `json:"modified"`
} `json:"vulns"`
NextPageToken string `json:"next_page_token,omitempty"`
}
// QueryBatchResponse represents a response from the OSV API batch query endpoint
type QueryBatchResponse struct {
Results []BatchQueryResult `json:"results"`
}
// Query queries the OSV API for vulnerabilities matching the given request
func (c *Client) Query(ctx context.Context, req QueryRequest) (*QueryResponse, error) {
_ = ctx
url := fmt.Sprintf("%s%s", c.baseURL, QueryEndpoint)
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create a new context with a 30-second timeout
reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
// Log the error or handle it as appropriate for your application
fmt.Printf("Error closing response body: %v\n", err)
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var queryResp QueryResponse
if err := json.NewDecoder(resp.Body).Decode(&queryResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &queryResp, nil
}
// QueryBatch queries the OSV API for vulnerabilities matching the given batch request
func (c *Client) QueryBatch(ctx context.Context, req QueryBatchRequest) (*QueryBatchResponse, error) {
_ = ctx
url := fmt.Sprintf("%s%s", c.baseURL, QueryBatchEndpoint)
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create a new context with a 30-second timeout
reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
// Log the error or handle it as appropriate for your application
fmt.Printf("Error closing response body: %v\n", err)
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var batchResp QueryBatchResponse
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &batchResp, nil
}
// GetVulnerability gets a vulnerability by ID
func (c *Client) GetVulnerability(ctx context.Context, id string) (*Vulnerability, error) {
_ = ctx
url := fmt.Sprintf("%s%s/%s", c.baseURL, VulnEndpoint, id)
// Create a new context with a 30-second timeout
reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
// Log the error or handle it as appropriate for your application
fmt.Printf("Error closing response body: %v\n", err)
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var vuln Vulnerability
if err := json.NewDecoder(resp.Body).Decode(&vuln); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &vuln, nil
}
```
--------------------------------------------------------------------------------
/pkg/mcp/server.go:
--------------------------------------------------------------------------------
```go
// Package mcp provides MCP server tools for OSV.
package mcp
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/StacklokLabs/osv-mcp/pkg/osv"
)
const (
// ServerName is the name of the MCP server
ServerName = "osv-mcp"
// ServerVersion is the version of the MCP server
ServerVersion = "0.1.0"
// ServerPort is the port the MCP server will listen on
ServerPort = "8080"
)
// Server is an MCP server that provides OSV vulnerability information
type Server struct {
mcpServer *server.MCPServer
osvClient osv.OSVClient
}
// NewServer creates a new OSV MCP server
func NewServer(opts ...ServerOption) *Server {
s := &Server{
osvClient: osv.NewClient(),
}
for _, opt := range opts {
opt(s)
}
mcpServer := server.NewMCPServer(ServerName, ServerVersion)
// Register tools
mcpServer.AddTool(
mcp.NewToolWithRawSchema(
"query_vulnerability",
"Query for vulnerabilities affecting a specific package version or commit",
json.RawMessage(`{
"type": "object",
"properties": {
"commit": {
"type": "string",
"description": "The commit hash to query for. If specified, version should not be set."
},
"version": {
"type": "string",
"description": "The version string to query for. If specified, commit should not be set."
},
"package_name": {
"type": "string",
"description": "The name of the package."
},
"ecosystem": {
"type": "string",
"description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
},
"purl": {
"type": "string",
"description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
}
},
"required": []
}`),
),
s.handleQueryVulnerability,
)
mcpServer.AddTool(
mcp.NewToolWithRawSchema(
"query_vulnerabilities_batch",
"Query for vulnerabilities affecting multiple packages or commits at once",
json.RawMessage(`{
"type": "object",
"properties": {
"queries": {
"type": "array",
"description": "Array of query objects",
"items": {
"type": "object",
"properties": {
"commit": {
"type": "string",
"description": "The commit hash to query for. If specified, version should not be set."
},
"version": {
"type": "string",
"description": "The version string to query for. If specified, commit should not be set."
},
"package_name": {
"type": "string",
"description": "The name of the package."
},
"ecosystem": {
"type": "string",
"description": "The ecosystem for this package (e.g., PyPI, npm, Go)."
},
"purl": {
"type": "string",
"description": "The package URL for this package. If purl is used, package_name and ecosystem should not be set."
}
}
}
}
},
"required": ["queries"]
}`),
),
s.handleQueryVulnerabilitiesBatch,
)
mcpServer.AddTool(
mcp.NewToolWithRawSchema(
"get_vulnerability",
"Get details for a specific vulnerability by ID",
json.RawMessage(`{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The OSV vulnerability ID"
}
},
"required": ["id"]
}`),
),
s.handleGetVulnerability,
)
s.mcpServer = mcpServer
return s
}
// ServerOption is a function that configures a Server
type ServerOption func(*Server)
// WithOSVClient sets the OSV client to use
func WithOSVClient(client osv.OSVClient) ServerOption {
return func(s *Server) {
s.osvClient = client
}
}
// ServeSSE starts the MCP server using SSE
func (s *Server) ServeSSE(addr string) error {
log.Printf("Starting OSV MCP server (SSE) on %s", addr)
sseServer := server.NewSSEServer(s.mcpServer)
return sseServer.Start(addr)
}
// ServeHTTPStream starts the MCP server using Streamable HTTP transport
func (s *Server) ServeHTTPStream(addr string) error {
log.Printf("Starting OSV MCP server (Streamable HTTP) on %s", addr)
httpSrv := server.NewStreamableHTTPServer(s.mcpServer,
server.WithEndpointPath("/mcp/"),
server.WithStateLess(true), // stateless mode
server.WithHeartbeatInterval(30*time.Second),
)
return httpSrv.Start(addr)
}
// handleQueryVulnerability handles the query_vulnerability tool
func (s *Server) handleQueryVulnerability(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
commit := mcp.ParseString(request, "commit", "")
version := mcp.ParseString(request, "version", "")
packageName := mcp.ParseString(request, "package_name", "")
ecosystem := mcp.ParseString(request, "ecosystem", "")
purl := mcp.ParseString(request, "purl", "")
// Validate input
if commit != "" && version != "" {
return mcp.NewToolResultError("Both commit and version cannot be specified"), nil
}
if purl != "" && (packageName != "" || ecosystem != "") {
return mcp.NewToolResultError("If purl is specified, package_name and ecosystem should not be specified"), nil
}
if purl == "" && (packageName == "" || ecosystem == "") && commit == "" {
return mcp.NewToolResultError("Either purl, or both package_name and ecosystem, or commit must be specified"), nil
}
// Create query request
queryReq := osv.QueryRequest{
Commit: commit,
Version: version,
}
if purl != "" {
queryReq.Package = osv.Package{
PURL: purl,
}
} else if packageName != "" && ecosystem != "" {
queryReq.Package = osv.Package{
Name: packageName,
Ecosystem: ecosystem,
}
}
// Query OSV API
resp, err := s.osvClient.Query(ctx, queryReq)
if err != nil {
return mcp.NewToolResultErrorFromErr("Failed to query OSV API", err), nil
}
// Format response
result, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return mcp.NewToolResultErrorFromErr("Failed to marshal response", err), nil
}
return mcp.NewToolResultText(string(result)), nil
}
// handleQueryVulnerabilitiesBatch handles the query_vulnerabilities_batch tool
func (s *Server) handleQueryVulnerabilitiesBatch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]interface{})
if !ok {
return mcp.NewToolResultError("Invalid arguments format"), nil
}
queriesRaw, ok := args["queries"].([]interface{})
if !ok {
return mcp.NewToolResultError("Invalid 'queries' parameter: must be array"), nil
}
// Convert queries to QueryRequest objects
var queries []osv.QueryRequest
for i, queryRaw := range queriesRaw {
queryMap, ok := queryRaw.(map[string]interface{})
if !ok {
return mcp.NewToolResultError(fmt.Sprintf("Invalid query at index %d", i)), nil
}
commit, _ := queryMap["commit"].(string)
version, _ := queryMap["version"].(string)
packageName, _ := queryMap["package_name"].(string)
ecosystem, _ := queryMap["ecosystem"].(string)
purl, _ := queryMap["purl"].(string)
// Validate input
if commit != "" && version != "" {
return mcp.NewToolResultError(fmt.Sprintf("Both commit and version cannot be specified in query %d", i)), nil
}
if purl != "" && (packageName != "" || ecosystem != "") {
return mcp.NewToolResultError(
fmt.Sprintf("If purl is specified, package_name and ecosystem should not be specified in query %d", i),
), nil
}
// Create query request
queryReq := osv.QueryRequest{
Commit: commit,
Version: version,
}
if purl != "" {
queryReq.Package = osv.Package{
PURL: purl,
}
} else if packageName != "" && ecosystem != "" {
queryReq.Package = osv.Package{
Name: packageName,
Ecosystem: ecosystem,
}
}
queries = append(queries, queryReq)
}
// Query OSV API
resp, err := s.osvClient.QueryBatch(ctx, osv.QueryBatchRequest{Queries: queries})
if err != nil {
return mcp.NewToolResultErrorFromErr("Failed to query OSV API", err), nil
}
// Format response
result, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return mcp.NewToolResultErrorFromErr("Failed to marshal response", err), nil
}
return mcp.NewToolResultText(string(result)), nil
}
// handleGetVulnerability handles the get_vulnerability tool
func (s *Server) handleGetVulnerability(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := mcp.ParseString(request, "id", "")
if id == "" {
return mcp.NewToolResultError("Vulnerability ID is required"), nil
}
// Get vulnerability from OSV API
vuln, err := s.osvClient.GetVulnerability(ctx, id)
if err != nil {
return mcp.NewToolResultErrorFromErr("Failed to get vulnerability", err), nil
}
// Format response
result, err := json.MarshalIndent(vuln, "", " ")
if err != nil {
return mcp.NewToolResultErrorFromErr("Failed to marshal response", err), nil
}
return mcp.NewToolResultText(string(result)), nil
}
```
--------------------------------------------------------------------------------
/pkg/mcp/server_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/StacklokLabs/osv-mcp/pkg/osv"
)
// mockOSVClient is a mock implementation of the OSV client for testing
type mockOSVClient struct {
queryFunc func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error)
queryBatchFunc func(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error)
getVulnerabilityFunc func(ctx context.Context, id string) (*osv.Vulnerability, error)
}
func (m *mockOSVClient) Query(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
return m.queryFunc(ctx, req)
}
func (m *mockOSVClient) QueryBatch(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error) {
return m.queryBatchFunc(ctx, req)
}
func (m *mockOSVClient) GetVulnerability(ctx context.Context, id string) (*osv.Vulnerability, error) {
return m.getVulnerabilityFunc(ctx, id)
}
// newMockOSVClient creates a new mock OSV client with default implementations
func newMockOSVClient() *mockOSVClient {
return &mockOSVClient{
queryFunc: func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
_, _ = ctx, req
return &osv.QueryResponse{
Vulns: []osv.Vulnerability{
{
ID: "TEST-2023-001",
Summary: "Test vulnerability",
Modified: time.Now(),
},
},
}, nil
},
queryBatchFunc: func(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error) {
_, _ = ctx, req
return &osv.QueryBatchResponse{
Results: []osv.BatchQueryResult{
{
Vulns: []struct {
ID string `json:"id"`
Modified time.Time `json:"modified"`
}{
{
ID: "TEST-2023-001",
Modified: time.Now(),
},
},
},
},
}, nil
},
getVulnerabilityFunc: func(ctx context.Context, id string) (*osv.Vulnerability, error) {
_, _ = ctx, id
return &osv.Vulnerability{
ID: "TEST-2023-001",
Summary: "Test vulnerability",
Modified: time.Now(),
}, nil
},
}
}
// getTextContent extracts the text content from a CallToolResult
func getTextContent(result *mcp.CallToolResult) string {
if len(result.Content) == 0 {
return ""
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
return ""
}
return textContent.Text
}
func TestHandleQueryVulnerability(t *testing.T) {
// Create mock OSV client
mockClient := newMockOSVClient()
// Set up expected query parameters
expectedPackageName := "test-package"
expectedEcosystem := "npm"
expectedVersion := "1.0.0"
// Override query function to check parameters
mockClient.queryFunc = func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
_, _ = ctx, req
assert.Equal(t, expectedPackageName, req.Package.Name)
assert.Equal(t, expectedEcosystem, req.Package.Ecosystem)
assert.Equal(t, expectedVersion, req.Version)
return &osv.QueryResponse{
Vulns: []osv.Vulnerability{
{
ID: "TEST-2023-001",
Summary: "Test vulnerability",
Modified: time.Now(),
},
},
}, nil
}
// Create server with mock client
server := NewServer(WithOSVClient(mockClient))
// Create tool request
request := mcp.CallToolRequest{}
request.Params.Arguments = map[string]interface{}{
"package_name": expectedPackageName,
"ecosystem": expectedEcosystem,
"version": expectedVersion,
}
// Call handler
result, err := server.handleQueryVulnerability(context.Background(), request)
// Check result
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError)
// Get text content
text := getTextContent(result)
assert.NotEmpty(t, text)
// Parse result text as JSON
var response osv.QueryResponse
err = json.Unmarshal([]byte(text), &response)
require.NoError(t, err)
// Check response
assert.Len(t, response.Vulns, 1)
assert.Equal(t, "TEST-2023-001", response.Vulns[0].ID)
assert.Equal(t, "Test vulnerability", response.Vulns[0].Summary)
}
func TestHandleQueryVulnerabilityWithPURL(t *testing.T) {
// Create mock OSV client
mockClient := newMockOSVClient()
// Set up expected query parameters
expectedPURL := "pkg:npm/[email protected]"
// Override query function to check parameters
mockClient.queryFunc = func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
_, _ = ctx, req
assert.Equal(t, expectedPURL, req.Package.PURL)
assert.Empty(t, req.Package.Name)
assert.Empty(t, req.Package.Ecosystem)
return &osv.QueryResponse{
Vulns: []osv.Vulnerability{
{
ID: "TEST-2023-001",
Summary: "Test vulnerability",
Modified: time.Now(),
},
},
}, nil
}
// Create server with mock client
server := NewServer(WithOSVClient(mockClient))
// Create tool request
request := mcp.CallToolRequest{}
request.Params.Arguments = map[string]interface{}{
"purl": expectedPURL,
}
// Call handler
result, err := server.handleQueryVulnerability(context.Background(), request)
// Check result
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError)
assert.NotEmpty(t, getTextContent(result))
}
func TestHandleQueryVulnerabilityWithCommit(t *testing.T) {
// Create mock OSV client
mockClient := newMockOSVClient()
// Set up expected query parameters
expectedCommit := "abcdef1234567890"
// Override query function to check parameters
mockClient.queryFunc = func(ctx context.Context, req osv.QueryRequest) (*osv.QueryResponse, error) {
_, _ = ctx, req
assert.Equal(t, expectedCommit, req.Commit)
assert.Empty(t, req.Version)
return &osv.QueryResponse{
Vulns: []osv.Vulnerability{
{
ID: "TEST-2023-001",
Summary: "Test vulnerability",
Modified: time.Now(),
},
},
}, nil
}
// Create server with mock client
server := NewServer(WithOSVClient(mockClient))
// Create tool request
request := mcp.CallToolRequest{}
request.Params.Arguments = map[string]interface{}{
"commit": expectedCommit,
}
// Call handler
result, err := server.handleQueryVulnerability(context.Background(), request)
// Check result
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError)
assert.NotEmpty(t, getTextContent(result))
}
func TestHandleQueryVulnerabilitiesBatch(t *testing.T) {
// Create mock OSV client
mockClient := newMockOSVClient()
// Override query batch function to check parameters
mockClient.queryBatchFunc = func(ctx context.Context, req osv.QueryBatchRequest) (*osv.QueryBatchResponse, error) {
_, _ = ctx, req
assert.Len(t, req.Queries, 2)
assert.Equal(t, "test-package-1", req.Queries[0].Package.Name)
assert.Equal(t, "npm", req.Queries[0].Package.Ecosystem)
assert.Equal(t, "1.0.0", req.Queries[0].Version)
assert.Equal(t, "test-package-2", req.Queries[1].Package.Name)
assert.Equal(t, "npm", req.Queries[1].Package.Ecosystem)
assert.Equal(t, "2.0.0", req.Queries[1].Version)
return &osv.QueryBatchResponse{
Results: []osv.BatchQueryResult{
{
Vulns: []struct {
ID string `json:"id"`
Modified time.Time `json:"modified"`
}{
{
ID: "TEST-2023-001",
Modified: time.Now(),
},
},
},
{
Vulns: []struct {
ID string `json:"id"`
Modified time.Time `json:"modified"`
}{
{
ID: "TEST-2023-002",
Modified: time.Now(),
},
},
},
},
}, nil
}
// Create server with mock client
server := NewServer(WithOSVClient(mockClient))
// Create tool request
request := mcp.CallToolRequest{}
request.Params.Arguments = map[string]interface{}{
"queries": []interface{}{
map[string]interface{}{
"package_name": "test-package-1",
"ecosystem": "npm",
"version": "1.0.0",
},
map[string]interface{}{
"package_name": "test-package-2",
"ecosystem": "npm",
"version": "2.0.0",
},
},
}
// Call handler
result, err := server.handleQueryVulnerabilitiesBatch(context.Background(), request)
// Check result
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError)
// Get text content
text := getTextContent(result)
assert.NotEmpty(t, text)
// Parse result text as JSON
var response osv.QueryBatchResponse
err = json.Unmarshal([]byte(text), &response)
require.NoError(t, err)
// Check response
assert.Len(t, response.Results, 2)
assert.Len(t, response.Results[0].Vulns, 1)
assert.Equal(t, "TEST-2023-001", response.Results[0].Vulns[0].ID)
assert.Len(t, response.Results[1].Vulns, 1)
assert.Equal(t, "TEST-2023-002", response.Results[1].Vulns[0].ID)
}
func TestHandleGetVulnerability(t *testing.T) {
// Create mock OSV client
mockClient := newMockOSVClient()
// Set up expected query parameters
expectedID := "TEST-2023-001"
// Override get vulnerability function to check parameters
mockClient.getVulnerabilityFunc = func(ctx context.Context, id string) (*osv.Vulnerability, error) {
_ = ctx
assert.Equal(t, expectedID, id)
return &osv.Vulnerability{
ID: expectedID,
Summary: "Test vulnerability",
Details: "This is a test vulnerability",
Modified: time.Now(),
}, nil
}
// Create server with mock client
server := NewServer(WithOSVClient(mockClient))
// Create tool request
request := mcp.CallToolRequest{}
request.Params.Arguments = map[string]interface{}{
"id": expectedID,
}
// Call handler
result, err := server.handleGetVulnerability(context.Background(), request)
// Check result
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError)
// Get text content
text := getTextContent(result)
assert.NotEmpty(t, text)
// Parse result text as JSON
var vuln osv.Vulnerability
err = json.Unmarshal([]byte(text), &vuln)
require.NoError(t, err)
// Check response
assert.Equal(t, expectedID, vuln.ID)
assert.Equal(t, "Test vulnerability", vuln.Summary)
assert.Equal(t, "This is a test vulnerability", vuln.Details)
}
```