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
128 changes: 113 additions & 15 deletions src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { type Config, resolveProductBaseUrl } from "../config.js";
import type { HarnessClient } from "../client/harness-client.js";
import { HarnessApiError } from "../utils/errors.js";
import type { ResourceDefinition, ToolsetDefinition, ToolsetName, OperationName, EndpointSpec, FilterFieldSpec } from "./types.js";
import type { ResourceDefinition, ToolsetDefinition, ToolsetName, OperationName, EndpointSpec, FilterFieldSpec, ResourceScope } from "./types.js";
import type { AuditManager } from "../audit/manager.js";
import type { AuditContext, AuditEvent } from "../audit/types.js";
import { createLogger } from "../utils/logger.js";
Expand Down Expand Up @@ -47,12 +47,75 @@ const log = createLogger("registry");

/** Keys under which different Harness APIs return list arrays. */
const LIST_ARRAY_KEYS = ["items", "features", "content", "data", "objects"];
const RESOURCE_SCOPES: readonly ResourceScope[] = ["account", "org", "project"];

/** Backward-compatible aliases for renamed public toolset names. */
const TOOLSET_ALIASES: Record<string, string> = {
"agent-pipelines": "agents",
};

function isResourceScope(value: unknown): value is ResourceScope {
return typeof value === "string" && RESOURCE_SCOPES.includes(value as ResourceScope);
}

function getSupportedScopes(def: ResourceDefinition): readonly ResourceScope[] {
if (def.supportedScopes?.length) {
return def.supportedScopes;
}
return [def.scope];
}

function getRequestedScope(def: ResourceDefinition, input: Record<string, unknown>): ResourceScope | undefined {
const value = input.resource_scope;
if (value === undefined || value === "") {
return undefined;
}
if (!isResourceScope(value)) {
throw new Error(`Invalid resource_scope "${String(value)}". Expected one of: ${RESOURCE_SCOPES.join(", ")}`);
}
const supported = getSupportedScopes(def);
if (!supported.includes(value)) {
throw new Error(
`${def.resourceType} does not support ${value} scope. Supported scopes: ${supported.join(", ")}`,
);
}
return value;
}

function shouldUseOrg(scope: ResourceScope): boolean {
return scope === "org" || scope === "project";
}

function shouldUseProject(scope: ResourceScope): boolean {
return scope === "project";
}

interface ExplicitScopeValues {
orgId?: string;
projectId?: string;
}

function resolveScopeString(value: unknown, fallback: string | undefined): string | undefined {
if (typeof value === "string" && value !== "") {
return value;
}
return fallback && fallback !== "" ? fallback : undefined;
}

function getExplicitScopeValues(scope: ResourceScope, input: Record<string, unknown>, config: Config): ExplicitScopeValues {
const orgId = resolveScopeString(input.org_id, config.HARNESS_ORG);
const projectId = resolveScopeString(input.project_id, config.HARNESS_PROJECT);

if (shouldUseOrg(scope) && !orgId) {
throw new Error(`resource_scope "${scope}" requires org_id or HARNESS_ORG.`);
}
if (shouldUseProject(scope) && !projectId) {
throw new Error(`resource_scope "${scope}" requires project_id or HARNESS_PROJECT.`);
}

return { orgId, projectId };
}

