Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Test

on:
push:
branches: [main]
pull_request:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Format
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-features --all-targets -- -D warnings
- name: Test
run: cargo test --all-features
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changelog

## Unreleased

The CLI and MCP server now share one set of operation declarations (`src/ops/`),
so the two surfaces expose identical operations with identical parameters.

### Breaking changes

- **MCP arg shapes changed.** Per-entity fields replace the generic
`parent`/string `id`; list entities are plural (`{"entity": "issues", ...}`),
matching the CLI. Agent clients pick the new shapes up from `tools/list`;
saved call examples must be updated (see README → Migrating to the unified
surface).
- **The standalone `snippet_match` MCP tool is gone.** Use `get` with
`entity: "snippet_match"`.
- **`list snippet-locations` is paginated.** Both surfaces page over the
underlying snippets (`--page`/`--count`) and return a page object instead of
an unbounded array.
- **CLI:** `list dependencies` no longer accepts `--revision` as an
alternative to the positional revision argument.
- `update project` with no fields to change is rejected instead of sending an
empty update.

### Additions

- MCP gains `get snippet`, paged `list snippets`, `list snippet_paths`, and
issue-category auto-probe; the CLI gains `update --url/--policy-id/
--default-branch` and real pagination on `list revisions` and
`list dependencies`.
- `page`/`count` values below 1 are clamped to 1 (`count` is capped at 100).
- MCP calls using the legacy arg shapes fail with a migration hint naming
this change.
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@ Project (top-level container)
| `List` | Paginated listing | `Project::list_page(&client, query, page, count)` |
| `Update` | Modify entity | `Project::update(&client, locator, params)` |

## CLI/MCP parity (`src/ops/`)

The CLI and the MCP server are thin adapters over shared operation
declarations in `src/ops/`: one enum per verb (`GetCommand`, `ListCommand`,
`UpdateCommand`) deriving `clap::Subcommand` **and** `Deserialize`/`JsonSchema`
(internally tagged on `entity`), with one param struct per entity deriving
`clap::Args` + `Deserialize` + `JsonSchema`. Doc comments become both clap
help and MCP schema descriptions.

To add an operation: add a param struct, an enum variant, a match arm in the
verb's `run_*` fn, and an output-enum variant. Both surfaces pick it up with
no surface-specific code; a missing arm is a compile error. `tests/parity.rs`
guards the rest (tool list == verbs, clap subcommands == schema entities,
clap args == schema properties) — never add a `#[serde(skip)]`/`#[clap(skip)]`
to these types without checking it. Pagination policy — defaults plus the
global clamp (page ≥ 1, 1 ≤ count ≤ 100) — lives only in `PageArgs::resolve`.
Per-endpoint bounds (issues min count 5, snippet pageSize ≤ 50) are enforced
by the API/model layer today; encoding them into the declarations so schemas
advertise them is issue #41.

## Models

- **Project** - Top-level container, implements Get/List/Update
Expand Down
42 changes: 42 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# fossapi

A CLI and MCP server exposing the FOSSA API to humans and agents through one
shared set of operation declarations.

## Language

**Operation**:
One verb applied to one entity (e.g. get issue, list snippets). Declared once
and exposed identically by both surfaces.
_Avoid_: endpoint, command

**Verb**:
One of `get`, `list`, `update` — the top-level grouping of operations. Each
verb is one shared enum and one MCP tool. (In code the enums are spelled
`GetCommand`/`ListCommand`/`UpdateCommand`; "command" in a type name means
verb, not operation.)
_Avoid_: action, method

**Entity**:
The thing a verb acts on (`project`, `issue`, `snippet_locations`, …). Appears
as the CLI subcommand name and as the `entity` discriminator in MCP arguments.
_Avoid_: resource, object

**Declaration**:
The single definition of an operation — its parameter struct and enum
variant — from which both surfaces derive their interface, documentation, and
schemas.

**Surface**:
A way of reaching the operations: the CLI (for humans) or the MCP server (for
agents). Surfaces are thin adapters; neither adds operations of its own.
_Avoid_: frontend, interface

**Parity**:
The guarantee that both surfaces expose exactly the same operations with the
same parameters.

**Pagination policy**:
The defaults and global bounds applied to `page`/`count` before an operation
runs. One policy for all operations; individual FOSSA endpoints may impose
their own tighter bounds.
57 changes: 44 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@ fossapi list snippets "custom+1/my-project\$abc123" --path /src
# Show the file/directory tree where snippets were detected
fossapi list snippet-paths "custom+1/my-project\$abc123"

# Flat report: every match location (first-party file -> matched package)
fossapi list snippet-locations "custom+1/my-project\$abc123"
# Flat report: every match location (first-party file -> matched package).
# Paginated over snippets: each page returns every location of --count snippets.
fossapi list snippet-locations "custom+1/my-project\$abc123" --page 1 --count 20

# ...and resolve the first-party line range for each match (extra API calls)
fossapi list snippet-locations "custom+1/my-project\$abc123" --with-lines
Expand Down Expand Up @@ -153,17 +154,47 @@ Add to your MCP config:

