Skip to content

Commit 4ebfe79

Browse files
feat(m2m): OAuth2 client_credentials + agent-framework tool-spec export (v0.10.84)
Brings cachly to every caller — human, CI, agent, or AI-to-AI — not just MCP clients. M2M / headless auth: - auth.ts: readClientCredentialsFromEnv / buildClientCredentialsBody / clientCredentialsTokenUrl — standard OAuth2 client_credentials grant - index.ts: at startup, if no JWT but CACHLY_CLIENT_ID + CACHLY_CLIENT_SECRET are set, mint an access token against Keycloak — fully non-interactive (no device flow) - m2m_auth_completed / m2m_auth_failed telemetry events Agent-ecosystem tool-spec exporter (zero new deps, derived from TOOLS): - toolspecs.ts: toOpenAITools / toAnthropicTools / toLangChainTools / toOpenAPI - CLI: `tool-specs --format=openai|anthropic|langchain` and `openapi` (OpenAPI 3.1) - exit-after-flush fix so large (>64KB) payloads aren't truncated on a pipe 14 new tests (auth + toolspecs); 591/591 passing; lint clean. https://claude.ai/code/session_01LhzzcUPCuRAfqizEtyGoJr
1 parent 9a54f9c commit 4ebfe79

9 files changed

Lines changed: 390 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cachly-dev/mcp-server",
3-
"version": "0.10.83",
3+
"version": "0.10.84",
44
"mcpName": "io.github.cachly-dev/cachly-mcp",
55
"description": "Your AI forgets everything between sessions. cachly fixes that permanently. Persistent memory + causal root-cause analysis for Claude Code, Cursor, Copilot & Windsurf. Learns from every bug fix, git commit, and CI run automatically. Arrives pre-briefed. Predicts failures before they happen. 107 MCP tools · Free tier forever · GDPR · EU servers.",
66
"type": "module",

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
"url": "https://github.com/cachly-dev/cachly-mcp",
77
"source": "github"
88
},
9-
"version": "0.10.83",
9+
"version": "0.10.84",
1010
"packages": [
1111
{
1212
"registryType": "npm",
1313
"name": "@cachly-dev/mcp-server",
1414
"identifier": "@cachly-dev/mcp-server",
15-
"version": "0.10.83",
15+
"version": "0.10.84",
1616
"transport": {
1717
"type": "stdio"
1818
}

src/__tests__/auth.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, it, expect } from 'vitest';
22
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
33
import { jwtExpiryMs, checkJwt, handleApiError,
4-
diagnoseAuth, planAuthHeal, isLongLivedApiKey, NEAR_EXPIRY_MS } from '../auth.js';
4+
diagnoseAuth, planAuthHeal, isLongLivedApiKey, NEAR_EXPIRY_MS,
5+
readClientCredentialsFromEnv, buildClientCredentialsBody,
6+
clientCredentialsTokenUrl, DEFAULT_AUTH_BASE } from '../auth.js';
57