const ALL_TOOLSETS: ToolsetDefinition[] = [
pipelinesToolset,
agentsToolset,
Expand Down Expand Up @@ -241,6 +304,11 @@ export class Registry {
return this.getAllResourceTypes().filter(rt => this.supportsOperation(rt, operation));
}

/** Get scopes supported by a resource for explicit resource_scope selection. */
getSupportedScopes(resourceType: string): readonly ResourceScope[] {
return getSupportedScopes(this.getResource(resourceType));
}

/** Get resource types that have at least one execute action. */
getTypesWithExecuteActions(): string[] {
return this.getAllResourceTypes().filter(rt => {
Expand Down Expand Up @@ -389,6 +457,7 @@ export class Registry {
httpStatus?: number,
): void {
if (!this.auditManager) return;
const auditScope = isResourceScope(input.resource_scope) ? input.resource_scope : undefined;

// Resolve path using pathBuilder if present, otherwise use static path
const resolvedPath = spec.pathBuilder
Expand All @@ -403,12 +472,20 @@ export class Registry {
resource_type: resourceType,
resource_id: auditCtx?.resource_id ?? (input.resource_id as string | undefined),
action: auditCtx?.action,
org_id: def.scope === "account"
? undefined
: (input.org_id as string | undefined) ?? (def.scopeOptional ? undefined : this.config.HARNESS_ORG),
project_id: def.scope === "account" || def.scope === "org"
? undefined
: (input.project_id as string | undefined) ?? (def.scopeOptional ? undefined : this.config.HARNESS_PROJECT),
org_id: auditScope
? shouldUseOrg(auditScope)
? (input.org_id as string | undefined) ?? this.config.HARNESS_ORG
: undefined
: def.scope === "account"
? undefined
: (input.org_id as string | undefined) ?? (def.scopeOptional ? undefined : this.config.HARNESS_ORG),
project_id: auditScope
? shouldUseProject(auditScope)
? (input.project_id as string | undefined) ?? this.config.HARNESS_PROJECT
: undefined
: def.scope === "account" || def.scope === "org"
? undefined
: (input.project_id as string | undefined) ?? (def.scopeOptional ? undefined : this.config.HARNESS_PROJECT),
account_id: this.getAccountId(),
risk: spec.operationPolicy?.risk ?? "read",
confirmation: auditCtx?.confirmation,
Expand All @@ -432,12 +509,23 @@ export class Registry {
): Promise<unknown> {
const resolvedAccountId = this.getAccountId();
const resolvedConfig: Config = { ...this.config, HARNESS_ACCOUNT_ID: resolvedAccountId };
const requestedScope = getRequestedScope(def, input);
const explicitScopeValues = requestedScope ? getExplicitScopeValues(requestedScope, input, this.config) : undefined;
const pathDefaultScope = requestedScope ?? def.scope;

// Run preflight hook (e.g. duplicate-check before create) before hitting the API.
if (spec.preflight) {
await spec.preflight({ client, input, registry: this, signal });
}

// When explicit resource_scope resolved org/project from config defaults,
// merge them into input so pathBuilder functions see the effective values.
// Only inject for scopes that actually use those params.
if (requestedScope && explicitScopeValues) {
if (shouldUseOrg(requestedScope) && explicitScopeValues.orgId && !input.org_id) input = { ...input, org_id: explicitScopeValues.orgId };
if (shouldUseProject(requestedScope) && explicitScopeValues.projectId && !input.project_id) input = { ...input, project_id: explicitScopeValues.projectId };
}

// Build path with substitutions (or pathBuilder when present)
let path: string;
if (spec.pathBuilder) {
Expand All @@ -449,15 +537,16 @@ export class Registry {
let value = input[inputKey];
Comment thread
cursor[bot] marked this conversation as resolved.
if (value === undefined || value === "") {
// Default scope placeholders from config for project/org-scoped resources
if (pathPlaceholder === "org" && (def.scope === "project" || def.scope === "org")) {
value = this.config.HARNESS_ORG;
} else if (pathPlaceholder === "project" && def.scope === "project") {
value = this.config.HARNESS_PROJECT;
if (pathPlaceholder === "org" && shouldUseOrg(pathDefaultScope)) {
value = explicitScopeValues?.orgId ?? this.config.HARNESS_ORG;
} else if (pathPlaceholder === "project" && shouldUseProject(pathDefaultScope)) {
value = explicitScopeValues?.projectId ?? this.config.HARNESS_PROJECT;
}
}
if (value === undefined || value === "") {
const scopeHint = def.scopeOptional
? ` This resource supports account/org/project scope — pass "${inputKey}" via params, or use a Harness URL.`
const supportedScopes = getSupportedScopes(def);
const scopeHint = supportedScopes.length > 1
? ` This resource supports ${supportedScopes.join("/")} scope — pass "${inputKey}" via params, set resource_scope appropriately, or use a Harness URL.`
: "";
throw new Error(
`Missing required field "${inputKey}" for ${def.resourceType}.${scopeHint}`,
Expand All @@ -476,8 +565,16 @@ export class Registry {
// Otherwise, fall back to config defaults based on the resource's scope level.
const orgParam = def.scopeParams?.org ?? "orgIdentifier";
const projectParam = def.scopeParams?.project ?? "projectIdentifier";
if (def.scopeOptional) {
// Dynamic scoping: only inject when caller explicitly provides them
if (requestedScope) {
// Explicit resource scoping: account omits org/project, org injects org only, project injects both.
if (shouldUseOrg(requestedScope)) {
params[orgParam] = explicitScopeValues?.orgId;
}
if (shouldUseProject(requestedScope)) {
params[projectParam] = explicitScopeValues?.projectId;
}
} else if (def.scopeOptional) {
// Dynamic scoping: only inject when caller explicitly provides them.
if (input.org_id) {
params[orgParam] = input.org_id as string;
}
Expand Down Expand Up @@ -899,6 +996,7 @@ export class Registry {
displayName: r.displayName,
description: r.description,
scope: r.scope,
supportedScopes: getSupportedScopes(r).length > 1 ? getSupportedScopes(r) : undefined,
operations: Object.keys(r.operations),
executeActions: r.executeActions ? Object.keys(r.executeActions) : undefined,
identifierFields: r.identifierFields,
Expand Down
3 changes: 2 additions & 1 deletion src/registry/toolsets/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ export const connectorsToolset: ToolsetDefinition = {
{
resourceType: "connector",
displayName: "Connector",
description: "External integration connector. Supports full CRUD and test_connection.",
description: "External integration connector. Supports full CRUD and test_connection. Use resource_scope='account' to list or get account-level connectors.",
toolset: "connectors",
scope: "project",
supportedScopes: ["account", "org", "project"],
identifierFields: ["connector_id"],
diagnosticHint: "Use harness_diagnose with resource_id set to the connector identifier to run a live connectivity test and get auth method, status history, and error details.",
listFilterFields: [
Expand Down
3 changes: 2 additions & 1 deletion src/registry/toolsets/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ export const environmentsToolset: ToolsetDefinition = {
{
resourceType: "environment",
displayName: "Environment",
description: "Deployment target environment. Supports full CRUD.",
description: "Deployment target environment. Supports full CRUD. Use resource_scope='account' to list or get account-level environments.",
toolset: "environments",
scope: "project",
supportedScopes: ["account", "org", "project"],
identifierFields: ["environment_id"],
listFilterFields: [
{ name: "search_term", description: "Filter environments by name or keyword" },
Expand Down
3 changes: 2 additions & 1 deletion src/registry/toolsets/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ export const infrastructureToolset: ToolsetDefinition = {
{
resourceType: "infrastructure",
displayName: "Infrastructure Definition",
description: "Infrastructure definition within an environment. Supports full CRUD.",
description: "Infrastructure definition within an environment. Supports full CRUD. Use resource_scope='account' to list or get account-level infrastructure definitions.",
toolset: "infrastructure",
scope: "project",
supportedScopes: ["account", "org", "project"],
identifierFields: ["infrastructure_id"],
listFilterFields: [
{ name: "environment_id", description: "**Required.** Environment identifier — infrastructure is always scoped to an environment" },
Expand Down
3 changes: 2 additions & 1 deletion src/registry/toolsets/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ export const secretsToolset: ToolsetDefinition = {
{
resourceType: "secret",
displayName: "Secret",
description: "Secret metadata (name, type, scope). Values are NEVER returned. Read-only.",
description: "Secret metadata (name, type, scope). Values are NEVER returned. Read-only. Use resource_scope='account' to list or get account-level secret metadata.",
toolset: "secrets",
scope: "project",
supportedScopes: ["account", "org", "project"],
identifierFields: ["secret_id"],
listFilterFields: [
{ name: "search_term", description: "Filter secrets by name or keyword" },
Expand Down
3 changes: 2 additions & 1 deletion src/registry/toolsets/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ export const servicesToolset: ToolsetDefinition = {
{
resourceType: "service",
displayName: "Service",
description: "Deployable service/workload definition. Supports full CRUD.",
description: "Deployable service/workload definition. Supports full CRUD. Use resource_scope='account' to list or get account-level services.",
toolset: "services",
scope: "project",
supportedScopes: ["account", "org", "project"],
identifierFields: ["service_id"],
listFilterFields: [
{ name: "search_term", description: "Filter services by name or keyword" },
Expand Down
3 changes: 2 additions & 1 deletion src/registry/toolsets/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ export const templatesToolset: ToolsetDefinition = {
{
resourceType: "template",
displayName: "Template",
description: "Reusable template definition. Supports list, get, create, update, and delete.",
description: "Reusable template definition. Supports list, get, create, update, and delete. Use resource_scope='account' to list or get account-level templates.",
toolset: "templates",
scope: "project",
supportedScopes: ["account", "org", "project"],
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding supportedScopes here widens the documented resource_scope contract for all template operations, not just list/get. The runtime now honors that on writes too, but only through the generic params escape hatch: on this head I reproduced harness_update(resource_type="template", resource_id="my-template", params:{ resource_scope:"project", version_label:"v2" }) emitting the correct project-scoped v1 path.

The problem is that the registered write-tool schemas still do not expose a top-level resource_scope field at all. For example, harness_update only publishes resource_type, resource_id, url, body, org_id, project_id, and params. So harness_describe(resource_type="template") now tells agents to set resource_scope=..., but the only working path is to smuggle that field through params, which breaks the repo's structured-contract rule for agent-facing metadata.

I’d either add explicit resource_scope inputs to the write tools, or keep template supportedScopes/scope guidance limited to the read paths you actually want to advertise.

scopeOptional: true,
identifierFields: ["template_id"],
listFilterFields: [
Expand Down
10 changes: 8 additions & 2 deletions src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export type ToolsetName =
export type ProductName = "harness" | "fme";

export type OperationName = "list" | "get" | "create" | "update" | "delete";
export type ResourceScope = "project" | "org" | "account";

/**
* Lightweight field descriptor for body schemas.
Expand Down Expand Up @@ -312,8 +313,13 @@ export interface ResourceDefinition {
description: string;
/** Which toolset this resource belongs to (for HARNESS_TOOLSETS filtering) */
toolset: string;
/** Scope level: "project" | "org" | "account" */
scope: "project" | "org" | "account";
/** Default scope level: "project" | "org" | "account" */
scope: ResourceScope;
/**
* Scopes this resource can query when the caller passes `resource_scope`.
* If omitted, the resource supports only its default `scope`.
*/
supportedScopes?: readonly ResourceScope[];
/**
* When true, org/project params are only added if explicitly provided in input.
* Use for resources that support multiple scopes (e.g., Harness Code repos/PRs
Expand Down
8 changes: 8 additions & 0 deletions src/tools/harness-describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ export function registerDescribeTool(server: McpServer, registry: Registry): voi
if (args.resource_type) {
try {
const def = registry.getResource(args.resource_type);
const resourceScopes = registry.getSupportedScopes(args.resource_type);
const supportedScopes = resourceScopes.length > 1 ? resourceScopes : undefined;
return jsonResult({
resource_type: def.resourceType,
displayName: def.displayName,
description: def.description,
toolset: def.toolset,
scope: def.scope,
supportedScopes,
scopeHint: supportedScopes && supportedScopes.length > 1
? def.scopeOptional
? "Set resource_scope='account' for account-level data, resource_scope='org' for org-level data, or resource_scope='project' for project-level data. If resource_scope is omitted, org/project are only included when explicitly passed (no fallback to configured defaults)."
: "Set resource_scope='account' for account-level data, resource_scope='org' for org-level data, or resource_scope='project' for project-level data. If resource_scope is omitted, the resource uses its default scope and configured defaults."
: undefined,
identifierFields: def.identifierFields,
listFilterFields: def.listFilterFields,
operations: Object.entries(def.operations).map(([op, spec]) => ({
Expand Down
3 changes: 2 additions & 1 deletion src/tools/harness-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function registerGetTool(server: McpServer, registry: Registry, client: H
resource_type: resourceTypeSchema(gettableTypes).optional().describe("Resource type to retrieve. Auto-detected from url."),
resource_id: z.string().describe("Primary resource identifier. Auto-detected from url.").optional(),
url: z.string().describe("Harness UI URL — auto-extracts org, project, type, and ID").optional(),
resource_scope: z.enum(["account", "org", "project"]).optional().describe("Scope to query. Use account for account-level resources and to omit org/project defaults; org injects only org; project injects org+project. Auto-detected from url."),
org_id: z.string().describe("Organization identifier (overrides default)").optional(),
project_id: z.string().describe("Project identifier (overrides default)").optional(),
params: z.record(z.string(), z.unknown()).describe("Additional identifiers for nested resources. Call harness_describe for fields per resource_type.").optional(),
Expand All @@ -34,7 +35,7 @@ export function registerGetTool(server: McpServer, registry: Registry, client: H
async (args) => {
try {
const { params, ...rest } = args;
const input = applyUrlDefaults(rest as Record<string, unknown>, args.url);
const input = applyUrlDefaults(rest as Record<string, unknown>, args.url, { includeResourceScope: true });
const coercedParams = coerceRecord(params);
if (coercedParams) Object.assign(input, coercedParams);
const resourceType = asString(input.resource_type);
Expand Down
3 changes: 2 additions & 1 deletion src/tools/harness-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function registerListTool(server: McpServer, registry: Registry, client:
inputSchema: {
resource_type: resourceTypeSchema(listableTypes).optional().describe("Resource type to list. Auto-detected from url."),
url: z.string().describe("Harness UI URL — auto-extracts org, project, and type").optional(),
resource_scope: z.enum(["account", "org", "project"]).optional().describe("Scope to query. Use account for account-level resources and to omit org/project defaults; org injects only org; project injects org+project. Auto-detected from url."),
org_id: z.string().describe("Organization identifier (overrides default)").optional(),
project_id: z.string().describe("Project identifier (overrides default)").optional(),
page: z.number().describe("Page number, 0-indexed").default(0).optional(),
Expand All @@ -50,7 +51,7 @@ export function registerListTool(server: McpServer, registry: Registry, client:
async (args) => {
try {
const { params, filters, ...rest } = args;
const input = applyUrlDefaults(rest as Record<string, unknown>, args.url);
const input = applyUrlDefaults(rest as Record<string, unknown>, args.url, { includeResourceScope: true });
// Spread caller-supplied params (path identifiers) and filters into the input
// Use coerceRecord to handle LLMs that serialize objects as JSON strings
const coercedParams = coerceRecord(params);
Expand Down
Loading
Loading