Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,31 @@ const tools = await manager.tools.listTools();
const acpCount = await manager.acp.countConversations();
```

### Updating MCP settings

Persisted MCP servers use their settings-map key as stable identity. Create,
update, or delete one server without fetching and resending the whole catalog:

```typescript
const settings = manager.settings;

await settings.createMcpServer('github', {
transport: 'http',
url: 'https://example.test/mcp',
});

await settings.patchMcpServer('github', {
url: 'https://example.test/mcp/v2',
});

await settings.deleteMcpServer('old-server');
```

An omitted patch field preserves its stored value, including credentials.
Supplying a value replaces it, and `null` explicitly clears a supported
optional field or deletes a map entry. Each helper sends one
request to the corresponding Agent Server MCP settings operation.

If you need the lower-level endpoint-specific clients directly, import them from the secondary entrypoint:

```typescript
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/agent-server-contract-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,50 @@
import type { MCPClient } from '../client/mcp-client';
import type { SettingsClient } from '../client/settings-client';
import type {
McpConfigPatchWritable,
McpServerPatchWritable,
} from '../generated/agent-server-schema';
import type {
AgentServerMCPOAuthCallbackRequest,
AgentServerMCPOAuthCallbackResponse,
AgentServerMCPOAuthStatusResponse,
AgentServerMCPStartOAuthRequest,
AgentServerMCPStartOAuthResponse,
AgentServerMCPTestRequest,
AgentServerMCPTestResponse,
AgentServerSettingsPatchRequest,
AgentServerSettingsPatchResponse,
AgentServerSettingsResponse,
AgentServerSettingsSchema,
} from '../models/agent-server-api';
import type {
MCPAuthCredential,
MCPConfig,
MCPConfigPatch,
MCPServer,
MCPServerPatch,
} from '../models/mcp-settings';

type Equal<Left, Right> =
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2
? true
: false;

type IsAny<Value> = 0 extends 1 & Value ? true : false;
type IsBareUnknown<Value> =
IsAny<Value> extends true
? false
: unknown extends Value
? [keyof Value] extends [never]
? true
: false
: false;

type GeneratedMCPClientContract = {
testServer(request: AgentServerMCPTestRequest): Promise<AgentServerMCPTestResponse>;
startOAuth(request: AgentServerMCPStartOAuthRequest): Promise<AgentServerMCPStartOAuthResponse>;
};

function assertType<Type extends true>(_value: Type): void {
// Compiling this call is the assertion.
}
Expand All @@ -29,5 +63,70 @@ describe('Agent Server generated contract aliases', () => {
assertType<
Equal<Awaited<ReturnType<SettingsClient['updateSettings']>>, AgentServerSettingsPatchResponse>
>(true);
assertType<Equal<Parameters<SettingsClient['createMcpServer']>[1], MCPServer>>(true);
assertType<Equal<Parameters<SettingsClient['patchMcpServer']>[1], MCPServerPatch>>(true);
assertType<
Equal<
Awaited<ReturnType<SettingsClient['createMcpServer']>>,
AgentServerSettingsPatchResponse
>
>(true);
assertType<
Equal<Awaited<ReturnType<SettingsClient['patchMcpServer']>>, AgentServerSettingsPatchResponse>
>(true);
assertType<
Equal<
Awaited<ReturnType<SettingsClient['deleteMcpServer']>>,
AgentServerSettingsPatchResponse
>
>(true);
});

it('keeps important MCP contract exports stronger than any or bare unknown', () => {
assertType<Equal<IsAny<MCPServer>, false>>(true);
assertType<Equal<IsBareUnknown<MCPServer>, false>>(true);
assertType<Equal<IsAny<MCPConfig>, false>>(true);
assertType<Equal<IsBareUnknown<MCPConfig>, false>>(true);
assertType<Equal<IsAny<MCPAuthCredential>, false>>(true);
assertType<Equal<IsBareUnknown<MCPAuthCredential>, false>>(true);
assertType<Equal<IsAny<MCPServerPatch>, false>>(true);
assertType<Equal<IsBareUnknown<MCPServerPatch>, false>>(true);
assertType<Equal<IsAny<MCPConfigPatch>, false>>(true);
assertType<Equal<IsBareUnknown<MCPConfigPatch>, false>>(true);
});

