-
Notifications
You must be signed in to change notification settings - Fork 0
feat: accept an org parameter on the execute tools #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c13197d
d0130ca
e275976
282abc3
30dba3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,11 @@ import { VERSION } from "./version.js"; | |||||
| const entries = catalog as CatalogEntry[]; | ||||||
| const config = loadConfig(); | ||||||
|
|
||||||
| // Shared by both execute tools so the two can't drift apart. | ||||||
| const ORG_INPUT = z.string().optional().describe( | ||||||
| `Kosli organization to run this call against, e.g. "cyber-dojo". Defaults to "${config.org}" (from KOSLI_ORG). It applies to this call only — the next call goes back to the default unless you set it again.`, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The one thing that stops the PR's headline benefit from landing in practice, and it's outside the files you touched. The stated win is that a write's target org shows up in the approval prompt because it is a top-level input. That only happens if the model actually fills this input. But { "name": "org", "in": "path", "required": true, "description": "Organization name" }So the documented flow — search first, then execute with the parameters you were given — tells the model that Both channels work, so nothing breaks. But the case the PR set out to fix — org visible in the write-approval prompt rather than buried in a params blob — is exactly the case that reverts to the old shape whenever the model follows the search output. Cheapest fix is a sentence here that names the competing signal, so the model doesn't have to resolve it by guessing:
Suggested change
The more thorough version is to have |
||||||
| ); | ||||||
|
|
||||||
| const server = new McpServer({ | ||||||
| name: "kosli", | ||||||
| version: VERSION, | ||||||
|
|
@@ -48,18 +53,19 @@ 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.`, | ||||||
| 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 call runs against the "${config.org}" organization unless you set 'org'.`, | ||||||
| annotations: { | ||||||
| readOnlyHint: true, | ||||||
| }, | ||||||
| inputSchema: { | ||||||
| actionId: z.string().describe("The action ID from search_actions results"), | ||||||
| org: ORG_INPUT, | ||||||
| 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"); | ||||||
| async ({ actionId, params, fields, org }) => { | ||||||
| const result = await executeAction(entries, config, actionId, params, fields, undefined, "GET", org); | ||||||
| return { | ||||||
| content: [ | ||||||
| { | ||||||
|
|
@@ -75,19 +81,20 @@ 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.`, | ||||||
| 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 call runs against the "${config.org}" organization unless you set 'org' — check it before approving, the write lands in whichever org it names.`, | ||||||
| annotations: { | ||||||
| destructiveHint: true, | ||||||
| readOnlyHint: false, | ||||||
| }, | ||||||
| inputSchema: { | ||||||
| actionId: z.string().describe("The action ID from search_actions results"), | ||||||
| org: ORG_INPUT, | ||||||
| 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"); | ||||||
| async ({ actionId, params, fields, org }) => { | ||||||
| const result = await executeAction(entries, config, actionId, params, fields, undefined, "WRITE", org); | ||||||
| return { | ||||||
| content: [ | ||||||
| { | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -56,6 +56,60 @@ function unwrapBodyParam( | |||||||||||||||||||||||||
| return { ...rest, ...(body as Record<string, unknown>) }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * The org a call is aimed at, as it will actually be used: trimmed, and | ||||||||||||||||||||||||||
| * `undefined` when nothing was supplied. `null` counts as nothing, matching | ||||||||||||||||||||||||||
| * `KosliClient.buildUrl`, which falls back to `config.org` on a nullish | ||||||||||||||||||||||||||
| * `params.org`. An empty string means "supplied, but unusable", which | ||||||||||||||||||||||||||
| * `orgError` turns into a rejection. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| function normalizeOrg(value: unknown): string | undefined { | ||||||||||||||||||||||||||
| if (value === undefined || value === null) return undefined; | ||||||||||||||||||||||||||
| // Only a string can name an org. `params` is a record of `unknown`, so the | ||||||||||||||||||||||||||
| // model can put anything here, and coercing it would turn a value that is not | ||||||||||||||||||||||||||
| // a name into one that looks like a name: `["a", "b"]` reads as the org "a,b" | ||||||||||||||||||||||||||
| // and an org id reads as an org called "1234". Both would then pass the | ||||||||||||||||||||||||||
| // agreement check below as a single name. Hand back the unusable marker | ||||||||||||||||||||||||||
| // instead and let orgError say so. | ||||||||||||||||||||||||||
| if (typeof value !== "string") return ""; | ||||||||||||||||||||||||||
| return value.trim(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+66
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That's exactly the shape a model reaches for when asked to list envs across two orgs, and it gets a 403 on a nonsense org rather than the "supply just one" message the PR added for it. The outcome is safe — no write lands in an unintended org, because no org has a comma in its name — so this is a diagnostics gap, not a correctness one. But
Suggested change
Worth widening the blank-org message slightly if you take it — "An empty org was given" reads oddly for
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken, and widened. Asking "is it an object" still coerces a number, and that case is worse. The org path parameter has no pattern in the catalog, so an org named Two follow-ons, in e275976. The verified: sed -n 91,105p src/tools/execute-action.ts search: grep -rnE "String(|Number(|Boolean(" src/ --include=*.ts Same coercion, other params. No other path param picks the organization, so a wrong one fails rather than succeeding somewhere unintended. Left alone. mutation: normalizeOrg returning String(value).trim() -> five "rejects ... rather than coercing it into the path" cases red; moving the unusable check after the conflict check -> "reports the unusable value, not a disagreement" red |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Check the orgs a call names, returning a message when the request cannot be | ||||||||||||||||||||||||||
| * honoured as written. This is the only place the target org is decided, so | ||||||||||||||||||||||||||
| * both execute tools behave identically and every way of naming one obeys the | ||||||||||||||||||||||||||
| * same rules. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * `named` holds the distinct orgs the call mentions, already normalized. There | ||||||||||||||||||||||||||
| * are three ways to name one and they must agree: the `org` argument, | ||||||||||||||||||||||||||
| * `params.org`, and — for a write — an `org` inside the request body, which | ||||||||||||||||||||||||||
| * `unwrapBodyParam` flattens over the top level. Two different names are | ||||||||||||||||||||||||||
| * rejected rather than resolved: the same code path performs writes, and | ||||||||||||||||||||||||||
| * guessing wrong would write to the wrong organization. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| function orgError(entry: CatalogEntry, named: string[]): string | undefined { | ||||||||||||||||||||||||||
| const takesOrg = entry.parameters.some((p) => p.name === "org" && p.in === "path"); | ||||||||||||||||||||||||||
| if (!takesOrg) { | ||||||||||||||||||||||||||
| if (named.length === 0) return undefined; | ||||||||||||||||||||||||||
| return `Action "${entry.id}" is not organization-scoped — it takes no org. Retry with no org in the org parameter, in params.org, or in the request body.`; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Before any disagreement: an unusable value is the thing to report, and | ||||||||||||||||||||||||||
| // reporting it as a nameless org disagreeing with a real one would send the | ||||||||||||||||||||||||||
| // caller to drop one of the two rather than to fix the bad value. | ||||||||||||||||||||||||||
| if (named.includes("")) { | ||||||||||||||||||||||||||
| return "The org must be a single organization name, given as a non-empty string. Check the org parameter, params.org, and any org in the request body, or omit all of them to use the configured default."; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (named.length > 1) { | ||||||||||||||||||||||||||
| const quoted = named.map((o) => `"${o}"`).join(" and "); | ||||||||||||||||||||||||||
| return `Conflicting orgs in one call: ${quoted}. The org parameter, params.org, and an org in the request body must agree — supply just one.`; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export async function executeAction( | ||||||||||||||||||||||||||
| catalog: CatalogEntry[], | ||||||||||||||||||||||||||
| config: Config, | ||||||||||||||||||||||||||
|
|
@@ -64,6 +118,7 @@ export async function executeAction( | |||||||||||||||||||||||||
| fields?: string[], | ||||||||||||||||||||||||||
| fetchFn: FetchFn = globalThis.fetch, | ||||||||||||||||||||||||||
| mode?: ToolMode, | ||||||||||||||||||||||||||
| org?: string, | ||||||||||||||||||||||||||
| ): Promise<unknown> { | ||||||||||||||||||||||||||
|
Comment on lines
113
to
122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor maintainability nit, pre-existing but worsened here: await executeAction(entries, config, actionId, params, fields, undefined, "GET", org);
executeAction(entries, config, actionId, params, { fields, mode: "GET", org })Not blocking, and out of scope if you'd rather keep the diff tight — but this is the second optional parameter added to the tail, so it's likely to come up again.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on the smell, declining for this PR. Reworking the signature touches both call sites and every test that reaches past Raising it as a follow-up issue. no mutation: this reply adds no test claim.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Raised as #64, along with the same blind spot in no mutation: this reply adds no test claim. |
||||||||||||||||||||||||||
| const entry = catalog.find((e) => e.id === actionId); | ||||||||||||||||||||||||||
| if (!entry) { | ||||||||||||||||||||||||||
|
|
@@ -84,8 +139,32 @@ export async function executeAction( | |||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Collect the org from every channel before deciding. `unwrapBodyParam` | ||||||||||||||||||||||||||
| // flattens a write's request body over the top level, so params.org has to be | ||||||||||||||||||||||||||
| // read before the unwrap and the body's org after it — otherwise the spread | ||||||||||||||||||||||||||
| // picks a winner and the disagreement is never seen. When the unwrap declines, | ||||||||||||||||||||||||||
| // `unwrapped` is `params` and the body's org is never read. That is correct: | ||||||||||||||||||||||||||
| // an unflattened body cannot reach the path, so there is nothing to check. | ||||||||||||||||||||||||||
| const beforeUnwrap = normalizeOrg(params.org); | ||||||||||||||||||||||||||
| const unwrapped = unwrapBodyParam(entry, params); | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The ordering is right and the test round-trip you added pins it. One seam worth a sentence, since the README now states the rule as an invariant. The body channel only exists when No safety problem, and I don't think it should change: the path org is still a value that was named and checked, and A line on the existing comment covers it:
Suggested change
…with the block above extended:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken as the comment you suggested, near enough. verified: sed -n 142,148p src/tools/execute-action.ts The class is a decline path that returns the params untouched. All five return the same object, so the comment covers every one: search: grep -n "return params" src/tools/execute-action.ts Left the README alone. No catalog action declares a parameter named no mutation: comment only, no behaviour changed. |
||||||||||||||||||||||||||
| const named = [...new Set( | ||||||||||||||||||||||||||
| [normalizeOrg(org), beforeUnwrap, normalizeOrg(unwrapped.org)] | ||||||||||||||||||||||||||
| .filter((o): o is string => o !== undefined), | ||||||||||||||||||||||||||
| )]; | ||||||||||||||||||||||||||
|
Comment on lines
+142
to
+153
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The read-before/read-after ordering is correct and the reasoning in the comment is exactly right — the spread really would pick a winner and hide the disagreement. Worth keeping. One thing to consider, though, about treating the body as a legitimate third channel rather than just a thing to cross-check. When the body is the only place naming an org, That's identical to pre-PR behaviour ( Since no catalog write body declares an
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declining. The dangerous case is the one this PR closes: a body org disagreeing with a named one. A body-only org still goes through the trim, blank and non-org-scoped checks. The guard added above means a body field called The class here is "a channel that decides the target without being checked". So I searched every place an org value is read: search: grep -rnE "(params|unwrapped|withOrg|config).org|normalizeOrg(|KOSLI_ORG" src/ Four channels reach line 131. All four pass through normalizeOrg first. mutation: add an |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const message = orgError(entry, named); | ||||||||||||||||||||||||||
| if (message) return { error: true, message }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // The checked value is the one that goes out, so buildUrl cannot see a | ||||||||||||||||||||||||||
| // different org from the one that was approved. With nothing named, the key | ||||||||||||||||||||||||||
| // is dropped rather than left as a null that would ride along as a stray | ||||||||||||||||||||||||||
| // query parameter. | ||||||||||||||||||||||||||
| const withOrg = { ...unwrapped }; | ||||||||||||||||||||||||||
| if (named.length === 0) delete withOrg.org; | ||||||||||||||||||||||||||
| else withOrg.org = named[0]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const client = new KosliClient(config, fetchFn); | ||||||||||||||||||||||||||
| const result = await client.execute(entry, unwrapBodyParam(entry, params)); | ||||||||||||||||||||||||||
| const result = await client.execute(entry, withOrg); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Never strip error shapes — pickFields would reduce them to {} | ||||||||||||||||||||||||||
| // and hide the failure from the LLM. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Content is right and worth recording — the ordering constraint around
unwrapBodyParamis exactly the kind of thing that gets silently undone by a refactor, so pinning it here is the correct move.Form is off for the file, though. Every other bullet in Conventions is one or two lines; this one is a ~12-line paragraph carrying six separate rules, and the ordering constraint — the load-bearing part — is the fifth sentence in. CLAUDE.md is read by agents that skim for the rule they need, so burying it costs the most where it matters most.
Same content, split so each rule is findable:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declining for this PR. Splitting the bullet is a readability change to a file this PR already rewrote twice, and it carries no behaviour.
The ordering constraint is the part worth finding, so I take the point about where it sits. Raising it with the rest of the CLAUDE.md tidy rather than adding a fifth commit here.
no mutation: this reply adds no test claim.