Skip to content

Commit 5e53cad

Browse files
authored
feat(kiloclaw): add Linear MCP* integration (#1407)
## Summary Adds Linear as an integrated developer tool in KiloClaw, backed by the official Linear MCP server via mcporter. - **Secret catalog**: Adds `LINEAR_API_KEY` field with `lin_api_` validation pattern, icon, and help link to Linear security settings - **Bootstrap**: New `configureLinear` step that wires up the env var; `updateToolsMdLinearSection` appends/removes a bounded reference section in TOOLS.md pointing agents to the Linear MCP server and mcporter skill - **MCP integration**: When `LINEAR_API_KEY` is set, `writeMcporterConfig()` adds the Linear MCP server entry (key stored as `${LINEAR_API_KEY}` verbatim, read from env by mcporter); entry is removed when the key is unset. Preserves existing user-configured servers. No credentials are ever written to disk. - **Dashboard UI**: Adds Linear to the "Developer Tools" section in `SettingsTab`, new `LinearIcon` SVG component, and `secret-ui-adapter` wiring (icon map + description) - **Tests**: Full coverage for `configureLinear`, `updateToolsMdLinearSection`, `writeMcporterConfig` (add, remove, preserve, multi-server), and updated catalog/route assertions ## Verification - [x] `pnpm format:check` passes - [x] `pnpm lint` passes - [x] Tests pass (`vitest` in kiloclaw workspace) - [x] E2E tested on a fresh instance ## Visual Changes - Kilo team only: https://www.loom.com/share/5ca9897104e84a3baf560818b7a3bcf7 <img width="1607" height="316" alt="image" src="https://github.com/user-attachments/assets/0ba1d87a-61d1-49f9-bc19-483a7fbb576f" /> <img width="1605" height="308" alt="image" src="https://github.com/user-attachments/assets/3a0edf0d-1bbb-4d60-baad-83dcc84b2c62" /> ## Reviewer Notes - Originally prototyped with `@schpet/linear-cli` installed in the Docker image, but switched to the official Linear MCP server — it's a better fit since mcporter is already deployed on all instances and avoids writing API keys to disk - The TOOLS.md section uses `<!-- BEGIN:linear -->` / `<!-- END:linear -->` markers, same pattern as the other tool sections - No Dockerfile changes — the MCP approach requires no additional system packages
2 parents 14c4537 + 458dc89 commit 5e53cad

File tree

11 files changed

+380
-8
lines changed

11 files changed

+380
-8
lines changed

kiloclaw/controller/src/bootstrap.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
applyFeatureFlags,
77
generateHooksToken,
88
configureGitHub,
9+
configureLinear,
10+
updateToolsMdLinearSection,
911
runOnboardOrDoctor,
1012
updateToolsMdKiloCliSection,
1113
updateToolsMd1PasswordSection,
@@ -487,6 +489,47 @@ describe('configureGitHub', () => {
487489
});
488490
});
489491

492+
// ---- configureLinear ----
493+
494+
describe('configureLinear', () => {
495+
it('logs configured when LINEAR_API_KEY is set', () => {
496+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
497+
const env: Record<string, string | undefined> = {
498+
LINEAR_API_KEY: 'lin_api_test123',
499+
};
500+
501+
configureLinear(env);
502+
503+
expect(env.LINEAR_API_KEY).toBe('lin_api_test123');
504+
expect(logSpy).toHaveBeenCalledWith('Linear MCP configured via LINEAR_API_KEY');
505+
logSpy.mockRestore();
506+
});
507+
508+
it('cleans up empty LINEAR_API_KEY and logs not configured', () => {
509+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
510+
const env: Record<string, string | undefined> = {
511+
LINEAR_API_KEY: '',
512+
};
513+
514+
configureLinear(env);
515+
516+
expect(env.LINEAR_API_KEY).toBeUndefined();
517+
expect(logSpy).toHaveBeenCalledWith('Linear: not configured');
518+
logSpy.mockRestore();
519+
});
520+
521+
it('logs not configured when LINEAR_API_KEY is absent', () => {
522+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
523+
const env: Record<string, string | undefined> = {};
524+
525+
configureLinear(env);
526+
527+
expect(env.LINEAR_API_KEY).toBeUndefined();
528+
expect(logSpy).toHaveBeenCalledWith('Linear: not configured');
529+
logSpy.mockRestore();
530+
});
531+
});
532+
490533
// ---- runOnboardOrDoctor ----
491534

492535
describe('runOnboardOrDoctor', () => {
@@ -684,6 +727,79 @@ describe('updateToolsMd1PasswordSection', () => {
684727
});
685728
});
686729