it('keeps MCP client methods checked against generated operations', () => {
assertType<MCPClient extends GeneratedMCPClientContract ? true : false>(true);
assertType<
Equal<Awaited<ReturnType<MCPClient['getOAuthStatus']>>, AgentServerMCPOAuthStatusResponse>
>(true);
assertType<
Equal<Parameters<MCPClient['submitOAuthCallback']>[1], AgentServerMCPOAuthCallbackRequest>
>(true);
assertType<
Equal<
Awaited<ReturnType<MCPClient['submitOAuthCallback']>>,
AgentServerMCPOAuthCallbackResponse
>
>(true);
});

it('distinguishes omitted patch fields from explicit clearing', () => {
const omitted = {} satisfies MCPServerPatch;
const cleared = { auth: null } satisfies MCPServerPatch;

expect('auth' in omitted).toBe(false);
expect(cleared.auth).toBeNull();
});

it('derives every MCP patch field from the generated server contract', () => {
assertType<Equal<MCPServerPatch, McpServerPatchWritable>>(true);
assertType<Equal<MCPConfigPatch, McpConfigPatchWritable>>(true);
assertType<Equal<MCPServerPatch['env'], Record<string, string | null> | null | undefined>>(
true
);
assertType<Equal<MCPServerPatch['headers'], Record<string, string | null> | null | undefined>>(
true
);
});
});
2 changes: 1 addition & 1 deletion src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2484,7 +2484,7 @@ describe('Auxiliary API clients', () => {
const request = {
name: 'superhuman-mail',
server: {
transport: 'http' as const,
type: 'http' as const,
url: 'https://mcp.mail.superhuman.com/mcp',
auth: {
strategy: 'oauth2' as const,
Expand Down
220 changes: 220 additions & 0 deletions src/__tests__/mcp-settings-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { SettingsClient } from '../client/settings-client';
import type { MCPConfig, MCPServer } from '../models/mcp-settings';

type JsonObject = Record<string, unknown>;

function clone<Value>(value: Value): Value {
return structuredClone(value);
}

function mergePatch(target: JsonObject, patch: JsonObject): JsonObject {
const result = clone(target);
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete result[key];
} else if (
typeof value === 'object' &&
!Array.isArray(value) &&
typeof result[key] === 'object' &&
result[key] !== null &&
!Array.isArray(result[key])
) {
result[key] = mergePatch(result[key] as JsonObject, value as JsonObject);
} else {
result[key] = clone(value);
}
}
return result;
}

function installStatefulSettingsServer(initial: MCPConfig) {
const catalog = clone(initial) as unknown as JsonObject;
const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
const settingsKey = decodeURIComponent(url.pathname.split('/').at(-1) ?? '');
const method = init?.method;
const body = init?.body ? (JSON.parse(String(init.body)) as JsonObject) : undefined;

if (method === 'POST') {
if (settingsKey in catalog) {
return new Response(null, { status: 409, statusText: 'Conflict' });
}
catalog[settingsKey] = clone(body as unknown as MCPServer) as unknown as JsonObject;
} else if (method === 'PATCH') {
if (!(settingsKey in catalog)) {
return new Response(null, { status: 404, statusText: 'Not Found' });
}
catalog[settingsKey] = mergePatch(catalog[settingsKey] as JsonObject, body ?? {});
} else if (method === 'DELETE') {
if (!(settingsKey in catalog)) {
return new Response(null, { status: 404, statusText: 'Not Found' });
}
delete catalog[settingsKey];
}

return new Response(
JSON.stringify({
agent_settings: { mcp_config: catalog },
conversation_settings: {},
llm_api_key_is_set: false,
}),
{
status: method === 'POST' ? 201 : 200,
headers: { 'content-type': 'application/json' },
}
);
});
global.fetch = fetchMock as typeof fetch;
return {
fetchMock,
getCatalog: () => clone(catalog),
};
}

const initialCatalog = (): MCPConfig => ({
github: {
transport: 'http',
url: 'https://api.githubcopilot.com/mcp/',
auth: { strategy: 'bearer', value: 'github-secret' },
headers: { 'X-Existing': 'keep-me', 'X-Remove': 'old' },
},
filesystem: {
transport: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/workspace'],
env: { FILESYSTEM_TOKEN: 'filesystem-secret' },
},
});

describe('SettingsClient canonical MCP mutations', () => {
it('adds one named server while preserving siblings and credentials', async () => {
const server = installStatefulSettingsServer(initialCatalog());
const client = new SettingsClient({ host: 'http://example.com' });

await client.createMcpServer('docs', {
transport: 'http',
url: 'https://example.test/mcp',
auth: { strategy: 'bearer', value: 'docs-secret' },
});

expect(server.fetchMock).toHaveBeenCalledTimes(1);
expect(server.getCatalog()).toMatchObject({
github: { auth: { strategy: 'bearer', value: 'github-secret' } },
filesystem: { env: { FILESYSTEM_TOKEN: 'filesystem-secret' } },
docs: {
transport: 'http',
url: 'https://example.test/mcp',
auth: { strategy: 'bearer', value: 'docs-secret' },
},
});
expect(server.fetchMock).toHaveBeenCalledWith(
'http://example.com/api/settings/mcp/docs',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
transport: 'http',
url: 'https://example.test/mcp',
auth: { strategy: 'bearer', value: 'docs-secret' },
}),
})
);
});