68
function makeJwt(payload: Record<string, unknown>): string {
79
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
@@ -226,3 +228,44 @@ describe('planAuthHeal', () => {
226228
expect(planAuthHeal(diagnoseAuth('cky_live_abc'))).toBe('none');
227229
});
228230
});
231+
232+
describe('M2M client_credentials auth', () => {
233+
it('returns null when either credential is missing', () => {
234+
expect(readClientCredentialsFromEnv({})).toBeNull();
235+
expect(readClientCredentialsFromEnv({ CACHLY_CLIENT_ID: 'x' } as NodeJS.ProcessEnv)).toBeNull();
236+
expect(readClientCredentialsFromEnv({ CACHLY_CLIENT_SECRET: 'y' } as NodeJS.ProcessEnv)).toBeNull();
237+
});
238+
239+
it('reads both credentials with sensible defaults', () => {
240+
const c = readClientCredentialsFromEnv({ CACHLY_CLIENT_ID: 'svc', CACHLY_CLIENT_SECRET: 'shh' } as NodeJS.ProcessEnv);
241+
expect(c).toEqual({ clientId: 'svc', clientSecret: 'shh', scope: 'openid', authBase: DEFAULT_AUTH_BASE });
242+
});
243+
244+
it('honors scope and self-host auth base overrides', () => {
245+
const c = readClientCredentialsFromEnv({
246+
CACHLY_CLIENT_ID: 'svc', CACHLY_CLIENT_SECRET: 'shh',
247+
CACHLY_CLIENT_SCOPE: 'openid profile', CACHLY_AUTH_URL: 'https://kc.local/realms/r/protocol/openid-connect',
248+
} as NodeJS.ProcessEnv);
249+
expect(c?.scope).toBe('openid profile');
250+
expect(c?.authBase).toBe('https://kc.local/realms/r/protocol/openid-connect');
251+
});
252+
253+
it('builds a valid client_credentials form body', () => {
254+
const body = buildClientCredentialsBody({ clientId: 'svc', clientSecret: 's/h h', scope: 'openid' });
255+
const p = new URLSearchParams(body);
256+
expect(p.get('grant_type')).toBe('client_credentials');
257+
expect(p.get('client_id')).toBe('svc');
258+
expect(p.get('client_secret')).toBe('s/h h'); // properly url-encoded round-trip
259+
expect(p.get('scope')).toBe('openid');
260+
});
261+
262+
it('omits scope from the body when not provided', () => {
263+
const body = buildClientCredentialsBody({ clientId: 'svc', clientSecret: 'shh' });
264+
expect(new URLSearchParams(body).has('scope')).toBe(false);
265+
});
266+
267+
it('derives the token endpoint from the realm base', () => {
268+
expect(clientCredentialsTokenUrl({ clientId: 'a', clientSecret: 'b' })).toBe(`${DEFAULT_AUTH_BASE}/token`);
269+
expect(clientCredentialsTokenUrl({ clientId: 'a', clientSecret: 'b', authBase: 'https://kc.local/x' })).toBe('https://kc.local/x/token');
270+
});
271+
});

src/__tests__/toolspecs.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Unit tests for the agent-framework tool-spec exporter.
3+
*
4+
* Verifies that every dialect (OpenAPI 3.1, OpenAI, Anthropic, LangChain) is
5+
* derived faithfully from the real TOOLS source of truth — so the wider agent
6+
* ecosystem can wrap cachly without hand-written glue.
7+
*
8+
* Run: npx vitest run src/__tests__/toolspecs.test.ts
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { TOOLS } from '../tools.js';
13+
import {
14+
toOpenAITools, toAnthropicTools, toLangChainTools, toOpenAPI, renderToolSpecs,
15+
type ToolDef,
16+
} from '../toolspecs.js';
17+
18+
const tools = TOOLS as unknown as ReadonlyArray<ToolDef>;
19+
20+
describe('tool-spec exporter', () => {
21+
it('covers every MCP tool in each dialect', () => {
22+
expect(toOpenAITools(tools)).toHaveLength(tools.length);
23+
expect(toAnthropicTools(tools)).toHaveLength(tools.length);
24+
expect(toLangChainTools(tools)).toHaveLength(tools.length);
25+
expect(tools.length).toBeGreaterThanOrEqual(114);
26+
});
27+
28+
it('OpenAI format uses {type:function, function:{name,description,parameters}}', () => {
29+
const t = toOpenAITools(tools)[0] as { type: string; function: { name: string; description: string; parameters: unknown } };
30+
expect(t.type).toBe('function');
31+
expect(t.function.name).toBe(tools[0].name);
32+
expect(t.function.description).toBe(tools[0].description);
33+
expect(t.function.parameters).toEqual(tools[0].inputSchema);
34+
});
35+
36+
it('Anthropic format uses input_schema (not parameters)', () => {
37+
const t = toAnthropicTools(tools)[0] as Record<string, unknown>;
38+
expect(t.name).toBe(tools[0].name);
39+
expect(t).toHaveProperty('input_schema');
40+
expect(t).not.toHaveProperty('parameters');
41+
});
42+
43+
it('LangChain format exposes name/description/schema', () => {
44+
const t = toLangChainTools(tools)[0] as Record<string, unknown>;
45+
expect(t.name).toBe(tools[0].name);
46+
expect(t).toHaveProperty('schema');
47+
});
48+
49+
it('OpenAPI is a valid 3.1 doc with one POST path per tool', () => {
50+
const doc = toOpenAPI(tools, '9.9.9') as { openapi: string; paths: Record<string, { post: unknown }>; security: unknown[] };
51+
expect(doc.openapi).toBe('3.1.0');
52+
expect(Object.keys(doc.paths)).toHaveLength(tools.length);
53+
for (const t of tools) {
54+
const path = doc.paths[`/mcp/tools/${t.name}`] as { post: { operationId: string } };
55+
expect(path).toBeTruthy();
56+
expect(path.post.operationId).toBe(t.name);
57+
}
58+
expect(doc.security).toEqual([{ bearerAuth: [] }]);
59+
});
60+
61+
it('OpenAPI requestBody.required reflects whether the tool has required params', () => {
62+
const withReq = tools.find(t => (t.inputSchema?.required?.length ?? 0) > 0)!;
63+
const noReq = tools.find(t => (t.inputSchema?.required?.length ?? 0) === 0)!;
64+
const doc = toOpenAPI(tools, '1.0.0') as { paths: Record<string, { post: { requestBody: { required: boolean } } }> };
65+
expect(doc.paths[`/mcp/tools/${withReq.name}`].post.requestBody.required).toBe(true);
66+
expect(doc.paths[`/mcp/tools/${noReq.name}`].post.requestBody.required).toBe(false);
67+
});
68+
69+
it('renderToolSpecs emits parseable JSON for every format', () => {
70+
for (const fmt of ['openapi', 'openai', 'anthropic', 'langchain'] as const) {
71+
const out = renderToolSpecs(tools, fmt, '0.0.1');
72+
expect(() => JSON.parse(out)).not.toThrow();
73+
}
74+
});
75+
76+
it('embeds the version into the OpenAPI info block', () => {
77+
const doc = JSON.parse(renderToolSpecs(tools, 'openapi', '7.7.7')) as { info: { version: string } };
78+
expect(doc.info.version).toBe('7.7.7');
79+
});
80+
});

