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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ Project (top-level container)
| Issues | `GET /v2/issues` | `category` **required**; `count` clamps to a minimum of 5 |
| Issue | `GET /v2/issues/{id}` | Single issue with full details |
| Issue actions | `PUT /v2/issues/` | Ignore/unignore. Targets in the query (`category` required, `ids[]`), action in the body (`{type, notes?, reason?}`). By ID the server is an **unguarded upsert**: the `status` query param is silently ignored, and re-ignoring overwrites notes/reason and resets the ignored-at timestamp — which is why we guard client-side (see ADR 0002). Responds `{count, issueId?}` (`issueId` only when count==1); `count: 0` = target not visible to the token. Full token only — push-only tokens can't write issues; missing resolve permission is a **400**, not 403 |
| Teams | `GET /v2/teams` | `{results, page, pageSize, totalCount}`; `pageSize` defaults 10, capped at 50; `search` matches name |
| Project teams | `GET /projects/{locator}/teams` | `{totalCount, teams: [{id, name}]}`, not paginated. `GET /projects/{locator}` has no teams; `GET /v2/projects` does (as objects) |
| Projects by team | `GET /v2/projects?teamId=N` | `teamId=null` = projects on no team |
| Team membership | `PUT /teams/{id}/projects` | Body `{action, projects: [locator]}` → `{id, projects}`. We send only `add`/`remove`: `replace` drops every project not listed. `add` keeps other teams, is all-or-nothing (403 if the token lacks Edit on any project; a `$revision` suffix matches nothing, so we strip it). No move endpoint: move = add then remove. `PUT /projects/{locator}` silently drops a `teams` field |
| Snippets | `GET /revisions/{locator}/snippets` | Paginated; `pageSize` capped at 50 (`list_all` overrides) |
| Snippet paths | `GET /revisions/{locator}/snippets/paths` | File/dir tree, drill in via `path` |
| Snippet details | `GET /revisions/{locator}/snippets/{id}` | Single snippet + its per-file matches |
Expand Down Expand Up @@ -164,6 +168,7 @@ advertise them is issue #41.
- **Dependency** - Package dependency, implements List only (via revision)
- **Issue** - Vulnerability/licensing/quality issue, implements Get/List/Update. Update = ignore/unignore via `IssueUpdateParams` (`IssueAction::Ignore { notes, reason }` / `Unignore`), surfaced as the top-level `ignore`/`unignore` verbs on both surfaces; it pre-fetches the issue and refuses actions whose target state already fully holds ("unignore it first" — see ADR 0002; partial org-wide states accept both actions, like the UI's global view), then re-fetches and returns the refreshed issue. `reason` is refused outside the vulnerability category (the server stores it for any category, but nothing ever displays it for licensing/quality — write-only noise). `IssueIgnoreReason` is an enum because the API resolves the reason string against its `ResolutionReasons` table and silently stores NULL on a mismatch. `Issue` has no `deny_unknown_fields`, so any API key not declared on the struct is silently dropped — when the API grows a field, add it here or callers never see it. `cpes` is deliberately unmodeled (empty on all 80 sampled issues); `patchedVersionRanges` is modeled but rarely populated (1/80) — prefer `remediation` for upgrade targets. `IssueProject` entries carry the revision the issue was found in (`revision_id`, `latest`, `first_found_at`), not just the project.
- **Snippet** - Third-party (OSS) code matched into first-party files, implements List only (via revision). Read-only; reached through the `get_snippet_*` convenience functions. Quirks: `id` is a string, `matchDetails.matchPercentage` is 0-100 (other percentages are 0-1), and whole-file matches highlight a trailing blank EOF line that is excluded from the reported range.
- **Team** - Read via `get_teams_page`/`get_project_teams`; membership written via `add_team_projects`/`remove_team_projects`/`move_team_projects` (the `add`/`remove`/`move` verbs). `Project.teams` holds `Team`s (id + name)
- **LicenseInfo** - Can be simple string ("MIT") or full object

## Issue Categories
Expand Down
17 changes: 14 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,35 @@ fossapi get project "custom+1/my-project"
fossapi update project "custom+1/my-project" --title "New Title"
```

### Teams

```bash
# List teams (optionally search by name)
fossapi list teams --search platform

# Which teams is a project on?
fossapi list teams --project "custom+1/my-project"

# Projects on one team, or on no team
fossapi list projects --team 12
fossapi list projects --no-team

# Add or remove projects (several locators at once)
fossapi add project "custom+1/a" "custom+1/b" --team 12
fossapi remove project "custom+1/a" --team 12

# Move projects between teams
fossapi move project "custom+1/a" --from 12 --to 34
```

Team IDs are numeric. A project can be on several teams, and `add` keeps its
other teams, so `move` is an add to `--to` followed by a remove from
`--from`: if the add fails nothing is removed; if the remove fails the error
says the projects are now on both teams. `add` is all-or-nothing: FOSSA
refuses the whole batch if the token lacks Edit on any project. Any
`$revision` suffix on a locator is stripped. `list teams` pages at most 50
per page.

### Revisions

```bash
Expand Down Expand Up @@ -182,10 +211,13 @@ 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` |
| `list` | `projects` (optional `team` or `no_team`), `teams` (optional `search` or `project`), `issues` (category required), `dependencies`, `revisions`, `snippets`, `snippet_locations`, `snippet_paths` |
| `update` | `project` (title, description, url, public, policy_id, default_branch) |
| `ignore` | `issue` (category required; optional notes, and reason on vulnerabilities) |
| `unignore` | `issue` (category required) |
| `add` | `project` (`locators`, `team`) |
| `remove` | `project` (`locators`, `team`) |
| `move` | `project` (`locators`, `from`, `to`) |

For example, `fossapi get issue 12345 --category licensing` is
`get {"entity": "issue", "id": 12345, "category": "licensing"}` over MCP, and
Expand Down
29 changes: 28 additions & 1 deletion src/bin/fossapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

use clap::Parser;
use fossapi::cli::{Cli, Command};
use fossapi::ops::{run_get, run_ignore, run_list, run_unignore, run_update, ListOutput};
use fossapi::ops::{
run_add, run_get, run_ignore, run_list, run_move, run_remove, run_unignore, run_update,
ListOutput,
};
use fossapi::{FossaClient, Page, PrettyPrint, Project, Snippet, SnippetLocation, SnippetPath};
use serde::Serialize;
use std::process::ExitCode;
Expand Down Expand Up @@ -57,6 +60,9 @@ async fn run(client: &FossaClient, cli: Cli) -> fossapi::Result<()> {
let output = run_unignore(client, command).await?;
output_single(&output, cli.json)
}
Command::Add { command } => output_single(&run_add(client, command).await?, cli.json),
Command::Remove { command } => output_single(&run_remove(client, command).await?, cli.json),
Command::Move { command } => output_single(&run_move(client, command).await?, cli.json),
Command::Mcp { verbose } => handle_mcp(client, verbose).await,
}
}
Expand Down Expand Up @@ -99,6 +105,7 @@ fn output_single<T: Serialize + PrettyPrint>(item: &T, json: bool) -> fossapi::R
fn output_list(output: &ListOutput, json: bool) -> fossapi::Result<()> {
match output {
ListOutput::Projects(page) => output_page(page, json, |p| ProjectRow::from(p)),
ListOutput::Teams(page) => output_page(page, json, |t| TeamRow::from(t)),
ListOutput::Issues(page) => output_page(page, json, |i| IssueRow::from(i)),
ListOutput::Dependencies(page) => output_page(page, json, |d| DependencyRow::from(d)),
ListOutput::Revisions(page) => output_page(page, json, |r| RevisionRow::from(r)),
Expand Down Expand Up @@ -168,6 +175,26 @@ impl From<&Project> for ProjectRow {
}
}

#[derive(Tabled)]
struct TeamRow {
id: u64,
name: String,
projects: String,
}

impl From<&fossapi::Team> for TeamRow {
fn from(t: &fossapi::Team) -> Self {
Self {
id: t.id,
name: t.name.clone(),
projects: t
.team_projects_count
.map(|c| c.to_string())
.unwrap_or_default(),
}
}
}

#[derive(Tabled)]
struct IssueRow {
id: u64,
Expand Down
23 changes: 22 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

use clap::{Parser, Subcommand};

pub use crate::ops::{GetCommand, IgnoreCommand, ListCommand, UnignoreCommand, UpdateCommand};
pub use crate::ops::{
AddCommand, GetCommand, IgnoreCommand, ListCommand, MoveCommand, RemoveCommand,
UnignoreCommand, UpdateCommand,
};

/// FOSSA API command-line interface.
#[derive(Parser, Debug)]
Expand Down Expand Up @@ -54,6 +57,24 @@ pub enum Command {
command: UnignoreCommand,
},

/// Add projects to a team (keeps their other teams).
Add {
#[command(subcommand)]
command: AddCommand,
},

/// Remove projects from a team.
Remove {
#[command(subcommand)]
command: RemoveCommand,
},

/// Move projects from one team to another (add, then remove).
Move {
#[command(subcommand)]
command: MoveCommand,
},

/// Run the MCP server on stdio.
Mcp {
/// Enable verbose (debug) logging.
Expand Down
7 changes: 7 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,16 @@ pub use models::{
SnippetMatchDetails,
SnippetPath,
SnippetQuery,
// Team types
Team,
TeamMove,
TeamProjects,
};

// Re-export convenience functions
pub use models::{
add_team_projects, get_project_teams, get_teams_page, move_team_projects, remove_team_projects,
};
pub use models::{get_dependencies, get_dependencies_page};
pub use models::{get_issues, get_issues_page, get_project_issues};
pub use models::{get_revision, get_revisions, get_revisions_page};
Expand Down
85 changes: 80 additions & 5 deletions src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ use std::sync::Arc;

use crate::{
ops::{
run_get, run_ignore, run_list, run_unignore, run_update, GetCommand, IgnoreCommand,
ListCommand, UnignoreCommand, UpdateCommand,
run_add, run_get, run_ignore, run_list, run_move, run_remove, run_unignore, run_update,
AddCommand, GetCommand, IgnoreCommand, ListCommand, MoveCommand, RemoveCommand,
UnignoreCommand, UpdateCommand,
},
FossaClient, FossaError,
};
Expand All @@ -30,7 +31,7 @@ use crate::{
/// # Tools
///
/// The server exposes one tool per CLI verb — `get`, `list`, `update`,
/// `ignore`, and `unignore` — and each tool's input schema is generated from the same
/// `ignore`, `unignore`, `add`, `remove`, and `move` — and each tool's input schema is generated from the same
/// [`crate::ops`] enum that the CLI parses into, so the two surfaces
/// always expose the same operations with the same parameters.
///
Expand Down Expand Up @@ -87,7 +88,10 @@ impl FossaServer {
),
Tool::new(
"list",
"List FOSSA entities. entity values: projects (paginated), issues \
"List FOSSA entities. entity values: projects (paginated; optional \
team filters to one numeric team id, no_team to projects on no team), \
teams (paginated, count max 50; optional search by name, or project \
locator for the teams that project is on), issues \
(category required: vulnerability, licensing, quality), dependencies \
(revision locator required), revisions (project locator required), \
snippets (revision locator; one row per matched OSS package), \
Expand Down Expand Up @@ -120,6 +124,28 @@ impl FossaServer {
nothing on the issue is ignored. Requires a full API token.",
Self::schema::<UnignoreCommand>(),
),
Tool::new(
"add",
"Add FOSSA projects to a team. entity values: project (locators \
list, team numeric id). Projects keep their other teams. \
All-or-nothing: refused if the token lacks Edit on any project.",
Self::schema::<AddCommand>(),
),
Tool::new(
"remove",
"Remove FOSSA projects from a team. entity values: project \
(locators list, team numeric id).",
Self::schema::<RemoveCommand>(),
),
Tool::new(
"move",
"Move FOSSA projects from one team to another. entity values: \
project (locators list, from and to numeric team ids). Adds to \
the target team, then removes from the source team; if the add \
fails nothing is removed, and if the remove fails the error says \
the projects are now on both teams.",
Self::schema::<MoveCommand>(),
),
]
}

Expand Down Expand Up @@ -204,6 +230,30 @@ impl FossaServer {
Self::to_result(&output)
}

/// Handle the `add` tool.
pub async fn handle_add(&self, command: AddCommand) -> Result<CallToolResult, McpError> {
let output = run_add(&self.client, command)
.await
.map_err(Self::to_mcp_error)?;
Self::to_result(&output)
}

/// Handle the `remove` tool.
pub async fn handle_remove(&self, command: RemoveCommand) -> Result<CallToolResult, McpError> {
let output = run_remove(&self.client, command)
.await
.map_err(Self::to_mcp_error)?;
Self::to_result(&output)
}

/// Handle the `move` tool.
pub async fn handle_move(&self, command: MoveCommand) -> Result<CallToolResult, McpError> {
let output = run_move(&self.client, command)
.await
.map_err(Self::to_mcp_error)?;
Self::to_result(&output)
}

/// Handle the `unignore` tool.
pub async fn handle_unignore(
&self,
Expand Down Expand Up @@ -290,6 +340,21 @@ impl ServerHandler for FossaServer {
.map_err(|e| Self::describe_args_error(e, &args))?;
self.handle_unignore(command).await
}
"add" => {
let command: AddCommand = serde_json::from_value(args.clone())
.map_err(|e| Self::describe_args_error(e, &args))?;
self.handle_add(command).await
}
"remove" => {
let command: RemoveCommand = serde_json::from_value(args.clone())
.map_err(|e| Self::describe_args_error(e, &args))?;
self.handle_remove(command).await
}
"move" => {
let command: MoveCommand = serde_json::from_value(args.clone())
.map_err(|e| Self::describe_args_error(e, &args))?;
self.handle_move(command).await
}
other => Err(McpError::invalid_params(
format!("Unknown tool: {other}"),
None,
Expand Down Expand Up @@ -324,7 +389,10 @@ mod tests {
.iter()
.map(|t| t.name.to_string())
.collect();
assert_eq!(names, ["get", "list", "update", "ignore", "unignore"]);
assert_eq!(
names,
["get", "list", "update", "ignore", "unignore", "add", "remove", "move"]
);
}

#[test]
Expand All @@ -334,6 +402,9 @@ mod tests {
assert!(!FossaServer::schema::<UpdateCommand>().is_empty());
assert!(!FossaServer::schema::<IgnoreCommand>().is_empty());
assert!(!FossaServer::schema::<UnignoreCommand>().is_empty());
assert!(!FossaServer::schema::<AddCommand>().is_empty());
assert!(!FossaServer::schema::<RemoveCommand>().is_empty());
assert!(!FossaServer::schema::<MoveCommand>().is_empty());
}

#[test]
Expand Down Expand Up @@ -386,6 +457,8 @@ mod tests {

let result = server
.handle_list(ListCommand::Projects(ListProjectsParams {
team: None,
no_team: false,
pagination: PageArgs::default(),
}))
.await
Expand Down Expand Up @@ -507,6 +580,8 @@ mod tests {

let _ = server
.handle_list(ListCommand::Projects(ListProjectsParams {
team: None,
no_team: false,
pagination: PageArgs {
page: Some(1),
count: Some(200),
Expand Down
4 changes: 4 additions & 0 deletions src/mock_server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mock_server/
├── projects.rs # GET/PUT /projects/:locator, GET /v2/projects
├── revisions.rs # GET /revisions/:locator, GET /projects/:locator/revisions
├── dependencies.rs # GET /v2/revisions/:locator/dependencies
├── teams.rs # GET /v2/teams, GET /projects/:locator/teams, PUT /teams/:id/projects
└── issues.rs # GET /v2/issues/:id, GET /v2/issues, PUT /v2/issues/
```

Expand Down Expand Up @@ -52,6 +53,9 @@ async fn test_workflow() {
| `/projects/:locator` | GET | get_project |
| `/projects/:locator` | PUT | update_project |
| `/v2/projects` | GET | list_projects |
| `/projects/:locator/teams` | GET | list_project_teams |
| `/v2/teams` | GET | list_teams |
| `/teams/:id/projects` | PUT | update_team_projects (add/remove; membership lives on `Project.teams`) |
| `/revisions/:locator` | GET | get_revision |
| `/projects/:locator/revisions` | GET | list_revisions |
| `/v2/revisions/:locator/dependencies` | GET | list_dependencies |
Expand Down
Loading
Loading