it('preserves stored auth when a sparse update omits it', async () => {
const server = installStatefulSettingsServer(initialCatalog());
const client = new SettingsClient({ host: 'http://example.com' });

await client.patchMcpServer('github', {
url: 'https://example.test/github-mcp',
});

expect(server.fetchMock).toHaveBeenCalledTimes(1);
expect(server.getCatalog().github).toMatchObject({
url: 'https://example.test/github-mcp',
auth: { strategy: 'bearer', value: 'github-secret' },
});
expect(server.fetchMock).toHaveBeenCalledWith(
'http://example.com/api/settings/mcp/github',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ url: 'https://example.test/github-mcp' }),
})
);
});

it('replaces and explicitly clears auth', async () => {
const server = installStatefulSettingsServer(initialCatalog());
const client = new SettingsClient({ host: 'http://example.com' });

await client.patchMcpServer('github', {
auth: { strategy: 'api_key', header_name: 'X-API-Key', value: 'replacement' },
});
expect(server.getCatalog().github).toMatchObject({
auth: { strategy: 'api_key', header_name: 'X-API-Key', value: 'replacement' },
});

await client.patchMcpServer('github', { auth: null });
expect(server.fetchMock).toHaveBeenCalledTimes(2);
expect(server.getCatalog().github).not.toHaveProperty('auth');
});

it('deletes a map entry without altering sibling credentials', async () => {
const server = installStatefulSettingsServer(initialCatalog());
const client = new SettingsClient({ host: 'http://example.com' });

await client.deleteMcpServer('filesystem');

expect(server.fetchMock).toHaveBeenCalledTimes(1);
expect(server.getCatalog()).toEqual({
github: {
transport: 'http',
url: 'https://api.githubcopilot.com/mcp/',
auth: { strategy: 'bearer', value: 'github-secret' },
headers: { 'X-Existing': 'keep-me', 'X-Remove': 'old' },
},
});
expect(server.fetchMock).toHaveBeenCalledWith(
'http://example.com/api/settings/mcp/filesystem',
expect.objectContaining({
method: 'DELETE',
})
);
});

it('uses null to delete one nested map value while preserving its siblings', async () => {
const server = installStatefulSettingsServer(initialCatalog());
const client = new SettingsClient({ host: 'http://example.com' });

await client.patchMcpServer('github', {
headers: { 'X-Remove': null },
});

expect(server.fetchMock).toHaveBeenCalledTimes(1);
expect(server.getCatalog().github).toMatchObject({
headers: { 'X-Existing': 'keep-me' },
auth: { strategy: 'bearer', value: 'github-secret' },
});
expect(
(server.getCatalog().github as JsonObject).headers as Record<string, string>
).not.toHaveProperty('X-Remove');
});

it('enforces create and existing-server preconditions', async () => {
const server = installStatefulSettingsServer(initialCatalog());
const client = new SettingsClient({ host: 'http://example.com' });

await expect(
client.createMcpServer('github', {
transport: 'http',
url: 'https://replacement.example/mcp',
})
).rejects.toMatchObject({ status: 409 });
await expect(
client.patchMcpServer('missing', { url: 'https://example.test/mcp' })
).rejects.toMatchObject({ status: 404 });
await expect(client.deleteMcpServer('missing')).rejects.toMatchObject({ status: 404 });

expect(server.fetchMock).toHaveBeenCalledTimes(3);
expect(server.getCatalog()).toEqual(initialCatalog());
});
});
Loading
Loading