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
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A Model Context Protocol (MCP) server that exposes the Kosli API to LLM clients
## Architecture in one minute

- **`src/catalog.json`** is the source of truth for available API actions. It's generated by `scripts/generate-catalog.ts` (which fetches `https://app.kosli.com/api/v2/openapi.json`) and committed to the repo. Regenerate with `npm run generate-catalog` when Kosli's API changes. The generator inlines `$ref` schema references (`scripts/resolve-refs.ts`) — the catalog doesn't ship the spec's components section, so an unresolved `$ref` would be a dangling pointer the LLM can't follow. Output is written with a width-capped compact formatter (`scripts/format-json.ts`): structures that fit in 100 columns are inlined, larger ones expand.
- **`src/index.ts`** registers exactly three MCP tools. Don't add more tools per endpoint — keep the catalog-driven design.
- **`src/server.ts`** registers exactly three MCP tools; `src/index.ts` is the bin that builds one and connects stdio. Don't add more tools per endpoint — keep the catalog-driven design.
- **`src/tools/search-actions.ts`** does a cheap client-side scored keyword search over `catalog.searchText`. No index, no embedding — deliberately simple.
- **`src/tools/execute-action.ts`** + **`src/client/kosli-client.ts`** build the URL from path params, put remaining params into query string (GET/DELETE) or JSON body (other methods), and return the JSON response (or a structured error object — it does not throw on non-2xx). Both execute tools support an optional `fields` array that filters response objects to only the requested keys via `pickFields` before returning — this is critical for token efficiency.
- **`src/config.ts`** loads env vars. `KOSLI_API_TOKEN` is preferred; `KOSLI_API_KEY` is a backwards-compat fallback.
Expand All @@ -26,7 +26,10 @@ A Model Context Protocol (MCP) server that exposes the Kosli API to LLM clients
- Commit messages and PR titles follow [Conventional Commits](https://www.conventionalcommits.org/) format (e.g. `feat:`, `fix:`, `docs:`, `ci:`, `refactor:`, `test:`, `chore:`).
- ESM only (`"type": "module"`, `NodeNext` resolution). Relative imports must use `.js` extensions even though the source is `.ts`.
- TypeScript `strict: true`. Don't weaken it.
- The `org` path parameter falls back to `config.org` (from `KOSLI_ORG`). Preserve this in `KosliClient.buildUrl`.
- There is no configured org. Every action whose path contains `{org}` must be told one per call, so no request lands in an organization the caller did not name. `KosliClient.buildUrl` treats `org` as an ordinary path parameter; don't reintroduce a fallback.
- A per-call org is decided in one place, `resolveOrg` in `src/tools/execute-action.ts`. Registration lives in `createServer` (`src/server.ts`) so a test can drive the real tools over an in-memory transport; `index.ts` is only the bin that connects stdio. Which parameter counts as the org is decided once in `src/org.ts`, because three places have to agree: the check in `resolveOrg`, the strip in `search_actions`, and the key `executeAction` writes the resolved org under. `buildUrl` is generic and reads whatever key the parameter is named. The `org` tool input and `params.org` obey the same rules: trimmed, only a string names one, `null` means not supplied, blank rejected, an org on a non-org-scoped action rejected, and two different names rejected rather than resolved. The body is not a third channel: a body naming `org` is refused by that rule.
- A request body may not name any of the action's own parameters. `unwrapBodyParam` flattens a body over the top level, so such a field would overwrite the caller's value and redirect the request while the approval prompt still showed what they typed. `test/catalog.test.ts` pins that no catalog body declares one.
- `search_actions` strips the `org` path parameter from what it returns. The server supplies it, and advertising it as required invites the model to put it in `params` instead of the tool's own `org` input. `PUT /user/{org}` is exempt: there the org is what the call writes, so the caller has to choose it.
- Errors from the Kosli API are returned as `{ error: true, status, statusText, message }` — not thrown. Tools stringify whatever they get. Keep this contract; the LLM handles the error object.
- Responses are serialized with `JSON.stringify(result)` (compact, no pretty-printing) to minimize token usage. Don't revert to pretty-printing.
- All API requests include `User-Agent: kosli-mcp-server/<version>` for server-side tracking, with the version coming from `VERSION` in `src/version.ts`. Preserve the header and keep the version in it — the backend uses it to tell releases apart.
Expand Down
54 changes: 44 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ The server reads configuration from environment variables:
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `KOSLI_API_TOKEN` | yes | — | Preferred. `KOSLI_API_KEY` is accepted as a fallback. |
Comment thread
AlexKantor87 marked this conversation as resolved.
| `KOSLI_ORG` | yes | — | Default org used when a path param `org` is not supplied. |
| `KOSLI_BASE_URL` | no | `https://app.kosli.com` | EU (default), US (`https://app.us.kosli.com`), or your single-tenant endpoint. |

## Wire up to an MCP client
Expand All @@ -44,13 +43,12 @@ Run this from your project directory (or use `--scope user` for global):
```bash
claude mcp add kosli \
-e KOSLI_API_TOKEN=your-token \
-e KOSLI_ORG=your-org \
-- npx -y @kosli/mcp-server
```

### Claude Desktop (Desktop Extension)

Download the latest `.mcpb` file from [Releases](https://github.com/kosli-dev/mcp-server/releases), then drag it into Claude Desktop or double-click to install. Claude Desktop will prompt you for your API token and organization. This is the recommended method for Claude Desktop as secrets are stored in the OS keychain rather than in a plain-text config file.
Download the latest `.mcpb` file from [Releases](https://github.com/kosli-dev/mcp-server/releases), then drag it into Claude Desktop or double-click to install. Claude Desktop will prompt you for your API token. This is the recommended method for Claude Desktop as secrets are stored in the OS keychain rather than in a plain-text config file.

> [!NOTE]
> When installing from a `.mcpb` file, Claude Desktop shows a warning that the extension has not been verified by Anthropic. This is expected for any extension installed from a file rather than from the built-in directory. Sideloaded extensions also do not auto-update — you'll need to download and reinstall new versions manually. Both of these limitations go away once the extension is listed in Anthropic's [Connectors Directory](https://claude.com/docs/connectors/building/submission).
Expand All @@ -66,8 +64,7 @@ Alternatively, add the following to your `claude_desktop_config.json` (Settings
"command": "npx",
"args": ["-y", "@kosli/mcp-server"],
"env": {
"KOSLI_API_TOKEN": "your-token",
"KOSLI_ORG": "your-org"
"KOSLI_API_TOKEN": "your-token"
}
}
}
Expand All @@ -76,7 +73,7 @@ Alternatively, add the following to your `claude_desktop_config.json` (Settings

### Other MCP clients

The server communicates over stdio. Point any MCP-compatible client at the package via `npx -y @kosli/mcp-server` and set the `KOSLI_API_TOKEN` and `KOSLI_ORG` environment variables.
The server communicates over stdio. Point any MCP-compatible client at the package via `npx -y @kosli/mcp-server` and set the `KOSLI_API_TOKEN` environment variable.

### Local checkout

Expand All @@ -89,8 +86,7 @@ If you're running from a local checkout instead:
"command": "node",
"args": ["/absolute/path/to/mcp-server/dist/index.js"],
"env": {
"KOSLI_API_TOKEN": "your-token",
"KOSLI_ORG": "your-org"
"KOSLI_API_TOKEN": "your-token"
}
}
}
Expand All @@ -107,7 +103,43 @@ Typical LLM flow:
> [!IMPORTANT]
> `execute_write_action` creates, modifies, and deletes real resources in your Kosli organization. MCP clients gate these calls behind an approval prompt — read the action ID and parameters before approving. An LLM may select the wrong action, or the right action with the wrong parameters, and approval is the only checkpoint before the call is made. Treat deletions and anything touching service accounts or API keys with particular care.

The `org` path parameter defaults to `KOSLI_ORG` if not supplied. For `GET`/`DELETE`, non-path params become query parameters; for other methods they become the JSON body.
For `GET`/`DELETE`, non-path params become query parameters; for other methods they become the JSON body.

### Naming the organization

Both execute tools take an `org`, so one running server reaches any organization
your token has access to:

```json
{ "actionId": "list_environments", "org": "cyber-dojo", "fields": ["name"] }
```

It is required for every action whose path contains `{org}`, which is all but
four of them. There is no configured default: a call that names no org is
refused rather than sent to one nobody chose.

`KOSLI_ORG` used to supply that default and has been removed. A config that
still sets it starts fine, the value is ignored, and the server says so on
stderr.

An org can also arrive as `params.org`. The two must agree: two different names
are rejected rather than resolved, since the same tools perform writes.

A write's request body may not name any of the action's own parameters, `org`
included. Such a field would overwrite what you supplied and send the request
somewhere else, while the approval prompt still showed your value.

The org must be one name, given as a non-empty string. Anything else is rejected
rather than coerced, because coercing invents a name: `["cyber-dojo",
"kosli-public"]` would request the org `cyber-dojo,kosli-public`.

Four actions are not organization-scoped: `get_user_default_org`,
`list_system_attestation_types`, and the two `/schemas/...` actions. Passing an
org to one of those is rejected rather than ignored.

An org your token cannot reach returns the usual error object with the API's
status, `403` for both a non-member and a non-existent org. Kosli staff accounts
can read any org, so a successful read does not prove a write would be allowed.

Both execute tools accept an optional `fields` array to request only specific top-level fields from each object in the response. This dramatically reduces response size and token usage:

Expand Down Expand Up @@ -141,7 +173,9 @@ npm run pack:mcpb # build a .mcpb bundle for Claude Desktop

```
src/
index.ts # MCP server entry point (stdio transport)
index.ts # bin: builds a server and connects stdio
org.ts # which parameter is the org, shared by three places
server.ts # registers the three MCP tools
config.ts # env-var loading
types.ts # shared types (CatalogEntry, Config, …)
catalog.json # generated action catalog
Expand Down
2 changes: 1 addition & 1 deletion docs/future/cli-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ Until any of that is real, the API MCP (plus the multipart gap above, if it beco
## What explicitly doesn't carry over

- The OpenAPI catalog generator — CLI commands don't correspond 1:1 to API endpoints (one CLI command often calls several endpoints plus local work).
- The `org` fallback from env — the CLI has its own profile logic; don't double-configure.
- Any notion of a configured org — the MCP server requires one per call, and the CLI has its own profile logic.
7 changes: 0 additions & 7 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"args": ["${__dirname}/dist/index.js"],
"env": {
"KOSLI_API_TOKEN": "${user_config.api_token}",
"KOSLI_ORG": "${user_config.org}",
"KOSLI_BASE_URL": "${user_config.base_url}"
}
}
Expand Down Expand Up @@ -54,12 +53,6 @@
"sensitive": true,
"required": true
},
"org": {
"type": "string",
"title": "Organization",
"description": "Your Kosli organization name",
"required": true
},
"base_url": {
"type": "string",
"title": "Base URL",
Expand Down
7 changes: 1 addition & 6 deletions src/client/kosli-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,7 @@ export class KosliClient {
for (const param of entry.parameters) {
if (param.in !== "path") continue;

let value: unknown;
if (param.name === "org") {
value = params.org ?? this.config.org;
} else {
value = params[param.name];
}
const value = params[param.name];

if (value === undefined && param.required) {
return {
Expand Down
11 changes: 7 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ export function loadConfig(): Config {
throw new Error("KOSLI_API_TOKEN (or KOSLI_API_KEY) environment variable is required");
}

const org = process.env.KOSLI_ORG;
if (!org) {
throw new Error("KOSLI_ORG environment variable is required");
// Removed in favour of naming the org per call. Say so rather than letting a
// stale config turn into a refusal the user cannot connect to anything.
if (process.env.KOSLI_ORG) {
console.error(
"KOSLI_ORG is set but no longer used: the org is named per call now, and this value is ignored.",
);
}

const baseUrl = process.env.KOSLI_BASE_URL || "https://app.kosli.com";
Expand All @@ -24,5 +27,5 @@ export function loadConfig(): Config {
);
}

return { apiKey, org, baseUrl };
return { apiKey, baseUrl };
}
102 changes: 3 additions & 99 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,11 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { loadConfig } from "./config.js";
import { searchActions } from "./tools/search-actions.js";
import { executeAction } from "./tools/execute-action.js";
import catalog from "./catalog.json" with { type: "json" };
import hints from "./hints.json" with { type: "json" };
import type { CatalogEntry, ActionHints } from "./types.js";
import { VERSION } from "./version.js";

const entries = catalog as CatalogEntry[];
const config = loadConfig();

const server = new McpServer({
name: "kosli",
version: VERSION,
});

server.registerTool(
"search_actions",
{
title: "Search actions",
description: `Search for available Kosli API actions by natural-language query. Returns matching actions with their IDs, descriptions, and parameter schemas. Use this to discover what actions are available before calling execute_read_action or execute_write_action. The configured Kosli org is "${config.org}".`,
annotations: {
readOnlyHint: true,
},
inputSchema: {
query: z.string().describe("Natural-language search query (e.g. 'list environments', 'get trail', 'search artifacts')"),
limit: z.number().optional().default(10).describe("Maximum number of results to return"),
},
},
async ({ query, limit }) => {
const results = searchActions(entries, query, limit, hints as ActionHints);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(results),
},
],
};
},
);

server.registerTool(
"execute_read_action",
{
title: "Execute read action",
description: `Execute a read-only (GET) Kosli API action by its ID with the given parameters. Use search_actions first to find the action ID and required parameters. The 'org' parameter defaults to "${config.org}" — you do not need to supply it unless querying a different organization.`,
annotations: {
readOnlyHint: true,
},
inputSchema: {
actionId: z.string().describe("The action ID from search_actions results"),
params: z.record(z.string(), z.unknown()).optional().default({}).describe("Parameters for the action (path params or query params)"),
fields: z.array(z.string()).optional().describe("Only include these fields in each object of the response. Dramatically reduces response size. Example: [\"name\",\"compliant\",\"fingerprint\",\"reasons_for_incompliance\"]"),
},
},
async ({ actionId, params, fields }) => {
const result = await executeAction(entries, config, actionId, params, fields, undefined, "GET");
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result),
},
],
};
},
);

server.registerTool(
"execute_write_action",
{
title: "Execute write action",
description: `Execute a write (POST, PUT, PATCH, DELETE) Kosli API action by its ID with the given parameters. Use search_actions first to find the action ID and required parameters. The 'org' parameter defaults to "${config.org}" — you do not need to supply it unless querying a different organization.`,
annotations: {
destructiveHint: true,
readOnlyHint: false,
},
inputSchema: {
actionId: z.string().describe("The action ID from search_actions results"),
params: z.record(z.string(), z.unknown()).optional().default({}).describe("Parameters for the action (path params, query params, or body)"),
fields: z.array(z.string()).optional().describe("Only include these fields in each object of the response. Dramatically reduces response size."),
},
},
async ({ actionId, params, fields }) => {
const result = await executeAction(entries, config, actionId, params, fields, undefined, "WRITE");
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result),
},
],
};
},
);
import { createServer } from "./server.js";

async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
const server = createServer(loadConfig());
await server.connect(new StdioServerTransport());
}

main().catch((err) => {
Expand Down
27 changes: 27 additions & 0 deletions src/org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ActionParam, CatalogEntry } from "./types.js";

/**
* Three places have to agree on which parameter the org is: the check in
* resolveOrg, the strip in search-actions, and the key executeAction writes the
* resolved value under. buildUrl is generic and reads whatever key the
* parameter is named. Widen this and forget that key and the call dies with
* "Missing required path parameter", which is why it is decided once here.
*/
export function isOrgPathParam(param: ActionParam): boolean {
return param.name === "org" && param.in === "path";
}

export function takesOrg(entry: CatalogEntry): boolean {
return entry.parameters.some(isOrgPathParam);
}

/**
* `PUT /user/{org}` sets the caller's default org, so its `{org}` is the thing
* being written, not the scope it is written in. It is the one action whose org
* the caller must choose, so search must keep advertising it. Matched on path
* rather than id: the id has already changed once (`put_user_default_org` to
* `set_user_default_org`) while the path stayed put.
*/
export function orgIsTheArgument(entry: CatalogEntry): boolean {
return entry.path === "/user/{org}";
Comment thread
AlexKantor87 marked this conversation as resolved.
}
Loading
Loading