730+
// ---- updateToolsMdLinearSection ----
731+
732+
describe('updateToolsMdLinearSection', () => {
733+
it('adds Linear section when LINEAR_API_KEY is set', () => {
734+
const harness = fakeDeps();
735+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('# TOOLS\n');
736+
737+
const env: Record<string, string | undefined> = {
738+
LINEAR_API_KEY: 'lin_api_test123',
739+
};
740+
741+
updateToolsMdLinearSection(env, harness.deps);
742+
743+
expect(harness.writeCalls).toHaveLength(1);
744+
expect(harness.writeCalls[0]!.data).toContain('<!-- BEGIN:linear -->');
745+
expect(harness.writeCalls[0]!.data).toContain('## Linear');
746+
expect(harness.writeCalls[0]!.data).toContain('<!-- END:linear -->');
747+
});
748+
749+
it('skips adding when section already present', () => {
750+
const harness = fakeDeps();
751+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
752+
'# TOOLS\n<!-- BEGIN:linear -->\nexisting\n<!-- END:linear -->'
753+
);
754+
755+
const env: Record<string, string | undefined> = {
756+
LINEAR_API_KEY: 'lin_api_test123',
757+
};
758+
759+
updateToolsMdLinearSection(env, harness.deps);
760+
761+
expect(harness.writeCalls).toHaveLength(0);
762+
});
763+
764+
it('removes stale section when key is absent', () => {
765+
const harness = fakeDeps();
766+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
767+
'# TOOLS\n<!-- BEGIN:linear -->\nold section\n<!-- END:linear -->\n'
768+
);
769+
770+
const env: Record<string, string | undefined> = {};
771+
772+
updateToolsMdLinearSection(env, harness.deps);
773+
774+
expect(harness.writeCalls).toHaveLength(1);
775+
expect(harness.writeCalls[0]!.data).not.toContain('<!-- BEGIN:linear -->');
776+
});
777+
778+
it('no-ops when TOOLS.md does not exist', () => {
779+
const harness = fakeDeps();
780+
(harness.deps.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(false);
781+
782+
const env: Record<string, string | undefined> = {
783+
LINEAR_API_KEY: 'lin_api_test123',
784+
};
785+
786+
updateToolsMdLinearSection(env, harness.deps);
787+
788+
expect(harness.writeCalls).toHaveLength(0);
789+
});
790+
791+
it('no-ops when key absent and no stale section exists', () => {
792+
const harness = fakeDeps();
793+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('# TOOLS\n');
794+
795+
const env: Record<string, string | undefined> = {};
796+
797+
updateToolsMdLinearSection(env, harness.deps);
798+
799+
expect(harness.writeCalls).toHaveLength(0);
800+
});
801+
});
802+
687803
// ---- buildGatewayArgs ----
688804