src/auth.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,54 @@ export function checkJwt(jwt: string): void {
122122
}
123123
}
124124

125+
// ── M2M / headless auth (OAuth2 client_credentials) ──────────────────────────
126+
// For machine-to-machine callers — CI runners, agent orchestrators, AI-to-AI
127+
// pipelines — there is no human to complete a device flow. When CACHLY_CLIENT_ID
128+
// and CACHLY_CLIENT_SECRET are present we run the standard OAuth2
129+
// client_credentials grant against Keycloak and use the resulting access token
130+
// exactly like a CACHLY_JWT. Fully non-interactive.
131+
132+
export const DEFAULT_AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
133+
134+
export interface ClientCredentials {
135+
clientId: string;
136+
clientSecret: string;
137+
scope?: string;
138+
/** Override the Keycloak realm token base (self-host). */
139+
authBase?: string;
140+
}
141+
142+
/** Read M2M client credentials from the environment, if both are present. */
143+
export function readClientCredentialsFromEnv(
144+
env: NodeJS.ProcessEnv = process.env,
145+
): ClientCredentials | null {
146+
const clientId = env.CACHLY_CLIENT_ID;
147+
const clientSecret = env.CACHLY_CLIENT_SECRET;
148+
if (!clientId || !clientSecret) return null;
149+
return {
150+
clientId,
151+
clientSecret,
152+
scope: env.CACHLY_CLIENT_SCOPE || 'openid',
153+
authBase: env.CACHLY_AUTH_URL || DEFAULT_AUTH_BASE,
154+
};
155+
}
156+
157+
/** Build the application/x-www-form-urlencoded body for a client_credentials grant. */
158+
export function buildClientCredentialsBody(c: ClientCredentials): string {
159+
const params = new URLSearchParams({
160+
grant_type: 'client_credentials',
161+
client_id: c.clientId,
162+
client_secret: c.clientSecret,
163+
});
164+
if (c.scope) params.set('scope', c.scope);
165+
return params.toString();
166+
}
167+
168+
/** Token endpoint URL for a given credentials/realm config. */
169+
export function clientCredentialsTokenUrl(c: ClientCredentials): string {
170+
return `${c.authBase ?? DEFAULT_AUTH_BASE}/token`;
171+
}
172+
125173
export function handleApiError(status: number, detail: string): never {
126174
if (status === 401) {
127175
throw new McpError(

src/handlers/brain.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,15 @@ import { rerankByQuality } from '../rerank.js';
1414
import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
1515

1616
// ── Changelog (shown once per version in session_start) ──────────────────────
17-
const MCP_VERSION = '0.10.83';
17+
const MCP_VERSION = '0.10.84';
1818
const WHATS_NEW: Record<string, string[]> = {
19+
'0.10.84': [
20+
`🤖 **M2M & agent-ecosystem reach — cachly for every caller, human or machine**`,
21+
` 🔑 OAuth2 \`client_credentials\` grant — set CACHLY_CLIENT_ID + CACHLY_CLIENT_SECRET for fully headless auth (CI, agents, AI-to-AI)`,
22+
` 🗂️ \`npx ... tool-specs --format=openai|anthropic|langchain\` — export all 114 tools in any framework's dialect`,
23+
` 🌐 \`npx ... openapi\` — OpenAPI 3.1 doc (1 POST path per tool) for Assistants / codegen / Postman`,
24+
` 🧪 14 new tests: client_credentials helpers, all four spec dialects, OpenAPI required-body inference`,
25+
],
1926
'0.10.83': [
2027
`🧠 **Brain Viz — 3D graph export**`,
2128
` 🌐 \`brain_graph(instance_id)\` — exports the Causal Knowledge Graph as a render-ready {nodes, links} payload`,

src/index.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env node
2-
import { jwtExpiryMs, checkJwt, handleApiError, diagnoseAuth, planAuthHeal } from './auth.js';
2+
import { jwtExpiryMs, checkJwt, handleApiError, diagnoseAuth, planAuthHeal,
3+
readClientCredentialsFromEnv, buildClientCredentialsBody, clientCredentialsTokenUrl } from './auth.js';
34
import type { FunnelEventName, DashboardMetrics } from './telemetry-types.js';
45
import { handleTcoTool } from './handlers/tco.js';
56
import { notify } from './notifier.js';
@@ -75,7 +76,7 @@ import { Redis } from 'ioredis';
7576
let API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
7677
let JWT = process.env.CACHLY_JWT ?? '';
7778
const _EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
78-
const CURRENT_VERSION = '0.10.83';
79+
const CURRENT_VERSION = '0.10.84';
7980

8081
// Max time to wait for a freshly-provisioned instance to become "running" before
8182
// giving up. Free-tier provisioning in high-latency regions can take 45–90s, so the
@@ -2051,6 +2052,38 @@ if (process.argv[2] === 'publish') {
20512052
process.exit(0);
20522053
}
20532054

2055+
// ── CLI: cachly tool-specs / openapi ───────────────────────────────────────────
2056+
// Emit the 114-tool surface in any agent framework's dialect, derived from the
2057+
// single TOOLS source of truth. Lets OpenAI Assistants, the Anthropic Messages
2058+
// API, LangChain, CrewAI and AutoGen wrap cachly without hand-written glue.
2059+
// npx @cachly-dev/mcp-server tool-specs --format=openai > cachly.openai.json
2060+
// npx @cachly-dev/mcp-server openapi > cachly.openapi.json
2061+
if (process.argv[2] === 'tool-specs' || process.argv[2] === 'openapi') {
2062+
const { renderToolSpecs } = await import('./toolspecs.js');
2063+
// Accept both "--format openai" and "--format=openai".
2064+
let format = 'openapi';
2065+
if (process.argv[2] !== 'openapi') {
2066+
const eqArg = process.argv.find(a => a.startsWith('--format='));
2067+
if (eqArg) {
2068+
format = eqArg.slice('--format='.length);
2069+
} else {
2070+
const fmtFlag = process.argv.indexOf('--format');
2071+
if (fmtFlag !== -1) format = process.argv[fmtFlag + 1] ?? 'openapi';
2072+
}
2073+
}
2074+
const valid = ['openapi', 'openai', 'anthropic', 'langchain'];
2075+
if (!valid.includes(format)) {
2076+
process.stderr.write(`\n Unknown format "${format}". Use one of: ${valid.join(', ')}\n\n`);
2077+
process.exit(1);
2078+
}
2079+
// Exit only after stdout has fully flushed — on a pipe, write() is async and an
2080+
// immediate process.exit() truncates large payloads (~64KB) mid-flush.
2081+
process.stdout.write(
2082+
renderToolSpecs(TOOLS as unknown as Parameters<typeof renderToolSpecs>[0], format as 'openapi' | 'openai' | 'anthropic' | 'langchain', CURRENT_VERSION) + '\n',
2083+
() => process.exit(0),
2084+
);
2085+
}
2086+
20542087
// ── CLI: cachly badge ─────────────────────────────────────────────────────────
20552088
// Outputs the Markdown + HTML snippet for embedding a live Brain lesson-count
20562089
// badge in any README or website. Badge SVG served by cachly API (public, no auth).
@@ -3176,14 +3209,47 @@ if (process.argv[2] === 'learn-git') {
31763209

31773210
// ── Start ─────────────────────────────────────────────────────────────────────
31783211

3212+
// M2M / headless auth: if no JWT but client credentials are present, run the
3213+
// OAuth2 client_credentials grant before any credential-missing handling. This
3214+
// makes cachly fully non-interactive for CI runners, agent orchestrators and
3215+
// AI-to-AI pipelines — no device flow, no human, no browser.
3216+
if (!JWT) {
3217+
const creds = readClientCredentialsFromEnv();
3218+
if (creds) {
3219+
try {
3220+
const res = await fetch(clientCredentialsTokenUrl(creds), {
3221+
method: 'POST',
3222+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
3223+
body: buildClientCredentialsBody(creds),
3224+
signal: AbortSignal.timeout(8000),
3225+
});
3226+
if (res.ok) {
3227+
const tok = await res.json() as { access_token?: string };
3228+
if (tok.access_token) {
3229+
JWT = tok.access_token;
3230+
setEmbedJwt(tok.access_token);
3231+
sendFunnelEvent('m2m_auth_completed', { grant: 'client_credentials', client_id: creds.clientId });
3232+
process.stderr.write('🤖 cachly: authenticated via client_credentials (M2M, headless).\n');
3233+
}
3234+
} else {
3235+
process.stderr.write(`⚠️ cachly: client_credentials auth failed (${res.status}). Check CACHLY_CLIENT_ID / CACHLY_CLIENT_SECRET.\n`);
3236+
sendFunnelEvent('m2m_auth_failed', { status: res.status });
3237+
}
3238+
} catch (err) {
3239+
process.stderr.write(`⚠️ cachly: client_credentials auth error: ${(err as Error).message}\n`);
3240+
sendFunnelEvent('m2m_auth_failed', { reason: 'network' });
3241+
}
3242+
}
3243+
}
3244+
31793245
// Credential-missing handling at startup. The critical distinction is HOW we were
31803246
// launched, because writing anything to stdout in stdio-MCP mode corrupts the
31813247
// JSON-RPC stream and exiting kills the zero-credential device-flow onboarding.
31823248
// • Human in a real terminal (TTY), no args → show the setup banner, exit 0.
31833249
// • Editor as an MCP stdio server (non-TTY) → ONE stderr hint, then KEEP RUNNING
31843250
// so tools/list works and the first tool call starts the browser sign-in.
31853251
// Skip entirely for CLI subcommands that intentionally run without credentials.
3186-
const _cliNoAuthCommands = ['demo', 'share', 'publish', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
3252+
const _cliNoAuthCommands = ['demo', 'share', 'publish', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade', 'tool-specs', 'openapi'];
31873253
if (!JWT && !_cliNoAuthCommands.includes(process.argv[2] ?? '')) {
31883254
const runningInTerminal = !process.argv[2] && process.stdout.isTTY === true && _isMain;
31893255
if (runningInTerminal) {

src/telemetry-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type FunnelEventName =
1818
| 'device_flow_completed'
1919
| 'device_flow_failed'
2020
| 'auth_self_healed'
21+
| 'm2m_auth_completed'
22+
| 'm2m_auth_failed'
2123
| 'setup_started'
2224
| 'setup_auth_started'
2325
| 'setup_auth_completed'

0 commit comments

Comments
 (0)