Skip to content

Commit 01c501c

Browse files
committed
feat(web): manage custom MCP servers from the connectors page
Add create, replace, and delete endpoints for MCP servers, writing only the user-global mcp.json under a lock and an atomic rename, and surface them as a form on the connectors settings page. Entries declared by a project file or a plugin stay read-only. A written server reaches the agent on the next session; the running agent is not reloaded.
1 parent afb06f5 commit 01c501c

25 files changed

Lines changed: 1252 additions & 32 deletions

File tree

.changeset/web-mcp-server-crud.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Add, edit, and remove your own MCP servers from the connectors page in the web UI; a new or edited server starts with your next session.

apps/pythinker-web/src/App.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,7 @@ function openPr(url: string): void {
11011101
:skills="client.skills.value"
11021102
:connectors="client.connectors.value"
11031103
:connectors-loading="client.connectorsLoading.value"
1104+
:connectors-error="client.connectorsError.value"
11041105
:sessions="client.sessionsWithUsage.value"
11051106
:plugins="client.plugins.value"
11061107
:subagents="client.subagents.value"
@@ -1111,6 +1112,9 @@ function openPr(url: string): void {
11111112
@set-plugin-enabled="client.setPluginEnabled($event.pluginId, $event.enabled)"
11121113
@set-tools="applySessionTools($event)"
11131114
@restart-connector="client.restartConnector($event)"
1115+
@create-connector="client.createConnector($event)"
1116+
@update-connector="client.updateConnector($event.connectorId, $event.input)"
1117+
@remove-connector="client.removeConnector($event)"
11141118
@set-theme="client.setTheme($event)"
11151119
@set-color-scheme="client.setColorScheme($event)"
11161120
@set-ui-font-size="client.setUiFontSize($event)"

apps/pythinker-web/src/api/daemon/client.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { PythinkerApiConfig } from '../config';
55
import { buildRestUrl, buildWsUrl } from '../config';
66
import type {
77
AppConfig,
8+
AppMcpServerDefinition,
9+
AppMcpServerInput,
810
AppMessage,
911
AppMessageRole,
1012
AppModel,
@@ -146,6 +148,17 @@ interface WireMcpServer {
146148
status: 'connected' | 'connecting' | 'disconnected' | 'error';
147149
tool_count: number;
148150
last_error?: string;
151+
editable: boolean;
152+
definition?: WireMcpServerDefinition;
153+
}
154+
155+
interface WireMcpServerDefinition {
156+
transport: 'stdio' | 'http' | 'sse';
157+
command?: string;
158+
args?: string[];
159+
env?: Record<string, string>;
160+
url?: string;
161+
headers?: Record<string, string>;
149162
}
150163

151164
interface WirePlugin {
@@ -170,6 +183,28 @@ interface WireAgentProfile {
170183
when_to_use?: string;
171184
}
172185

186+
function toAppMcpDefinition(definition: WireMcpServerDefinition): AppMcpServerDefinition {
187+
return {
188+
transport: definition.transport,
189+
command: definition.command,
190+
args: definition.args,
191+
env: definition.env,
192+
url: definition.url,
193+
headers: definition.headers,
194+
};
195+
}
196+
197+
function toWireMcpDefinition(input: AppMcpServerInput): WireMcpServerDefinition {
198+
return {
199+
transport: input.transport,
200+
command: input.command,
201+
args: input.args,
202+
env: input.env,
203+
url: input.url,
204+
headers: input.headers,
205+
};
206+
}
207+
173208
interface WireArchiveResult {
174209
archived: true;
175210
}
@@ -786,9 +821,34 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi {
786821
status: server.status,
787822
toolCount: server.tool_count,
788823
lastError: server.last_error,
824+
editable: server.editable,
825+
definition: server.definition === undefined ? undefined : toAppMcpDefinition(server.definition),
789826
}));
790827
}
791828

829+
async createConnector(input: AppMcpServerInput): Promise<{ created: true }> {
830+
return this.http.post<{ created: true }>('/mcp/servers', {
831+
mcp_server_id: input.name,
832+
config: toWireMcpDefinition(input),
833+
});
834+
}
835+
836+
async updateConnector(
837+
connectorId: string,
838+
input: AppMcpServerInput,
839+
): Promise<{ updated: true }> {
840+
return this.http.put<{ updated: true }>(
841+
`/mcp/servers/${encodeURIComponent(connectorId)}`,
842+
{ config: toWireMcpDefinition(input) },
843+
);
844+
}
845+
846+
async removeConnector(connectorId: string): Promise<{ deleted: true }> {
847+
return this.http.delete<{ deleted: true }>(
848+
`/mcp/servers/${encodeURIComponent(connectorId)}`,
849+
);
850+
}
851+
792852
async listPlugins(): Promise<AppPlugin[]> {
793853
const data = await this.http.get<{ plugins: WirePlugin[] }>('/plugins');
794854
return (data.plugins ?? []).map((plugin) => ({

apps/pythinker-web/src/api/daemon/http.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ export class DaemonHttpClient {
170170
return this.request<T>('PATCH', path, body);
171171
}
172172

173+
async put<T>(path: string, body: unknown): Promise<T> {
174+
return this.request<T>('PUT', path, body);
175+
}
176+
173177
async delete<T>(path: string): Promise<T> {
174178
return this.request<T>('DELETE', path);
175179
}

apps/pythinker-web/src/api/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,13 +646,28 @@ export interface AppSubagent {
646646
}
647647

648648
/** A configured MCP server ("connector") and its live connection state. */
649+
export interface AppMcpServerDefinition {
650+
transport: 'stdio' | 'http' | 'sse';
651+
command?: string;
652+
args?: string[];
653+
env?: Record<string, string>;
654+
url?: string;
655+
headers?: Record<string, string>;
656+
}
657+
658+
export interface AppMcpServerInput extends AppMcpServerDefinition {
659+
name: string;
660+
}
661+
649662
export interface AppConnector {
650663
id: string;
651664
name: string;
652665
transport: 'stdio' | 'http' | 'sse';
653666
status: 'connected' | 'connecting' | 'disconnected' | 'error';
654667
toolCount: number;
655668
lastError?: string;
669+
editable: boolean;
670+
definition?: AppMcpServerDefinition;
656671
}
657672

658673
/** One tool available to the current session. */
@@ -704,6 +719,12 @@ export interface PythinkerWebApi {
704719
activateSkill(sessionId: string, skillName: string, args?: string): Promise<{ activated: true; skillName: string }>;
705720
/** Configured MCP servers — GET /mcp/servers. */
706721
listConnectors(): Promise<AppConnector[]>;
722+
/** Create one user-global MCP server — POST /mcp/servers. */
723+
createConnector(input: AppMcpServerInput): Promise<{ created: true }>;
724+
/** Replace one user-global MCP server — PUT /mcp/servers/{id}. */
725+
updateConnector(connectorId: string, input: AppMcpServerInput): Promise<{ updated: true }>;
726+
/** Remove one user-global MCP server — DELETE /mcp/servers/{id}. */
727+
removeConnector(connectorId: string): Promise<{ deleted: true }>;
707728
/** Restart one MCP server — POST /mcp/servers/{id}:restart. */
708729
restartConnector(connectorId: string): Promise<{ restarting: true }>;
709730
/** Installed plugins — GET /plugins. */
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<script setup lang="ts">
2+
import { computed, reactive, ref } from 'vue';
3+
import { useI18n } from 'vue-i18n';
4+
5+
import type { AppConnector, AppMcpServerInput } from '../../api/types';
6+
7+
type McpTransport = AppMcpServerInput['transport'];
8+
9+
interface FormState {
10+
name: string;
11+
transport: McpTransport;
12+
command: string;
13+
args: string;
14+
env: string;
15+
url: string;
16+
headers: string;
17+
}
18+
19+
const props = defineProps<{
20+
connector?: AppConnector;
21+
}>();
22+
23+
const emit = defineEmits<{
24+
submit: [input: AppMcpServerInput];
25+
cancel: [];
26+
}>();
27+
28+
const { t } = useI18n();
29+
const isEditing = computed(() => props.connector !== undefined);
30+
const formError = ref<string>();
31+
const definition = props.connector?.definition;
32+
const form = reactive<FormState>({
33+
name: props.connector?.name ?? '',
34+
transport: definition?.transport ?? props.connector?.transport ?? 'stdio',
35+
command: definition?.command ?? '',
36+
args: definition?.args?.join('\n') ?? '',
37+
env: stringifyRecord(definition?.env),
38+
url: definition?.url ?? '',
39+
headers: stringifyRecord(definition?.headers),
40+
});
41+
42+
function stringifyRecord(value: Record<string, string> | undefined): string {
43+
return value === undefined ? '' : JSON.stringify(value, null, 2);
44+
}
45+
46+
function parseRecord(value: string, field: string): Record<string, string> | undefined {
47+
if (value.trim() === '') return undefined;
48+
let parsed: unknown;
49+
try {
50+
parsed = JSON.parse(value);
51+
} catch {
52+
formError.value = t('settings.connectors.form.invalidJson', { field });
53+
return undefined;
54+
}
55+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
56+
formError.value = t('settings.connectors.form.objectRequired', { field });
57+
return undefined;
58+
}
59+
const entries = Object.entries(parsed as Record<string, unknown>);
60+
if (entries.some(([, entry]) => typeof entry !== 'string')) {
61+
formError.value = t('settings.connectors.form.stringValuesRequired', { field });
62+
return undefined;
63+
}
64+
return Object.fromEntries(entries) as Record<string, string>;
65+
}
66+
67+
function submit(): void {
68+
formError.value = undefined;
69+
const env = form.transport === 'stdio' ? parseRecord(form.env, 'env') : undefined;
70+
if (form.transport === 'stdio' && form.env.trim() !== '' && env === undefined) return;
71+
const headers = form.transport !== 'stdio' ? parseRecord(form.headers, 'headers') : undefined;
72+
if (form.transport !== 'stdio' && form.headers.trim() !== '' && headers === undefined) return;
73+
emit('submit', {
74+
name: form.name,
75+
transport: form.transport,
76+
command: form.transport === 'stdio' ? form.command : undefined,
77+
args: form.transport === 'stdio' && form.args.trim() !== ''
78+
? form.args.split('\n').map((arg) => arg.trim()).filter((arg) => arg.length > 0)
79+
: undefined,
80+
env,
81+
url: form.transport === 'stdio' ? undefined : form.url,
82+
headers,
83+
});
84+
}
85+
</script>
86+
87+
<template>
88+
<form class="connector-form" @submit.prevent="submit">
89+
<div class="connector-fields">
90+
<label class="connector-field">
91+
<span class="rlabel">{{ t('settings.connectors.form.name') }}</span>
92+
<input v-model="form.name" class="page-search" :readonly="isEditing" required />
93+
</label>
94+
<label class="connector-field">
95+
<span class="rlabel">{{ t('settings.connectors.form.transport') }}</span>
96+
<select v-model="form.transport" class="page-search">
97+
<option value="stdio">stdio</option>
98+
<option value="http">http</option>
99+
<option value="sse">sse</option>
100+
</select>
101+
</label>
102+
<label v-if="form.transport === 'stdio'" class="connector-field">
103+
<span class="rlabel">{{ t('settings.connectors.form.command') }}</span>
104+
<input v-model="form.command" class="page-search" required />
105+
</label>
106+
<label v-if="form.transport === 'stdio'" class="connector-field">
107+
<span class="rlabel">{{ t('settings.connectors.form.args') }}</span>
108+
<textarea v-model="form.args" class="page-search connector-textarea" :placeholder="t('settings.connectors.form.argsHint')" />
109+
</label>
110+
<label v-if="form.transport === 'stdio'" class="connector-field">
111+
<span class="rlabel">{{ t('settings.connectors.form.env') }}</span>
112+
<textarea v-model="form.env" class="page-search connector-textarea" :placeholder="t('settings.connectors.form.objectHint')" />
113+
</label>
114+
<label v-else class="connector-field">
115+
<span class="rlabel">{{ t('settings.connectors.form.url') }}</span>
116+
<input v-model="form.url" class="page-search" type="url" required />
117+
</label>
118+
<label v-if="form.transport !== 'stdio'" class="connector-field">
119+
<span class="rlabel">{{ t('settings.connectors.form.headers') }}</span>
120+
<textarea v-model="form.headers" class="page-search connector-textarea" :placeholder="t('settings.connectors.form.objectHint')" />
121+
</label>
122+
</div>
123+
<p v-if="formError" class="listing-error">{{ formError }}</p>
124+
<div class="connector-form-actions">
125+
<button type="submit" class="act">{{ isEditing ? t('settings.connectors.form.save') : t('settings.connectors.form.add') }}</button>
126+
<button v-if="isEditing" type="button" class="act" @click="emit('cancel')">{{ t('settings.connectors.form.cancel') }}</button>
127+
</div>
128+
</form>
129+
</template>
130+
131+
<style scoped src="./settings.css"></style>
132+
133+
<style scoped>
134+
.connector-form {
135+
margin: 12px 0 16px;
136+
padding: 12px;
137+
border: 1px solid var(--line);
138+
border-radius: var(--r-sm);
139+
background: var(--panel);
140+
}
141+
.connector-fields {
142+
display: grid;
143+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
144+
gap: 8px 12px;
145+
}
146+
.connector-field { min-width: 0; }
147+
.connector-field .page-search { margin: 4px 0 0; }
148+
.connector-textarea {
149+
min-height: 64px;
150+
resize: vertical;
151+
font-family: var(--mono);
152+
}
153+
.connector-form-actions {
154+
display: flex;
155+
gap: 8px;
156+
margin-top: 4px;
157+
}
158+
</style>

apps/pythinker-web/src/components/settings/SettingsPane.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type {
33
AppConfig,
44
AppConnector,
5+
AppMcpServerInput,
56
AppModel,
67
AppPlugin,
78
AppSession,
@@ -40,6 +41,7 @@ defineProps<{
4041
skills?: AppSkill[];
4142
connectors?: AppConnector[];
4243
connectorsLoading?: boolean;
44+
connectorsError?: string;
4345
sessions?: AppSession[];
4446
plugins?: AppPlugin[];
4547
subagents?: AppSubagent[];
@@ -59,6 +61,9 @@ const emit = defineEmits<{
5961
openOnboarding: [];
6062
updateConfig: [patch: Partial<AppConfig>];
6163
restartConnector: [connectorId: string];
64+
createConnector: [input: AppMcpServerInput];
65+
updateConnector: [payload: { connectorId: string; input: AppMcpServerInput }];
66+
removeConnector: [connectorId: string];
6267
setPluginEnabled: [payload: { pluginId: string; enabled: boolean }];
6368
setTools: [names: string[]];
6469
close: [];
@@ -125,7 +130,11 @@ const { t } = useI18n();
125130
v-show="activeTab === 'connectors'"
126131
:connectors="connectors"
127132
:connectors-loading="connectorsLoading"
133+
:connectors-error="connectorsError"
128134
@restart-connector="emit('restartConnector', $event)"
135+
@create-connector="emit('createConnector', $event)"
136+
@update-connector="emit('updateConnector', $event)"
137+
@remove-connector="emit('removeConnector', $event)"
129138
/>
130139
<HooksPage v-show="activeTab === 'hooks'" :config="config" />
131140
<UsagePage v-show="activeTab === 'usage'" :sessions="sessions" />

0 commit comments

Comments
 (0)