689805
describe('buildGatewayArgs', () => {
@@ -743,7 +859,14 @@ describe('bootstrap', () => {
743859

744860
await bootstrap(env, phase => phases.push(phase), harness.deps);
745861

746-
expect(phases).toEqual(['decrypting', 'directories', 'feature-flags', 'github', 'onboard']);
862+
expect(phases).toEqual([
863+
'decrypting',
864+
'directories',
865+
'feature-flags',
866+
'github',
867+
'linear',
868+
'onboard',
869+
]);
747870
});
748871

749872
it('reports doctor phase when config exists', async () => {

kiloclaw/controller/src/bootstrap.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,24 @@ export function configureGitHub(env: EnvLike, deps: BootstrapDeps = defaultDeps)
324324
}
325325
}
326326

327-
// ---- Step 6: Onboard / doctor + config patching ----
327+
// ---- Step 6: Linear config ----
328+
329+
/**
330+
* Configure or clean up Linear MCP access.
331+
* Linear access is provided via the Linear MCP server configured in mcporter.
332+
* When LINEAR_API_KEY is present, mcporter uses it to authenticate.
333+
* When absent, we just clean up the env var. No on-disk artifacts to clean.
334+
*/
335+
export function configureLinear(env: EnvLike): void {
336+
if (env.LINEAR_API_KEY) {
337+
console.log('Linear MCP configured via LINEAR_API_KEY');
338+
} else {
339+
delete env.LINEAR_API_KEY;
340+
console.log('Linear: not configured');
341+
}
342+
}
343+
344+
// ---- Step 7: Onboard / doctor + config patching ----
328345

329346
/**
330347
* Run openclaw onboard (first boot) or openclaw doctor (subsequent boots),
@@ -386,7 +403,7 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe
386403
}
387404
}
388405

389-
// ---- Step 7: TOOLS.md Google Workspace section ----
406+
// ---- Step 8: TOOLS.md Google Workspace section ----
390407

391408
const GOG_MARKER_BEGIN = '<!-- BEGIN:google-workspace -->';
392409
const GOG_MARKER_END = '<!-- END:google-workspace -->';
@@ -539,7 +556,60 @@ export function updateToolsMd1PasswordSection(env: EnvLike, deps: BootstrapDeps)
539556
}
540557
}
541558

542-
// ---- Step 10: Gateway args ----
559+
// ---- Step 10: TOOLS.md Linear section ----
560+
561+
const LINEAR_MARKER_BEGIN = '<!-- BEGIN:linear -->';
562+
const LINEAR_MARKER_END = '<!-- END:linear -->';
563+
564+
const LINEAR_TOOLS_SECTION = `
565+
${LINEAR_MARKER_BEGIN}
566+
## Linear
567+
568+
Linear is configured as your project management tool. Use it to track issues, plan projects, and manage product roadmaps.
569+
You can interact with the \`Linear\` MCP server using your \`mcporter\` skill.
570+
571+
${LINEAR_MARKER_END}`;
572+
573+
/**
574+
* Manage the Linear section in TOOLS.md.
575+
*
576+
* When LINEAR_API_KEY is present, append a bounded section so the agent
577+
* knows Linear MCP is available. When absent, remove any stale section.
578+
* Idempotent: skips if the marker is already present.
579+
*/
580+
export function updateToolsMdLinearSection(env: EnvLike, deps: BootstrapDeps): void {
581+
if (!deps.existsSync(TOOLS_MD_DEST)) return;
582+
583+
const content = deps.readFileSync(TOOLS_MD_DEST, 'utf8');
584+
585+
if (env.LINEAR_API_KEY) {
586+
// Linear configured — add section if not already present
587+
if (!content.includes(LINEAR_MARKER_BEGIN)) {
588+
deps.writeFileSync(TOOLS_MD_DEST, content + LINEAR_TOOLS_SECTION);
589+
console.log('TOOLS.md: added Linear section');
590+
} else {
591+
console.log('TOOLS.md: Linear section already present');
592+
}
593+
} else {
594+
// Linear not configured — remove stale section if present
595+
if (content.includes(LINEAR_MARKER_BEGIN)) {
596+
const beginIdx = content.indexOf(LINEAR_MARKER_BEGIN);
597+
const endIdx = content.indexOf(LINEAR_MARKER_END);
598+
if (beginIdx !== -1 && endIdx !== -1) {
599+
const before = content.slice(0, beginIdx).replace(/\n+$/, '\n');
600+
const after = content.slice(endIdx + LINEAR_MARKER_END.length).replace(/^\n+/, '');
601+
deps.writeFileSync(TOOLS_MD_DEST, before + after);
602+
console.log('TOOLS.md: removed stale Linear section');
603+
} else {
604+
console.warn(
605+
'TOOLS.md: Linear BEGIN marker found but END marker missing, skipping removal'
606+
);
607+
}
608+
}
609+
}
610+
}
611+
612+
// ---- Step 11: Gateway args ----
543613

544614
/**
545615
* Build the gateway CLI arguments array.
@@ -588,6 +658,10 @@ export async function bootstrap(
588658
configureGitHub(env, deps);
589659
await yieldToEventLoop();
590660

661+
setPhase('linear');
662+
configureLinear(env);
663+
await yieldToEventLoop();
664+
591665
const configExists = deps.existsSync(CONFIG_PATH);
592666
setPhase(configExists ? 'doctor' : 'onboard');
593667
runOnboardOrDoctor(env, deps);
@@ -596,6 +670,7 @@ export async function bootstrap(
596670
updateToolsMdKiloCliSection(env, deps);
597671
updateToolsMdGoogleSection(env, deps);
598672
updateToolsMd1PasswordSection(env, deps);
673+
updateToolsMdLinearSection(env, deps);
599674

600675
// Write mcporter config for MCP servers (AgentCard, etc.)
601676
writeMcporterConfig(env);

kiloclaw/controller/src/config-writer.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
backupConfigFile,
44
generateBaseConfig,
55
writeBaseConfig,
6+
writeMcporterConfig,
67
MAX_CONFIG_BACKUPS,
78
} from './config-writer';
89

@@ -818,3 +819,106 @@ describe('writeBaseConfig', () => {
818819
expect(config.tools.exec.host).toBe('gateway');
819820
});
820821
});
822+
823+
function mcporterFakeDeps(existingMcporterConfig?: string) {
824+
const written: { path: string; data: string }[] = [];
825+
return {
826+
deps: {
827+
readFileSync: vi.fn((filePath: string) => {
828+
if (existingMcporterConfig !== undefined) return existingMcporterConfig;
829+
throw new Error(`ENOENT: no such file: ${filePath}`);
830+
}),
831+
writeFileSync: vi.fn((filePath: string, data: string) => {
832+
written.push({ path: filePath, data });
833+
}),
834+
renameSync: vi.fn(),
835+
copyFileSync: vi.fn(),
836+
readdirSync: vi.fn(() => []),
837+
unlinkSync: vi.fn(),
838+
existsSync: vi.fn((filePath: string) => {
839+
if (existingMcporterConfig !== undefined && filePath.endsWith('mcporter.json')) return true;
840+
return false;
841+
}),
842+
execFileSync: vi.fn(),
843+
},
844+
written,
845+
};
846+
}
847+
848+
describe('writeMcporterConfig', () => {
849+
it('adds Linear MCP server when LINEAR_API_KEY is set', () => {
850+
const { deps, written } = mcporterFakeDeps();
851+
const env = { LINEAR_API_KEY: 'lin_api_test123' };
852+
853+
writeMcporterConfig(env, '/tmp/mcporter.json', deps);
854+
855+
expect(written).toHaveLength(1);
856+
const config = JSON.parse(written[0].data);
857+
expect(config.mcpServers.linear).toEqual({
858+
url: 'https://mcp.linear.app/mcp',
859+
headers: { Authorization: 'Bearer ${LINEAR_API_KEY}' },
860+
});
861+
});
862+
863+
it('removes Linear MCP server when LINEAR_API_KEY is absent', () => {
864+
const existing = JSON.stringify({
865+
mcpServers: {
866+
linear: {
867+
url: 'https://mcp.linear.app/mcp',
868+
headers: { Authorization: 'Bearer ${LINEAR_API_KEY}' },
869+
},
870+
},
871+
});
872+
const { deps, written } = mcporterFakeDeps(existing);
873+
const env: Record<string, string | undefined> = {};
874+
875+
writeMcporterConfig(env, '/tmp/mcporter.json', deps);
876+
877+
expect(written).toHaveLength(1);
878+
const config = JSON.parse(written[0].data);
879+
expect(config.mcpServers.linear).toBeUndefined();
880+
});
881+
882+
it('preserves user-added servers when adding Linear', () => {
883+
const existing = JSON.stringify({
884+
mcpServers: {
885+
custom: { url: 'https://custom.example.com/mcp' },
886+
},
887+
});
888+
const { deps, written } = mcporterFakeDeps(existing);
889+
const env = { LINEAR_API_KEY: 'lin_api_test123' };
890+
891+
writeMcporterConfig(env, '/tmp/mcporter.json', deps);
892+
893+
expect(written).toHaveLength(1);
894+
const config = JSON.parse(written[0].data);
895+
expect(config.mcpServers.custom).toEqual({ url: 'https://custom.example.com/mcp' });
896+
expect(config.mcpServers.linear).toBeDefined();
897+
});
898+
899+
it('adds both AgentCard and Linear when both keys are set', () => {
900+
const { deps, written } = mcporterFakeDeps();
901+
const env = {
902+
AGENTCARD_API_KEY: 'ac_test123',
903+
LINEAR_API_KEY: 'lin_api_test123',
904+
};
905+
906+
writeMcporterConfig(env, '/tmp/mcporter.json', deps);
907+
908+
expect(written).toHaveLength(1);
909+
const config = JSON.parse(written[0].data);
910+
expect(config.mcpServers.agentcard).toBeDefined();
911+
expect(config.mcpServers.linear).toBeDefined();
912+
});
913+
914+
it('uses literal ${LINEAR_API_KEY} in authorization header (not interpolated)', () => {
915+
const { deps, written } = mcporterFakeDeps();
916+
const env = { LINEAR_API_KEY: 'lin_api_test123' };
917+
918+
writeMcporterConfig(env, '/tmp/mcporter.json', deps);
919+
920+
const config = JSON.parse(written[0].data);
921+
// The header should contain the literal string ${LINEAR_API_KEY}, not the actual value
922+
expect(config.mcpServers.linear.headers.Authorization).toBe('Bearer ${LINEAR_API_KEY}');
923+
});
924+
});

0 commit comments

Comments
 (0)