Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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: 7 additions & 0 deletions changelogs/v2.5.8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Improvements

- **Community services can now bundle several tools**: A single installed service connector can expose multiple related operations (each its own tool) that share one base URL, headers, and API key — instead of needing a separate connector, re-entered key, for every endpoint. Operations support URL path templates (e.g. `/repos/{owner}/{repo}/issues/{number}`) with per-argument placement (path, query, or JSON body) and connector-supplied static query parameters, so a connector can cover a real multi-endpoint API. Existing single-endpoint services keep working unchanged.

## Under the hood

- Extended the shared HTTP-service core (`@cairn/shared/chat/service-exec`) and the community registry schema with multi-operation support: `baseUrl` + `operations[]`, `{placeholder}` path templating, explicit `paramLocations` (path/query/body), and static per-operation `query` params. The desktop `custom_services` store gained `base_url`/`operations` columns (auto-migrated), and the tool builder/executor route each operation through the shared `resolveOperation` + `buildOperationRequest`. Desktop and mobile consume the identical shared core, so a connector behaves the same on both. Backward-compatible: legacy single-op services normalize to a one-operation service internally.
17 changes: 11 additions & 6 deletions electron/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,8 +1094,9 @@ export function deleteMcpServer(db: Database.Database, id: string) {

interface CustomServiceInput {
id: string; workspaceId: string; name: string; description?: string;
apiUrl: string; method: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record<string, string>;
toolDefinition: string; responseKeys?: string[]; apiKeyUrl?: string;
apiUrl?: string; method?: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record<string, string>;
toolDefinition?: string; responseKeys?: string[]; apiKeyUrl?: string;
baseUrl?: string; operations?: unknown[];
authMode?: "none" | "oauth";
oauth?: { serverUrl?: string; scope?: string; clientId?: string; authorizationUrl?: string; tokenUrl?: string };
enabled: boolean; source: string; communityId?: string; version?: string;
Expand All @@ -1114,15 +1115,17 @@ export function getCustomServiceById(db: Database.Database, id: string) {
export function saveCustomService(db: Database.Database, s: CustomServiceInput) {
const now = ts();
db.prepare(`
INSERT INTO custom_services (id, workspace_id, name, description, api_url, method, headers, tool_definition, response_keys, api_key_url, enabled, source, community_id, version, auth_mode, oauth_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO custom_services (id, workspace_id, name, description, api_url, method, headers, tool_definition, base_url, operations, response_keys, api_key_url, enabled, source, community_id, version, auth_mode, oauth_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
api_url = excluded.api_url,
method = excluded.method,
headers = excluded.headers,
tool_definition = excluded.tool_definition,
base_url = excluded.base_url,
operations = excluded.operations,
response_keys = excluded.response_keys,
api_key_url = excluded.api_key_url,
enabled = excluded.enabled,
Expand All @@ -1133,8 +1136,10 @@ export function saveCustomService(db: Database.Database, s: CustomServiceInput)
oauth_config = excluded.oauth_config,
updated_at = excluded.updated_at
`).run(
s.id, s.workspaceId, s.name, s.description ?? null, s.apiUrl, s.method,
j(s.headers ?? {}), s.toolDefinition, j(s.responseKeys ?? []), s.apiKeyUrl ?? null,
s.id, s.workspaceId, s.name, s.description ?? null, s.apiUrl ?? "", s.method ?? "GET",
j(s.headers ?? {}), s.toolDefinition ?? "", s.baseUrl ?? null,
s.operations ? JSON.stringify(s.operations) : null,
j(s.responseKeys ?? []), s.apiKeyUrl ?? null,
s.enabled ? 1 : 0, s.source, s.communityId ?? null, s.version ?? null,
s.authMode ?? "none", s.oauth ? JSON.stringify(s.oauth) : null, now, now,
);
Expand Down
6 changes: 6 additions & 0 deletions electron/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,8 @@ const MIGRATIONS: Migration[] = [
method TEXT NOT NULL DEFAULT 'GET',
headers TEXT NOT NULL DEFAULT '{}',
tool_definition TEXT NOT NULL,
base_url TEXT,
operations TEXT,
response_keys TEXT NOT NULL DEFAULT '[]',
api_key_url TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
Expand Down Expand Up @@ -805,6 +807,10 @@ function ensureColumns(db: Database.Database): void {
ensure("chat_messages", "subagents", "subagents TEXT");
ensure("custom_services", "auth_mode", "auth_mode TEXT NOT NULL DEFAULT 'none'");
ensure("custom_services", "oauth_config", "oauth_config TEXT");
// Multi-operation services: baseUrl + operations[] (JSON). Nullable — legacy
// single-op rows leave them empty and use api_url/method/tool_definition.
ensure("custom_services", "base_url", "base_url TEXT");
ensure("custom_services", "operations", "operations TEXT");
}

function runMigrations(db: Database.Database): void {
Expand Down
148 changes: 12 additions & 136 deletions electron/lib/community-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,57 +17,20 @@

import fs from "fs";
import path from "path";
import * as z from "zod";
import { findUserDataDir } from "../runtime/port-discovery";
import { parseManifest, type CommunityManifest } from "../../shared/chat/registry-schema";

// Manifest TYPES + Zod validation now live in shared/chat/registry-schema.ts so
// desktop and mobile validate the catalog identically. Re-export the types so
// existing electron importers keep resolving them from here.
export type {
CommunityManifest,
RegistryMcpEntry,
RegistryServiceEntry,
RegistryEntryMeta,
} from "../../shared/chat/registry-schema";
export { parseManifest } from "../../shared/chat/registry-schema";

// Local types (main-process side mirrors src/types/index.ts — the two sides are
// wired by IPC channel strings, not a shared import; see preload.ts convention).
export interface CommunityManifest {
version: number;
updatedAt: string;
mcpServers: RegistryMcpEntry[];
services: RegistryServiceEntry[];
}
interface RegistryEntryMeta {
id: string;
author: string;
version: string;
category?: string;
tags: string[];
blurb: string;
brandColor?: string;
homepage?: string;
/** Brand logo, compiled + allowlist-sanitized by cairn-community CI. */
iconSvg?: string;
}
interface RegistryMcpEntry extends RegistryEntryMeta {
definition: {
name: string;
description?: string;
transport: "sse" | "http";
baseUrl: string;
headers?: Record<string, string>;
authMode?: "none" | "oauth";
oauthScope?: string;
disabledTools?: string[];
enabled: boolean;
};
}
interface RegistryServiceEntry extends RegistryEntryMeta {
definition: {
name: string;
description?: string;
apiUrl: string;
method: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
toolDefinition: string;
responseKeys?: string[];
apiKeyUrl?: string;
authMode?: "none" | "oauth";
oauth?: { serverUrl?: string; scope?: string; clientId?: string; authorizationUrl?: string; tokenUrl?: string };
enabled: boolean;
};
}
export interface RegistryFetchResult {
manifest: CommunityManifest;
fromCache: boolean;
Expand All @@ -80,93 +43,6 @@ const MANIFEST_URL =
const CACHE_FILE = "community-registry.json";
const FETCH_TIMEOUT_MS = 10_000;

// ── validation (mirrors cairn-community/schema.json) ────────────────────────

const headers = z.record(z.string(), z.string()).optional();

const mcpDefinition = z.object({
name: z.string().min(1),
description: z.string().optional(),
transport: z.enum(["sse", "http"]),
baseUrl: z.string().url().startsWith("https://"),
headers,
authMode: z.enum(["none", "oauth"]).optional(),
oauthScope: z.string().optional(),
disabledTools: z.array(z.string()).optional(),
enabled: z.boolean(),
});

const oauthConfig = z
.object({
serverUrl: z.string().url().startsWith("https://").optional(),
scope: z.string().optional(),
clientId: z.string().optional(),
authorizationUrl: z.string().url().startsWith("https://").optional(),
tokenUrl: z.string().url().startsWith("https://").optional(),
})
.optional();

const serviceDefinition = z.object({
name: z.string().min(1),
description: z.string().optional(),
apiUrl: z.string().url().startsWith("https://"),
method: z.enum(["GET", "POST", "PUT", "DELETE"]),
headers,
toolDefinition: z.string().min(1),
responseKeys: z.array(z.string()).optional(),
apiKeyUrl: z.string().url().startsWith("https://").optional(),
authMode: z.enum(["none", "oauth"]).optional(),
oauth: oauthConfig,
enabled: z.boolean(),
});

const entryMeta = {
id: z.string(),
author: z.string(),
version: z.string(),
category: z.string().optional(),
tags: z.array(z.string()),
blurb: z.string(),
brandColor: z.string().optional(),
// Validated as an https URL — it is rendered as an anchor href in the Browse
// modal, so an unvalidated string could smuggle a javascript:/data: URI.
homepage: z.string().url().startsWith("https://").optional(),
// Brand logo, compiled + allowlist-sanitized by cairn-community CI (never raw
// user SVG). Rendered inline by ConnectorLogo. Absent → app fallback glyph.
iconSvg: z.string().optional(),
};

const mcpEntry = z.object({ ...entryMeta, definition: mcpDefinition }).passthrough();
const serviceEntry = z.object({ ...entryMeta, definition: serviceDefinition }).passthrough();

// Manifest-level shape only validates the envelope; entries are validated
// individually in parseManifest so ONE bad community entry can't blank the
// whole catalog (the reject-all behaviour of z.array(z.object(...)) would).
const manifestSchema = z.object({
version: z.number(),
updatedAt: z.string(),
mcpServers: z.array(z.unknown()),
services: z.array(z.unknown()),
});

/** Parse + validate an unknown payload into a CommunityManifest, or throw. */
export function parseManifest(raw: unknown): CommunityManifest {
const m = manifestSchema.parse(raw);
return {
version: m.version,
updatedAt: m.updatedAt,
// Drop malformed entries individually rather than failing the whole parse.
mcpServers: m.mcpServers.flatMap((e) => {
const r = mcpEntry.safeParse(e);
return r.success ? [r.data] : [];
}),
services: m.services.flatMap((e) => {
const r = serviceEntry.safeParse(e);
return r.success ? [r.data] : [];
}),
} as CommunityManifest;
}

// ── cache ───────────────────────────────────────────────────────────────────

interface CacheEnvelope {
Expand Down
2 changes: 1 addition & 1 deletion electron/lib/custom-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("custom-services namespacing", () => {

describe("custom-services tool definition parsing", () => {
it("parses a bare definition object", () => {
const def = parseToolDefinition(baseCfg.toolDefinition);
const def = parseToolDefinition(baseCfg.toolDefinition!);
expect(def.name).toBe("search");
expect(def.description).toBe("Search things");
expect(def.parameters).toEqual({ type: "object", properties: { q: { type: "string" } } });
Expand Down
Loading