mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -1,54 +1,41 @@
|
||||
import React, {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import type { Command } from '../../commands.js'
|
||||
import type { Tool } from '../../Tool.js'
|
||||
import type {
|
||||
MCPServerConnection,
|
||||
ScopedMcpServerConfig,
|
||||
ServerResource,
|
||||
} from './types.js'
|
||||
import { useManageMCPConnections } from './useManageMCPConnections.js'
|
||||
import React, { createContext, type ReactNode, useContext, useMemo } from 'react';
|
||||
import type { Command } from '../../commands.js';
|
||||
import type { Tool } from '../../Tool.js';
|
||||
import type { MCPServerConnection, ScopedMcpServerConfig, ServerResource } from './types.js';
|
||||
import { useManageMCPConnections } from './useManageMCPConnections.js';
|
||||
|
||||
interface MCPConnectionContextValue {
|
||||
reconnectMcpServer: (serverName: string) => Promise<{
|
||||
client: MCPServerConnection
|
||||
tools: Tool[]
|
||||
commands: Command[]
|
||||
resources?: ServerResource[]
|
||||
}>
|
||||
toggleMcpServer: (serverName: string) => Promise<void>
|
||||
client: MCPServerConnection;
|
||||
tools: Tool[];
|
||||
commands: Command[];
|
||||
resources?: ServerResource[];
|
||||
}>;
|
||||
toggleMcpServer: (serverName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const MCPConnectionContext = createContext<MCPConnectionContextValue | null>(
|
||||
null,
|
||||
)
|
||||
const MCPConnectionContext = createContext<MCPConnectionContextValue | null>(null);
|
||||
|
||||
export function useMcpReconnect() {
|
||||
const context = useContext(MCPConnectionContext)
|
||||
const context = useContext(MCPConnectionContext);
|
||||
if (!context) {
|
||||
throw new Error('useMcpReconnect must be used within MCPConnectionManager')
|
||||
throw new Error('useMcpReconnect must be used within MCPConnectionManager');
|
||||
}
|
||||
return context.reconnectMcpServer
|
||||
return context.reconnectMcpServer;
|
||||
}
|
||||
|
||||
export function useMcpToggleEnabled() {
|
||||
const context = useContext(MCPConnectionContext)
|
||||
const context = useContext(MCPConnectionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useMcpToggleEnabled must be used within MCPConnectionManager',
|
||||
)
|
||||
throw new Error('useMcpToggleEnabled must be used within MCPConnectionManager');
|
||||
}
|
||||
return context.toggleMcpServer
|
||||
return context.toggleMcpServer;
|
||||
}
|
||||
|
||||
interface MCPConnectionManagerProps {
|
||||
children: ReactNode
|
||||
dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined
|
||||
isStrictMcpConfig: boolean
|
||||
children: ReactNode;
|
||||
dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined;
|
||||
isStrictMcpConfig: boolean;
|
||||
}
|
||||
|
||||
// TODO (ollie): We may be able to get rid of this context by putting these function on app state
|
||||
@@ -57,18 +44,8 @@ export function MCPConnectionManager({
|
||||
dynamicMcpConfig,
|
||||
isStrictMcpConfig,
|
||||
}: MCPConnectionManagerProps): React.ReactNode {
|
||||
const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections(
|
||||
dynamicMcpConfig,
|
||||
isStrictMcpConfig,
|
||||
)
|
||||
const value = useMemo(
|
||||
() => ({ reconnectMcpServer, toggleMcpServer }),
|
||||
[reconnectMcpServer, toggleMcpServer],
|
||||
)
|
||||
const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections(dynamicMcpConfig, isStrictMcpConfig);
|
||||
const value = useMemo(() => ({ reconnectMcpServer, toggleMcpServer }), [reconnectMcpServer, toggleMcpServer]);
|
||||
|
||||
return (
|
||||
<MCPConnectionContext.Provider value={value}>
|
||||
{children}
|
||||
</MCPConnectionContext.Provider>
|
||||
)
|
||||
return <MCPConnectionContext.Provider value={value}>{children}</MCPConnectionContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
// findChannelEntry extracted from ../channelNotification.ts (line 161)
|
||||
// Copied to avoid heavy import chain
|
||||
|
||||
type ChannelEntry = {
|
||||
kind: "server" | "plugin"
|
||||
kind: 'server' | 'plugin'
|
||||
name: string
|
||||
}
|
||||
|
||||
@@ -12,58 +12,58 @@ function findChannelEntry(
|
||||
serverName: string,
|
||||
channels: readonly ChannelEntry[],
|
||||
): ChannelEntry | undefined {
|
||||
const parts = serverName.split(":")
|
||||
const parts = serverName.split(':')
|
||||
return channels.find(c =>
|
||||
c.kind === "server"
|
||||
c.kind === 'server'
|
||||
? serverName === c.name
|
||||
: parts[0] === "plugin" && parts[1] === c.name,
|
||||
: parts[0] === 'plugin' && parts[1] === c.name,
|
||||
)
|
||||
}
|
||||
|
||||
describe("findChannelEntry", () => {
|
||||
test("finds server entry by exact name match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "my-server" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeDefined()
|
||||
expect(findChannelEntry("my-server", channels)!.name).toBe("my-server")
|
||||
describe('findChannelEntry', () => {
|
||||
test('finds server entry by exact name match', () => {
|
||||
const channels = [{ kind: 'server' as const, name: 'my-server' }]
|
||||
expect(findChannelEntry('my-server', channels)).toBeDefined()
|
||||
expect(findChannelEntry('my-server', channels)!.name).toBe('my-server')
|
||||
})
|
||||
|
||||
test("finds plugin entry by matching second segment", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
test('finds plugin entry by matching second segment', () => {
|
||||
const channels = [{ kind: 'plugin' as const, name: 'slack' }]
|
||||
expect(findChannelEntry('plugin:slack:tg', channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns undefined for no match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "other" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeUndefined()
|
||||
test('returns undefined for no match', () => {
|
||||
const channels = [{ kind: 'server' as const, name: 'other' }]
|
||||
expect(findChannelEntry('my-server', channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles empty channels array", () => {
|
||||
expect(findChannelEntry("my-server", [])).toBeUndefined()
|
||||
test('handles empty channels array', () => {
|
||||
expect(findChannelEntry('my-server', [])).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles server name without colon", () => {
|
||||
const channels = [{ kind: "server" as const, name: "simple" }]
|
||||
expect(findChannelEntry("simple", channels)).toBeDefined()
|
||||
test('handles server name without colon', () => {
|
||||
const channels = [{ kind: 'server' as const, name: 'simple' }]
|
||||
expect(findChannelEntry('simple', channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("handles 'plugin:name' format correctly", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
expect(findChannelEntry("plugin:discord:tg", channels)).toBeUndefined()
|
||||
const channels = [{ kind: 'plugin' as const, name: 'slack' }]
|
||||
expect(findChannelEntry('plugin:slack:tg', channels)).toBeDefined()
|
||||
expect(findChannelEntry('plugin:discord:tg', channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers exact match (server kind) over partial match", () => {
|
||||
test('prefers exact match (server kind) over partial match', () => {
|
||||
const channels = [
|
||||
{ kind: "server" as const, name: "plugin:slack" },
|
||||
{ kind: "plugin" as const, name: "slack" },
|
||||
{ kind: 'server' as const, name: 'plugin:slack' },
|
||||
{ kind: 'plugin' as const, name: 'slack' },
|
||||
]
|
||||
const result = findChannelEntry("plugin:slack", channels)
|
||||
const result = findChannelEntry('plugin:slack', channels)
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.kind).toBe("server")
|
||||
expect(result!.kind).toBe('server')
|
||||
})
|
||||
|
||||
test("plugin kind does not match bare name", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("slack", channels)).toBeUndefined()
|
||||
test('plugin kind does not match bare name', () => {
|
||||
const channels = [{ kind: 'plugin' as const, name: 'slack' }]
|
||||
expect(findChannelEntry('slack', channels)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { mock, describe, expect, test } from 'bun:test'
|
||||
|
||||
mock.module("src/services/analytics/growthbook.js", () => ({
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
}));
|
||||
}))
|
||||
|
||||
const {
|
||||
filterPermissionRelayClients,
|
||||
@@ -10,185 +10,187 @@ const {
|
||||
truncateForPreview,
|
||||
PERMISSION_REPLY_RE,
|
||||
createChannelPermissionCallbacks,
|
||||
} = await import("../channelPermissions");
|
||||
} = await import('../channelPermissions')
|
||||
|
||||
describe("shortRequestId", () => {
|
||||
test("returns 5-char string from tool use ID", () => {
|
||||
const result = shortRequestId("toolu_abc123");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
describe('shortRequestId', () => {
|
||||
test('returns 5-char string from tool use ID', () => {
|
||||
const result = shortRequestId('toolu_abc123')
|
||||
expect(result).toHaveLength(5)
|
||||
})
|
||||
|
||||
test("is deterministic (same input = same output)", () => {
|
||||
const a = shortRequestId("toolu_abc123");
|
||||
const b = shortRequestId("toolu_abc123");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
test('is deterministic (same input = same output)', () => {
|
||||
const a = shortRequestId('toolu_abc123')
|
||||
const b = shortRequestId('toolu_abc123')
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
test("different inputs produce different outputs", () => {
|
||||
const a = shortRequestId("toolu_aaa");
|
||||
const b = shortRequestId("toolu_bbb");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
test('different inputs produce different outputs', () => {
|
||||
const a = shortRequestId('toolu_aaa')
|
||||
const b = shortRequestId('toolu_bbb')
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
test("result contains only valid letters (no 'l')", () => {
|
||||
const validChars = new Set("abcdefghijkmnopqrstuvwxyz");
|
||||
const validChars = new Set('abcdefghijkmnopqrstuvwxyz')
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = shortRequestId(`toolu_${i}`);
|
||||
const result = shortRequestId(`toolu_${i}`)
|
||||
for (const ch of result) {
|
||||
expect(validChars.has(ch)).toBe(true);
|
||||
expect(validChars.has(ch)).toBe(true)
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = shortRequestId("");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
const result = shortRequestId('')
|
||||
expect(result).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateForPreview", () => {
|
||||
test("returns JSON string for object input", () => {
|
||||
const result = truncateForPreview({ key: "value" });
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
});
|
||||
describe('truncateForPreview', () => {
|
||||
test('returns JSON string for object input', () => {
|
||||
const result = truncateForPreview({ key: 'value' })
|
||||
expect(result).toBe('{"key":"value"}')
|
||||
})
|
||||
|
||||
test("truncates to <=200 chars with ellipsis when input is long", () => {
|
||||
const longObj = { data: "x".repeat(300) };
|
||||
const result = truncateForPreview(longObj);
|
||||
expect(result.length).toBeLessThanOrEqual(203); // 200 + '…'
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
test('truncates to <=200 chars with ellipsis when input is long', () => {
|
||||
const longObj = { data: 'x'.repeat(300) }
|
||||
const result = truncateForPreview(longObj)
|
||||
expect(result.length).toBeLessThanOrEqual(203) // 200 + '…'
|
||||
expect(result.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns short input unchanged", () => {
|
||||
const result = truncateForPreview({ a: 1 });
|
||||
expect(result).toBe('{"a":1}');
|
||||
expect(result.endsWith("…")).toBe(false);
|
||||
});
|
||||
test('returns short input unchanged', () => {
|
||||
const result = truncateForPreview({ a: 1 })
|
||||
expect(result).toBe('{"a":1}')
|
||||
expect(result.endsWith('…')).toBe(false)
|
||||
})
|
||||
|
||||
test("handles string input", () => {
|
||||
const result = truncateForPreview("hello");
|
||||
expect(result).toBe('"hello"');
|
||||
});
|
||||
test('handles string input', () => {
|
||||
const result = truncateForPreview('hello')
|
||||
expect(result).toBe('"hello"')
|
||||
})
|
||||
|
||||
test("handles null input", () => {
|
||||
const result = truncateForPreview(null);
|
||||
expect(result).toBe("null");
|
||||
});
|
||||
test('handles null input', () => {
|
||||
const result = truncateForPreview(null)
|
||||
expect(result).toBe('null')
|
||||
})
|
||||
|
||||
test("handles undefined input", () => {
|
||||
const result = truncateForPreview(undefined);
|
||||
test('handles undefined input', () => {
|
||||
const result = truncateForPreview(undefined)
|
||||
// JSON.stringify(undefined) returns undefined, then .length throws → catch returns '(unserializable)'
|
||||
expect(result).toBe("(unserializable)");
|
||||
});
|
||||
});
|
||||
expect(result).toBe('(unserializable)')
|
||||
})
|
||||
})
|
||||
|
||||
describe("PERMISSION_REPLY_RE", () => {
|
||||
describe('PERMISSION_REPLY_RE', () => {
|
||||
test("matches 'y abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("y abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('y abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'yes abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('yes abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'n abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("n abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('n abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'no abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("no abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('no abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("Y abcde")).toBe(true);
|
||||
expect(PERMISSION_REPLY_RE.test("YES abcde")).toBe(true);
|
||||
});
|
||||
test('is case-insensitive', () => {
|
||||
expect(PERMISSION_REPLY_RE.test('Y abcde')).toBe(true)
|
||||
expect(PERMISSION_REPLY_RE.test('YES abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match without ID", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes")).toBe(false);
|
||||
});
|
||||
test('does not match without ID', () => {
|
||||
expect(PERMISSION_REPLY_RE.test('yes')).toBe(false)
|
||||
})
|
||||
|
||||
test("captures the ID from reply", () => {
|
||||
const match = "y abcde".match(PERMISSION_REPLY_RE);
|
||||
expect(match?.[2]).toBe("abcde");
|
||||
});
|
||||
});
|
||||
test('captures the ID from reply', () => {
|
||||
const match = 'y abcde'.match(PERMISSION_REPLY_RE)
|
||||
expect(match?.[2]).toBe('abcde')
|
||||
})
|
||||
})
|
||||
|
||||
describe("createChannelPermissionCallbacks", () => {
|
||||
test("resolve returns false for unknown request ID", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
expect(cb.resolve("unknown-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
describe('createChannelPermissionCallbacks', () => {
|
||||
test('resolve returns false for unknown request ID', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
expect(cb.resolve('unknown-id', 'allow', 'server')).toBe(false)
|
||||
})
|
||||
|
||||
test("onResponse + resolve triggers handler", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("test-id", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("test-id", "allow", "test-server")).toBe(true);
|
||||
test('onResponse + resolve triggers handler', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
let received: any = null
|
||||
cb.onResponse('test-id', response => {
|
||||
received = response
|
||||
})
|
||||
expect(cb.resolve('test-id', 'allow', 'test-server')).toBe(true)
|
||||
expect(received).toEqual({
|
||||
behavior: "allow",
|
||||
fromServer: "test-server",
|
||||
});
|
||||
});
|
||||
behavior: 'allow',
|
||||
fromServer: 'test-server',
|
||||
})
|
||||
})
|
||||
|
||||
test("onResponse unsubscribe prevents resolve", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let called = false;
|
||||
const unsub = cb.onResponse("test-id", () => {
|
||||
called = true;
|
||||
});
|
||||
unsub();
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
test('onResponse unsubscribe prevents resolve', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
let called = false
|
||||
const unsub = cb.onResponse('test-id', () => {
|
||||
called = true
|
||||
})
|
||||
unsub()
|
||||
expect(cb.resolve('test-id', 'allow', 'server')).toBe(false)
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
test("duplicate resolve returns false (already consumed)", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
cb.onResponse("test-id", () => {});
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(true);
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
test('duplicate resolve returns false (already consumed)', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
cb.onResponse('test-id', () => {})
|
||||
expect(cb.resolve('test-id', 'allow', 'server')).toBe(true)
|
||||
expect(cb.resolve('test-id', 'allow', 'server')).toBe(false)
|
||||
})
|
||||
|
||||
test("is case-insensitive for request IDs", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("ABC", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("abc", "deny", "server")).toBe(true);
|
||||
expect(received?.behavior).toBe("deny");
|
||||
});
|
||||
});
|
||||
test('is case-insensitive for request IDs', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
let received: any = null
|
||||
cb.onResponse('ABC', response => {
|
||||
received = response
|
||||
})
|
||||
expect(cb.resolve('abc', 'deny', 'server')).toBe(true)
|
||||
expect(received?.behavior).toBe('deny')
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterPermissionRelayClients", () => {
|
||||
test("requires truthy permission capability", () => {
|
||||
describe('filterPermissionRelayClients', () => {
|
||||
test('requires truthy permission capability', () => {
|
||||
const clients = [
|
||||
{
|
||||
type: "connected",
|
||||
name: "plugin:weixin:weixin",
|
||||
type: 'connected',
|
||||
name: 'plugin:weixin:weixin',
|
||||
capabilities: {
|
||||
experimental: {
|
||||
"claude/channel": {},
|
||||
"claude/channel/permission": false,
|
||||
'claude/channel': {},
|
||||
'claude/channel/permission': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "connected",
|
||||
name: "plugin:telegram:telegram",
|
||||
type: 'connected',
|
||||
name: 'plugin:telegram:telegram',
|
||||
capabilities: {
|
||||
experimental: {
|
||||
"claude/channel": {},
|
||||
"claude/channel/permission": {},
|
||||
'claude/channel': {},
|
||||
'claude/channel/permission': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
expect(
|
||||
filterPermissionRelayClients(clients, () => true).map(client => client.name),
|
||||
).toEqual(["plugin:telegram:telegram"]);
|
||||
});
|
||||
});
|
||||
filterPermissionRelayClients(clients, () => true).map(
|
||||
client => client.name,
|
||||
),
|
||||
).toEqual(['plugin:telegram:telegram'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,148 +1,148 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { expandEnvVarsInString } from "../envExpansion";
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { expandEnvVarsInString } from '../envExpansion'
|
||||
|
||||
const ENV_OPEN = "$" + "{";
|
||||
const ENV_CLOSE = "}";
|
||||
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`;
|
||||
const ENV_OPEN = '$' + '{'
|
||||
const ENV_CLOSE = '}'
|
||||
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`
|
||||
|
||||
describe("expandEnvVarsInString", () => {
|
||||
describe('expandEnvVarsInString', () => {
|
||||
// Save and restore env vars touched by tests
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
const trackedKeys = [
|
||||
"TEST_HOME",
|
||||
"MISSING",
|
||||
"TEST_A",
|
||||
"TEST_B",
|
||||
"TEST_EMPTY",
|
||||
"TEST_X",
|
||||
"VAR",
|
||||
"TEST_FOUND",
|
||||
];
|
||||
'TEST_HOME',
|
||||
'MISSING',
|
||||
'TEST_A',
|
||||
'TEST_B',
|
||||
'TEST_EMPTY',
|
||||
'TEST_X',
|
||||
'VAR',
|
||||
'TEST_FOUND',
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
savedEnv[key] = process.env[key]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
process.env[key] = savedEnv[key]
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("expands a single env var that exists", () => {
|
||||
process.env.TEST_HOME = "/home/user";
|
||||
const result = expandEnvVarsInString(envExpr("TEST_HOME"));
|
||||
expect(result.expanded).toBe("/home/user");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('expands a single env var that exists', () => {
|
||||
process.env.TEST_HOME = '/home/user'
|
||||
const result = expandEnvVarsInString(envExpr('TEST_HOME'))
|
||||
expect(result.expanded).toBe('/home/user')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("returns original placeholder and tracks missing var when not found", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString(envExpr("MISSING"));
|
||||
expect(result.expanded).toBe(envExpr("MISSING"));
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
test('returns original placeholder and tracks missing var when not found', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING'))
|
||||
expect(result.expanded).toBe(envExpr('MISSING'))
|
||||
expect(result.missingVars).toEqual(['MISSING'])
|
||||
})
|
||||
|
||||
test("uses default value when var is missing and default is provided", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString(envExpr("MISSING:-fallback"));
|
||||
expect(result.expanded).toBe("fallback");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('uses default value when var is missing and default is provided', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING:-fallback'))
|
||||
expect(result.expanded).toBe('fallback')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("expands multiple vars", () => {
|
||||
process.env.TEST_A = "hello";
|
||||
process.env.TEST_B = "world";
|
||||
test('expands multiple vars', () => {
|
||||
process.env.TEST_A = 'hello'
|
||||
process.env.TEST_B = 'world'
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr("TEST_A")}/${envExpr("TEST_B")}`,
|
||||
);
|
||||
expect(result.expanded).toBe("hello/world");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
`${envExpr('TEST_A')}/${envExpr('TEST_B')}`,
|
||||
)
|
||||
expect(result.expanded).toBe('hello/world')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles mix of found and missing vars", () => {
|
||||
process.env.TEST_FOUND = "yes";
|
||||
delete process.env.MISSING;
|
||||
test('handles mix of found and missing vars', () => {
|
||||
process.env.TEST_FOUND = 'yes'
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr("TEST_FOUND")}-${envExpr("MISSING")}`,
|
||||
);
|
||||
expect(result.expanded).toBe(`yes-${envExpr("MISSING")}`);
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
`${envExpr('TEST_FOUND')}-${envExpr('MISSING')}`,
|
||||
)
|
||||
expect(result.expanded).toBe(`yes-${envExpr('MISSING')}`)
|
||||
expect(result.missingVars).toEqual(['MISSING'])
|
||||
})
|
||||
|
||||
test("returns plain string unchanged with empty missingVars", () => {
|
||||
const result = expandEnvVarsInString("plain string");
|
||||
expect(result.expanded).toBe("plain string");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('returns plain string unchanged with empty missingVars', () => {
|
||||
const result = expandEnvVarsInString('plain string')
|
||||
expect(result.expanded).toBe('plain string')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("expands empty env var value", () => {
|
||||
process.env.TEST_EMPTY = "";
|
||||
const result = expandEnvVarsInString(envExpr("TEST_EMPTY"));
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('expands empty env var value', () => {
|
||||
process.env.TEST_EMPTY = ''
|
||||
const result = expandEnvVarsInString(envExpr('TEST_EMPTY'))
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("prefers env var value over default when var exists", () => {
|
||||
process.env.TEST_X = "real";
|
||||
const result = expandEnvVarsInString(envExpr("TEST_X:-default"));
|
||||
expect(result.expanded).toBe("real");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('prefers env var value over default when var exists', () => {
|
||||
process.env.TEST_X = 'real'
|
||||
const result = expandEnvVarsInString(envExpr('TEST_X:-default'))
|
||||
expect(result.expanded).toBe('real')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles default value containing colons", () => {
|
||||
test('handles default value containing colons', () => {
|
||||
// split(':-', 2) means only the first :- is the delimiter
|
||||
delete process.env.TEST_X;
|
||||
const result = expandEnvVarsInString(envExpr("TEST_X:-value:-with:-colons"));
|
||||
delete process.env.TEST_X
|
||||
const result = expandEnvVarsInString(envExpr('TEST_X:-value:-with:-colons'))
|
||||
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
|
||||
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
|
||||
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
|
||||
expect(result.expanded).toBe("value");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
expect(result.expanded).toBe('value')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles nested-looking syntax as literal (not supported)", () => {
|
||||
test('handles nested-looking syntax as literal (not supported)', () => {
|
||||
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
|
||||
// so varName would be "${VAR" which won't be found in env
|
||||
delete process.env.VAR;
|
||||
const nestedExpr = `${ENV_OPEN}${envExpr("VAR")}${ENV_CLOSE}`;
|
||||
const result = expandEnvVarsInString(nestedExpr);
|
||||
delete process.env.VAR
|
||||
const nestedExpr = `${ENV_OPEN}${envExpr('VAR')}${ENV_CLOSE}`
|
||||
const result = expandEnvVarsInString(nestedExpr)
|
||||
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
|
||||
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
|
||||
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`]);
|
||||
expect(result.expanded).toBe(nestedExpr);
|
||||
});
|
||||
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`])
|
||||
expect(result.expanded).toBe(nestedExpr)
|
||||
})
|
||||
|
||||
test("handles empty string input", () => {
|
||||
const result = expandEnvVarsInString("");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles empty string input', () => {
|
||||
const result = expandEnvVarsInString('')
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles var surrounded by text", () => {
|
||||
process.env.TEST_A = "middle";
|
||||
const result = expandEnvVarsInString(`before-${envExpr("TEST_A")}-after`);
|
||||
expect(result.expanded).toBe("before-middle-after");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles var surrounded by text', () => {
|
||||
process.env.TEST_A = 'middle'
|
||||
const result = expandEnvVarsInString(`before-${envExpr('TEST_A')}-after`)
|
||||
expect(result.expanded).toBe('before-middle-after')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles default value that is empty string", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString(envExpr("MISSING:-"));
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles default value that is empty string', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING:-'))
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("does not expand $VAR without braces", () => {
|
||||
process.env.TEST_A = "value";
|
||||
const result = expandEnvVarsInString("$TEST_A");
|
||||
expect(result.expanded).toBe("$TEST_A");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('does not expand $VAR without braces', () => {
|
||||
process.env.TEST_A = 'value'
|
||||
const result = expandEnvVarsInString('$TEST_A')
|
||||
expect(result.expanded).toBe('$TEST_A')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
// parseHeaders is a pure function from ../utils.ts (line 325)
|
||||
// Copied here to avoid triggering the heavy import chain of utils.ts
|
||||
function parseHeaders(headerArray: string[]): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
for (const header of headerArray) {
|
||||
const colonIndex = header.indexOf(":")
|
||||
const colonIndex = header.indexOf(':')
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid header format: "${header}". Expected format: "Header-Name: value"`,
|
||||
@@ -23,43 +23,43 @@ function parseHeaders(headerArray: string[]): Record<string, string> {
|
||||
return headers
|
||||
}
|
||||
|
||||
describe("parseHeaders", () => {
|
||||
describe('parseHeaders', () => {
|
||||
test("parses 'Key: Value' format", () => {
|
||||
expect(parseHeaders(["Content-Type: application/json"])).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
expect(parseHeaders(['Content-Type: application/json'])).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses multiple headers", () => {
|
||||
expect(parseHeaders(["Key1: val1", "Key2: val2"])).toEqual({
|
||||
Key1: "val1",
|
||||
Key2: "val2",
|
||||
});
|
||||
});
|
||||
test('parses multiple headers', () => {
|
||||
expect(parseHeaders(['Key1: val1', 'Key2: val2'])).toEqual({
|
||||
Key1: 'val1',
|
||||
Key2: 'val2',
|
||||
})
|
||||
})
|
||||
|
||||
test("trims whitespace around key and value", () => {
|
||||
expect(parseHeaders([" Key : Value "])).toEqual({ Key: "Value" });
|
||||
});
|
||||
test('trims whitespace around key and value', () => {
|
||||
expect(parseHeaders([' Key : Value '])).toEqual({ Key: 'Value' })
|
||||
})
|
||||
|
||||
test("throws on missing colon", () => {
|
||||
expect(() => parseHeaders(["no colon here"])).toThrow();
|
||||
});
|
||||
test('throws on missing colon', () => {
|
||||
expect(() => parseHeaders(['no colon here'])).toThrow()
|
||||
})
|
||||
|
||||
test("throws on empty key", () => {
|
||||
expect(() => parseHeaders([": value"])).toThrow();
|
||||
});
|
||||
test('throws on empty key', () => {
|
||||
expect(() => parseHeaders([': value'])).toThrow()
|
||||
})
|
||||
|
||||
test("handles value with colons (like URLs)", () => {
|
||||
expect(parseHeaders(["url: http://example.com:8080"])).toEqual({
|
||||
url: "http://example.com:8080",
|
||||
});
|
||||
});
|
||||
test('handles value with colons (like URLs)', () => {
|
||||
expect(parseHeaders(['url: http://example.com:8080'])).toEqual({
|
||||
url: 'http://example.com:8080',
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty object for empty array", () => {
|
||||
expect(parseHeaders([])).toEqual({});
|
||||
});
|
||||
test('returns empty object for empty array', () => {
|
||||
expect(parseHeaders([])).toEqual({})
|
||||
})
|
||||
|
||||
test("handles duplicate keys (last wins)", () => {
|
||||
expect(parseHeaders(["K: v1", "K: v2"])).toEqual({ K: "v2" });
|
||||
});
|
||||
});
|
||||
test('handles duplicate keys (last wins)', () => {
|
||||
expect(parseHeaders(['K: v1', 'K: v2'])).toEqual({ K: 'v2' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
mcpInfoFromString,
|
||||
buildMcpToolName,
|
||||
@@ -6,135 +6,133 @@ import {
|
||||
getMcpDisplayName,
|
||||
getToolNameForPermissionCheck,
|
||||
extractMcpToolDisplayName,
|
||||
} from "../mcpStringUtils";
|
||||
} from '../mcpStringUtils'
|
||||
|
||||
// ─── mcpInfoFromString ─────────────────────────────────────────────────
|
||||
|
||||
describe("mcpInfoFromString", () => {
|
||||
test("parses standard mcp tool name", () => {
|
||||
const result = mcpInfoFromString("mcp__github__list_issues");
|
||||
expect(result).toEqual({ serverName: "github", toolName: "list_issues" });
|
||||
});
|
||||
describe('mcpInfoFromString', () => {
|
||||
test('parses standard mcp tool name', () => {
|
||||
const result = mcpInfoFromString('mcp__github__list_issues')
|
||||
expect(result).toEqual({ serverName: 'github', toolName: 'list_issues' })
|
||||
})
|
||||
|
||||
test("returns null for non-mcp string", () => {
|
||||
expect(mcpInfoFromString("Bash")).toBeNull();
|
||||
expect(mcpInfoFromString("grep__pattern")).toBeNull();
|
||||
});
|
||||
test('returns null for non-mcp string', () => {
|
||||
expect(mcpInfoFromString('Bash')).toBeNull()
|
||||
expect(mcpInfoFromString('grep__pattern')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when no server name", () => {
|
||||
expect(mcpInfoFromString("mcp__")).toBeNull();
|
||||
});
|
||||
test('returns null when no server name', () => {
|
||||
expect(mcpInfoFromString('mcp__')).toBeNull()
|
||||
})
|
||||
|
||||
test("handles server name only (no tool)", () => {
|
||||
const result = mcpInfoFromString("mcp__server");
|
||||
expect(result).toEqual({ serverName: "server", toolName: undefined });
|
||||
});
|
||||
test('handles server name only (no tool)', () => {
|
||||
const result = mcpInfoFromString('mcp__server')
|
||||
expect(result).toEqual({ serverName: 'server', toolName: undefined })
|
||||
})
|
||||
|
||||
test("preserves double underscores in tool name", () => {
|
||||
const result = mcpInfoFromString("mcp__server__tool__with__underscores");
|
||||
test('preserves double underscores in tool name', () => {
|
||||
const result = mcpInfoFromString('mcp__server__tool__with__underscores')
|
||||
expect(result).toEqual({
|
||||
serverName: "server",
|
||||
toolName: "tool__with__underscores",
|
||||
});
|
||||
});
|
||||
serverName: 'server',
|
||||
toolName: 'tool__with__underscores',
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(mcpInfoFromString("")).toBeNull();
|
||||
});
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(mcpInfoFromString('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getMcpPrefix ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getMcpPrefix", () => {
|
||||
test("creates prefix from server name", () => {
|
||||
expect(getMcpPrefix("github")).toBe("mcp__github__");
|
||||
});
|
||||
describe('getMcpPrefix', () => {
|
||||
test('creates prefix from server name', () => {
|
||||
expect(getMcpPrefix('github')).toBe('mcp__github__')
|
||||
})
|
||||
|
||||
test("normalizes server name with special chars", () => {
|
||||
expect(getMcpPrefix("my-server")).toBe("mcp__my-server__");
|
||||
});
|
||||
test('normalizes server name with special chars', () => {
|
||||
expect(getMcpPrefix('my-server')).toBe('mcp__my-server__')
|
||||
})
|
||||
|
||||
test("normalizes dots to underscores", () => {
|
||||
expect(getMcpPrefix("my.server")).toBe("mcp__my_server__");
|
||||
});
|
||||
});
|
||||
test('normalizes dots to underscores', () => {
|
||||
expect(getMcpPrefix('my.server')).toBe('mcp__my_server__')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── buildMcpToolName ──────────────────────────────────────────────────
|
||||
|
||||
describe("buildMcpToolName", () => {
|
||||
test("builds fully qualified name", () => {
|
||||
expect(buildMcpToolName("github", "list_issues")).toBe(
|
||||
"mcp__github__list_issues"
|
||||
);
|
||||
});
|
||||
describe('buildMcpToolName', () => {
|
||||
test('builds fully qualified name', () => {
|
||||
expect(buildMcpToolName('github', 'list_issues')).toBe(
|
||||
'mcp__github__list_issues',
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes both server and tool names", () => {
|
||||
expect(buildMcpToolName("my.server", "my.tool")).toBe(
|
||||
"mcp__my_server__my_tool"
|
||||
);
|
||||
});
|
||||
});
|
||||
test('normalizes both server and tool names', () => {
|
||||
expect(buildMcpToolName('my.server', 'my.tool')).toBe(
|
||||
'mcp__my_server__my_tool',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getMcpDisplayName ─────────────────────────────────────────────────
|
||||
|
||||
describe("getMcpDisplayName", () => {
|
||||
test("strips mcp prefix from full name", () => {
|
||||
expect(getMcpDisplayName("mcp__github__list_issues", "github")).toBe(
|
||||
"list_issues"
|
||||
);
|
||||
});
|
||||
describe('getMcpDisplayName', () => {
|
||||
test('strips mcp prefix from full name', () => {
|
||||
expect(getMcpDisplayName('mcp__github__list_issues', 'github')).toBe(
|
||||
'list_issues',
|
||||
)
|
||||
})
|
||||
|
||||
test("returns full name if prefix doesn't match", () => {
|
||||
expect(getMcpDisplayName("mcp__other__tool", "github")).toBe(
|
||||
"mcp__other__tool"
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getMcpDisplayName('mcp__other__tool', 'github')).toBe(
|
||||
'mcp__other__tool',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getToolNameForPermissionCheck ─────────────────────────────────────
|
||||
|
||||
describe("getToolNameForPermissionCheck", () => {
|
||||
test("returns built MCP name for MCP tools", () => {
|
||||
describe('getToolNameForPermissionCheck', () => {
|
||||
test('returns built MCP name for MCP tools', () => {
|
||||
const tool = {
|
||||
name: "list_issues",
|
||||
mcpInfo: { serverName: "github", toolName: "list_issues" },
|
||||
};
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe(
|
||||
"mcp__github__list_issues"
|
||||
);
|
||||
});
|
||||
name: 'list_issues',
|
||||
mcpInfo: { serverName: 'github', toolName: 'list_issues' },
|
||||
}
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe('mcp__github__list_issues')
|
||||
})
|
||||
|
||||
test("returns tool name for non-MCP tools", () => {
|
||||
const tool = { name: "Bash" };
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe("Bash");
|
||||
});
|
||||
test('returns tool name for non-MCP tools', () => {
|
||||
const tool = { name: 'Bash' }
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe('Bash')
|
||||
})
|
||||
|
||||
test("returns tool name when mcpInfo is undefined", () => {
|
||||
const tool = { name: "Write", mcpInfo: undefined };
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe("Write");
|
||||
});
|
||||
});
|
||||
test('returns tool name when mcpInfo is undefined', () => {
|
||||
const tool = { name: 'Write', mcpInfo: undefined }
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe('Write')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── extractMcpToolDisplayName ─────────────────────────────────────────
|
||||
|
||||
describe("extractMcpToolDisplayName", () => {
|
||||
test("extracts display name from full user-facing name", () => {
|
||||
describe('extractMcpToolDisplayName', () => {
|
||||
test('extracts display name from full user-facing name', () => {
|
||||
expect(
|
||||
extractMcpToolDisplayName("github - Add comment to issue (MCP)")
|
||||
).toBe("Add comment to issue");
|
||||
});
|
||||
extractMcpToolDisplayName('github - Add comment to issue (MCP)'),
|
||||
).toBe('Add comment to issue')
|
||||
})
|
||||
|
||||
test("removes (MCP) suffix only", () => {
|
||||
expect(extractMcpToolDisplayName("simple-tool (MCP)")).toBe("simple-tool");
|
||||
});
|
||||
test('removes (MCP) suffix only', () => {
|
||||
expect(extractMcpToolDisplayName('simple-tool (MCP)')).toBe('simple-tool')
|
||||
})
|
||||
|
||||
test("handles name without (MCP) suffix", () => {
|
||||
expect(extractMcpToolDisplayName("github - List issues")).toBe(
|
||||
"List issues"
|
||||
);
|
||||
});
|
||||
test('handles name without (MCP) suffix', () => {
|
||||
expect(extractMcpToolDisplayName('github - List issues')).toBe(
|
||||
'List issues',
|
||||
)
|
||||
})
|
||||
|
||||
test("handles name without dash separator", () => {
|
||||
expect(extractMcpToolDisplayName("just-a-name")).toBe("just-a-name");
|
||||
});
|
||||
});
|
||||
test('handles name without dash separator', () => {
|
||||
expect(extractMcpToolDisplayName('just-a-name')).toBe('just-a-name')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeNameForMCP } from "../normalization";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { normalizeNameForMCP } from '../normalization'
|
||||
|
||||
describe("normalizeNameForMCP", () => {
|
||||
test("returns simple valid name unchanged", () => {
|
||||
expect(normalizeNameForMCP("my-server")).toBe("my-server");
|
||||
});
|
||||
describe('normalizeNameForMCP', () => {
|
||||
test('returns simple valid name unchanged', () => {
|
||||
expect(normalizeNameForMCP('my-server')).toBe('my-server')
|
||||
})
|
||||
|
||||
test("replaces dots with underscores", () => {
|
||||
expect(normalizeNameForMCP("my.server.name")).toBe("my_server_name");
|
||||
});
|
||||
test('replaces dots with underscores', () => {
|
||||
expect(normalizeNameForMCP('my.server.name')).toBe('my_server_name')
|
||||
})
|
||||
|
||||
test("replaces spaces with underscores", () => {
|
||||
expect(normalizeNameForMCP("my server")).toBe("my_server");
|
||||
});
|
||||
test('replaces spaces with underscores', () => {
|
||||
expect(normalizeNameForMCP('my server')).toBe('my_server')
|
||||
})
|
||||
|
||||
test("replaces special characters with underscores", () => {
|
||||
expect(normalizeNameForMCP("server@v2!")).toBe("server_v2_");
|
||||
});
|
||||
test('replaces special characters with underscores', () => {
|
||||
expect(normalizeNameForMCP('server@v2!')).toBe('server_v2_')
|
||||
})
|
||||
|
||||
test("returns already valid name unchanged", () => {
|
||||
expect(normalizeNameForMCP("valid_name-123")).toBe("valid_name-123");
|
||||
});
|
||||
test('returns already valid name unchanged', () => {
|
||||
expect(normalizeNameForMCP('valid_name-123')).toBe('valid_name-123')
|
||||
})
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(normalizeNameForMCP("")).toBe("");
|
||||
});
|
||||
test('returns empty string for empty input', () => {
|
||||
expect(normalizeNameForMCP('')).toBe('')
|
||||
})
|
||||
|
||||
test("handles claude.ai prefix: collapses consecutive underscores and strips edges", () => {
|
||||
test('handles claude.ai prefix: collapses consecutive underscores and strips edges', () => {
|
||||
// "claude.ai My Server" -> replace invalid -> "claude_ai_My_Server"
|
||||
// starts with "claude.ai " so collapse + strip -> "claude_ai_My_Server"
|
||||
expect(normalizeNameForMCP("claude.ai My Server")).toBe(
|
||||
"claude_ai_My_Server"
|
||||
);
|
||||
});
|
||||
expect(normalizeNameForMCP('claude.ai My Server')).toBe(
|
||||
'claude_ai_My_Server',
|
||||
)
|
||||
})
|
||||
|
||||
test("handles claude.ai prefix with consecutive invalid chars", () => {
|
||||
test('handles claude.ai prefix with consecutive invalid chars', () => {
|
||||
// "claude.ai ...test..." -> replace invalid -> "claude_ai____test___"
|
||||
// collapse consecutive _ -> "claude_ai_test_"
|
||||
// strip leading/trailing _ -> "claude_ai_test"
|
||||
expect(normalizeNameForMCP("claude.ai ...test...")).toBe("claude_ai_test");
|
||||
});
|
||||
expect(normalizeNameForMCP('claude.ai ...test...')).toBe('claude_ai_test')
|
||||
})
|
||||
|
||||
test("non-claude.ai name preserves consecutive underscores", () => {
|
||||
test('non-claude.ai name preserves consecutive underscores', () => {
|
||||
// "a..b" -> "a__b", no claude.ai prefix so no collapse
|
||||
expect(normalizeNameForMCP("a..b")).toBe("a__b");
|
||||
});
|
||||
expect(normalizeNameForMCP('a..b')).toBe('a__b')
|
||||
})
|
||||
|
||||
test("non-claude.ai name preserves trailing underscores", () => {
|
||||
expect(normalizeNameForMCP("name!")).toBe("name_");
|
||||
});
|
||||
test('non-claude.ai name preserves trailing underscores', () => {
|
||||
expect(normalizeNameForMCP('name!')).toBe('name_')
|
||||
})
|
||||
|
||||
test("handles claude.ai prefix that results in only underscores", () => {
|
||||
test('handles claude.ai prefix that results in only underscores', () => {
|
||||
// "claude.ai ..." -> replace invalid -> "claude_ai____"
|
||||
// collapse -> "claude_ai_"
|
||||
// strip trailing -> "claude_ai"
|
||||
expect(normalizeNameForMCP("claude.ai ...")).toBe("claude_ai");
|
||||
});
|
||||
});
|
||||
expect(normalizeNameForMCP('claude.ai ...')).toBe('claude_ai')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { mock, describe, expect, test, afterEach } from "bun:test";
|
||||
import { debugMock } from "../../../../tests/mocks/debug";
|
||||
import { mock, describe, expect, test, afterEach } from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug'
|
||||
|
||||
mock.module("axios", () => ({
|
||||
mock.module('axios', () => ({
|
||||
default: { get: async () => ({ data: { servers: [] } }) },
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
}))
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
|
||||
"../officialRegistry"
|
||||
);
|
||||
'../officialRegistry'
|
||||
)
|
||||
|
||||
describe("isOfficialMcpUrl", () => {
|
||||
describe('isOfficialMcpUrl', () => {
|
||||
afterEach(() => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
});
|
||||
resetOfficialMcpUrlsForTesting()
|
||||
})
|
||||
|
||||
test("returns false when registry not loaded (initial state)", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://example.com")).toBe(false);
|
||||
});
|
||||
test('returns false when registry not loaded (initial state)', () => {
|
||||
resetOfficialMcpUrlsForTesting()
|
||||
expect(isOfficialMcpUrl('https://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for non-registered URL", () => {
|
||||
expect(isOfficialMcpUrl("https://random-server.com/mcp")).toBe(false);
|
||||
});
|
||||
test('returns false for non-registered URL', () => {
|
||||
expect(isOfficialMcpUrl('https://random-server.com/mcp')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isOfficialMcpUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for empty string', () => {
|
||||
expect(isOfficialMcpUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetOfficialMcpUrlsForTesting", () => {
|
||||
test("can be called without error", () => {
|
||||
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow();
|
||||
});
|
||||
describe('resetOfficialMcpUrlsForTesting', () => {
|
||||
test('can be called without error', () => {
|
||||
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow()
|
||||
})
|
||||
|
||||
test("clears state so subsequent lookups return false", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://anything.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('clears state so subsequent lookups return false', () => {
|
||||
resetOfficialMcpUrlsForTesting()
|
||||
expect(isOfficialMcpUrl('https://anything.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
export function createMcpAnalytics(): AnalyticsSink {
|
||||
return {
|
||||
trackEvent(event: string, metadata: Record<string, unknown>) {
|
||||
logEvent(event, metadata as Record<string, AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS>)
|
||||
logEvent(
|
||||
event,
|
||||
metadata as Record<
|
||||
string,
|
||||
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
>,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import { maybeResizeAndDownsampleImageBuffer } from '../../../utils/imageResizer
|
||||
export function createMcpImageProcessor(): ImageProcessor {
|
||||
return {
|
||||
async resizeAndDownsample(buffer: Buffer) {
|
||||
const result = await maybeResizeAndDownsampleImageBuffer(buffer, buffer.length, 'png')
|
||||
const result = await maybeResizeAndDownsampleImageBuffer(
|
||||
buffer,
|
||||
buffer.length,
|
||||
'png',
|
||||
)
|
||||
return result.buffer
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import type { ContentStorage } from '@claude-code-best/mcp-client'
|
||||
import { persistBinaryContent } from '../../../utils/mcpOutputStorage.js'
|
||||
import { persistToolResult, isPersistError } from '../../../utils/toolResultStorage.js'
|
||||
import {
|
||||
persistToolResult,
|
||||
isPersistError,
|
||||
} from '../../../utils/toolResultStorage.js'
|
||||
|
||||
/**
|
||||
* Creates a ContentStorage implementation using the host's binary persistence.
|
||||
@@ -10,7 +13,11 @@ import { persistToolResult, isPersistError } from '../../../utils/toolResultStor
|
||||
export function createMcpStorage(): ContentStorage {
|
||||
return {
|
||||
async persistBinaryContent(data: Buffer, ext: string) {
|
||||
const result = await persistBinaryContent(data, ext, `mcp-adapter-${Date.now()}`)
|
||||
const result = await persistBinaryContent(
|
||||
data,
|
||||
ext,
|
||||
`mcp-adapter-${Date.now()}`,
|
||||
)
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function isChannelAllowlisted(
|
||||
if (!pluginSource) return false
|
||||
const { name, marketplace } = parsePluginIdentifier(pluginSource)
|
||||
if (!marketplace) return false
|
||||
if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') {
|
||||
if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') {
|
||||
return true
|
||||
}
|
||||
return getChannelAllowlist().some(
|
||||
|
||||
@@ -20,9 +20,7 @@ import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod/v4'
|
||||
import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js'
|
||||
import { CHANNEL_TAG } from '../../constants/xml.js'
|
||||
import {
|
||||
getSubscriptionType,
|
||||
} from '../../utils/auth.js'
|
||||
import { getSubscriptionType } from '../../utils/auth.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
||||
import { getSettingsForSource } from '../../utils/settings/settings.js'
|
||||
@@ -259,7 +257,6 @@ export function gateChannelServer(
|
||||
reason: `you asked for plugin:${entry.name}@${entry.marketplace} but the installed ${entry.name} plugin is from ${actual ?? 'an unknown source'}`,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return { action: 'register' }
|
||||
|
||||
@@ -51,7 +51,10 @@ import {
|
||||
toolMatchesName,
|
||||
} from '../../Tool.js'
|
||||
import { ListMcpResourcesTool } from '@claude-code-best/builtin-tools/tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
|
||||
import { type MCPProgress, MCPTool } from '@claude-code-best/builtin-tools/tools/MCPTool/MCPTool.js'
|
||||
import {
|
||||
type MCPProgress,
|
||||
MCPTool,
|
||||
} from '@claude-code-best/builtin-tools/tools/MCPTool/MCPTool.js'
|
||||
import { createMcpAuthTool } from '@claude-code-best/builtin-tools/tools/McpAuthTool/McpAuthTool.js'
|
||||
import { ReadMcpResourceTool } from '@claude-code-best/builtin-tools/tools/ReadMcpResourceTool/ReadMcpResourceTool.js'
|
||||
import { createAbortController } from '../../utils/abortController.js'
|
||||
@@ -903,7 +906,8 @@ export const connectToServer = memoize(
|
||||
)
|
||||
logMCPDebug(name, `claude.ai proxy transport created successfully`)
|
||||
} else if (
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) &&
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' ||
|
||||
!(serverRef as ScopedMcpServerConfig).type) &&
|
||||
isClaudeInChromeMCPServer(name)
|
||||
) {
|
||||
// Run the Chrome MCP server in-process to avoid spawning a ~325 MB subprocess
|
||||
@@ -916,7 +920,9 @@ export const connectToServer = memoize(
|
||||
const { createLinkedTransportPair } = await import(
|
||||
'./InProcessTransport.js'
|
||||
)
|
||||
const context = createChromeContext((serverRef as McpStdioServerConfig).env)
|
||||
const context = createChromeContext(
|
||||
(serverRef as McpStdioServerConfig).env,
|
||||
)
|
||||
inProcessServer = createClaudeForChromeMcpServer(context)
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
@@ -924,7 +930,8 @@ export const connectToServer = memoize(
|
||||
logMCPDebug(name, `In-process Chrome MCP server started`)
|
||||
} else if (
|
||||
feature('CHICAGO_MCP') &&
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) &&
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' ||
|
||||
!(serverRef as ScopedMcpServerConfig).type) &&
|
||||
isComputerUseMCPServer!(name)
|
||||
) {
|
||||
// Run the Computer Use MCP server in-process — same rationale as
|
||||
@@ -941,7 +948,10 @@ export const connectToServer = memoize(
|
||||
await inProcessServer.connect(serverTransport)
|
||||
transport = clientTransport
|
||||
logMCPDebug(name, `In-process Computer Use MCP server started`)
|
||||
} else if ((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) {
|
||||
} else if (
|
||||
(serverRef as ScopedMcpServerConfig).type === 'stdio' ||
|
||||
!(serverRef as ScopedMcpServerConfig).type
|
||||
) {
|
||||
const stdioRef = serverRef as McpStdioServerConfig
|
||||
const finalCommand =
|
||||
process.env.CLAUDE_CODE_SHELL_PREFIX || stdioRef.command
|
||||
@@ -958,7 +968,9 @@ export const connectToServer = memoize(
|
||||
stderr: 'pipe', // prevents error output from the MCP server from printing to the UI
|
||||
})
|
||||
} else {
|
||||
throw new Error(`Unsupported server type: ${(serverRef as ScopedMcpServerConfig).type}`)
|
||||
throw new Error(
|
||||
`Unsupported server type: ${(serverRef as ScopedMcpServerConfig).type}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Set up stderr logging for stdio transport before connecting in case there are any stderr
|
||||
@@ -3247,8 +3259,14 @@ async function callMCPTool({
|
||||
}
|
||||
|
||||
function extractToolUseId(message: AssistantMessage): string | undefined {
|
||||
const firstBlock = (message.message.content as ContentBlockParam[] | undefined)?.[0]
|
||||
if (!firstBlock || typeof firstBlock === 'string' || firstBlock.type !== 'tool_use') {
|
||||
const firstBlock = (
|
||||
message.message.content as ContentBlockParam[] | undefined
|
||||
)?.[0]
|
||||
if (
|
||||
!firstBlock ||
|
||||
typeof firstBlock === 'string' ||
|
||||
firstBlock.type !== 'tool_use'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return firstBlock.id
|
||||
|
||||
@@ -1351,7 +1351,7 @@ export function parseMcpConfig(params: {
|
||||
if (
|
||||
getPlatform() === 'windows' &&
|
||||
(!configToCheck.type || configToCheck.type === 'stdio') &&
|
||||
('command' in configToCheck) &&
|
||||
'command' in configToCheck &&
|
||||
(configToCheck.command === 'npx' ||
|
||||
configToCheck.command.endsWith('\\npx') ||
|
||||
configToCheck.command.endsWith('/npx'))
|
||||
|
||||
@@ -56,8 +56,7 @@ export async function findAvailablePort(): Promise<number> {
|
||||
})
|
||||
})
|
||||
return port
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// If random selection failed, try the fallback port
|
||||
|
||||
@@ -536,9 +536,7 @@ export function useManageMCPConnections(
|
||||
// reply and emits {request_id, behavior}; no regex on our
|
||||
// side, text in the general channel can't accidentally match.
|
||||
if (
|
||||
client.capabilities?.experimental?.[
|
||||
'claude/channel/permission'
|
||||
]
|
||||
client.capabilities?.experimental?.['claude/channel/permission']
|
||||
) {
|
||||
client.client.setNotificationHandler(
|
||||
ChannelPermissionNotificationSchema(),
|
||||
@@ -567,9 +565,7 @@ export function useManageMCPConnections(
|
||||
client.client.removeNotificationHandler(
|
||||
'notifications/claude/channel',
|
||||
)
|
||||
client.client.removeNotificationHandler(
|
||||
CHANNEL_PERMISSION_METHOD,
|
||||
)
|
||||
client.client.removeNotificationHandler(CHANNEL_PERMISSION_METHOD)
|
||||
logMCPDebug(
|
||||
client.name,
|
||||
`Channel notifications skipped: ${gate.reason}`,
|
||||
|
||||
Reference in New Issue
Block a user