### Available Tools

| Tool | Description |
|------|-------------|
| `get` | Fetch a single project, revision, or issue by ID |
| `list` | List projects, revisions, dependencies, issues, or snippet match locations |
| `update` | Update project metadata (title, description, url, public) |
| `snippet_match` | Drill into one snippet match: the matched first-party and reference code |

> **Snippets over MCP:** use `list` with `entity: snippet` and `parent: <revision
> locator>` (optional `path` and `with_lines`) to map third-party matches to
> first-party files, then `snippet_match` to drill into a single match. Snippets
> don't support `get` or `update`.
The MCP tools mirror the CLI verbs exactly: each tool takes an `entity`
discriminator naming the subcommand, plus that subcommand's arguments (the
input schemas are generated from the same declarations the CLI parses into).

| Tool | Entities |
|------|----------|
| `get` | `project`, `revision`, `issue` (category optional — omitted probes all three), `snippet`, `snippet_match` |
| `list` | `projects`, `issues` (category required), `dependencies`, `revisions`, `snippets`, `snippet_locations`, `snippet_paths` |
| `update` | `project` (title, description, url, public, policy_id, default_branch) |

For example, `fossapi get issue 12345 --category licensing` is
`get {"entity": "issue", "id": 12345, "category": "licensing"}` over MCP.

> **Snippets over MCP:** use `list` with `entity: snippet_locations` and
> `revision: <revision locator>` (optional `path` and `with_lines`) to map
> third-party matches to first-party files, then `get` with
> `entity: snippet_match` to drill into a single match.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Paged list operations take `page` and `count` (defaults 1 and 20; values are
clamped to at least 1 and `count` to at most 100). `snippet_locations` pages
over the underlying snippets, so one page can hold more or fewer rows than
`count`.

### Migrating to the unified surface

The CLI/MCP unification changed the MCP arg shapes (breaking for saved call
configs; live clients pick the new shapes up automatically from `tools/list`):

- Per-entity fields replace the old generic `parent`/string `id` — e.g.
`get {"entity": "issue", "id": 12345}` (numeric id),
`list {"entity": "revisions", "project": "custom+1/my-project"}`.
- List entities are plural (`projects`, `issues`, …), matching the CLI.
- The standalone `snippet_match` tool folded into
`get {"entity": "snippet_match", ...}`.
- `list {"entity": "snippet_locations", ...}` is now paginated and returns a
page object (`items`/`page`/`count`/`total`/`has_more`) instead of a bare
array.

On the CLI, `list dependencies` now takes the revision positionally only
(`--revision` was removed). Calls using the old shapes fail with an error that
points back to this section.

## Locators

Expand Down
21 changes: 21 additions & 0 deletions docs/adr/0001-generic-mcp-tools-per-verb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generic MCP tools per verb, not per operation

The MCP server exposes exactly three tools — `get`, `list`, `update` — each
taking an `entity` discriminator, rather than one tool per operation
(`get_issue`, `list_snippets`, …). The CLI and the MCP server are one
presentation layer and must stay consistent, so the tools mirror the CLI's
verb-first grammar; a small tool list also keeps agent contexts lean.

## Considered Options

One tool per operation was rejected: its main draws are per-tool permissioning
and discoverability, but authorization is enforced by the FOSSA app behind the
API token (the tool surface adds nothing), and entity discoverability is
handled by the tool descriptions and self-describing input schemas.

## Consequences

Adding an operation never changes the MCP tool list — clients' saved tool
configurations stay valid as the operation set grows. The cost is that the
`entity` tag becomes load-bearing wire format: renaming an entity is a
breaking change to every saved call, not just a CLI rename.
15 changes: 12 additions & 3 deletions examples/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ async fn main() -> fossapi::Result<()> {
let project = Project::get(&client, first_project.id.clone()).await?;
println!("Project: {}", project.title);
println!(" ID: {}", project.id);
println!(" Type: {}", project.project_type.as_deref().unwrap_or("unknown"));
println!(
" Type: {}",
project.project_type.as_deref().unwrap_or("unknown")
);
println!(" Public: {}", project.public);
println!(" Issues: {:?}", project.issues);

Expand All @@ -51,7 +54,13 @@ async fn main() -> fossapi::Result<()> {
let ref_name = rev.ref_from_locator().unwrap_or("unknown");
let resolved = if rev.resolved { "resolved" } else { "pending" };
let issues = rev.unresolved_issue_count.unwrap_or(0);
println!(" {}. {} - {} ({} issues)", i + 1, ref_name, resolved, issues);
println!(
" {}. {} - {} ({} issues)",
i + 1,
ref_name,
resolved,
issues
);
}

// Get the first revision and show its dependencies
Expand Down Expand Up @@ -82,7 +91,7 @@ async fn main() -> fossapi::Result<()> {
} else {
String::new()
};
println!(" - {}@{}{}", name, version, issues);
println!(" - {name}@{version}{issues}");
}
}
}
Expand Down
Loading
Loading