[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
---
|
||||
id: ai-mcp-server-building
|
||||
title: MCP Server 작성 — 도구 + 권한 + 배포
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [ai, mcp, tool, vibe-coding]
|
||||
tech_stack: { language: "TS / MCP SDK", applicable_to: ["Backend"] }
|
||||
applied_in: []
|
||||
aliases: [MCP server, tool design, sampling, OAuth MCP, Streamable HTTP]
|
||||
---
|
||||
|
||||
# MCP Server Building
|
||||
|
||||
> Tool wrap + 권한 + 배포 = MCP server. **Stdio (로컬) / Streamable HTTP (cloud, 2024+)**. 좋은 tool descrip + JSON Schema + 안전.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Server: tools / resources / prompts 노출.
|
||||
- Client: Claude Desktop / Cursor / Cline.
|
||||
- Transport: stdio / SSE / Streamable HTTP.
|
||||
- Sampling: server 가 client 의 LLM 사용 가능.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### 기본 server (TS, stdio)
|
||||
```ts
|
||||
// server.ts
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const server = new Server({ name: 'acme-tools', version: '1.0.0' }, {
|
||||
capabilities: { tools: {}, resources: {}, prompts: {} },
|
||||
});
|
||||
|
||||
// Tools 정의
|
||||
const tools = {
|
||||
search_orders: {
|
||||
schema: z.object({ email: z.string().email() }),
|
||||
handler: async (input: { email: string }) => {
|
||||
const orders = await db.orders.findByEmail(input.email);
|
||||
return JSON.stringify(orders.slice(0, 10));
|
||||
},
|
||||
description: 'Search recent orders by customer email. Returns up to 10 orders.',
|
||||
},
|
||||
get_inventory: {
|
||||
schema: z.object({ productId: z.string() }),
|
||||
handler: async ({ productId }: { productId: string }) => {
|
||||
const inv = await db.inventory.find(productId);
|
||||
return JSON.stringify(inv);
|
||||
},
|
||||
description: 'Get current inventory for a product.',
|
||||
},
|
||||
};
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: Object.entries(tools).map(([name, t]) => ({
|
||||
name,
|
||||
description: t.description,
|
||||
inputSchema: zodToJsonSchema(t.schema),
|
||||
})),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
const tool = tools[req.params.name as keyof typeof tools];
|
||||
if (!tool) {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }], isError: true };
|
||||
}
|
||||
|
||||
const parsed = tool.schema.safeParse(req.params.arguments);
|
||||
if (!parsed.success) {
|
||||
return { content: [{ type: 'text', text: `Invalid input: ${parsed.error.message}` }], isError: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handler(parsed.data as any);
|
||||
return { content: [{ type: 'text', text: result }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `Error: ${(e as Error).message}` }], isError: true };
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
### Tool description (LLM 이 잘 고르도록)
|
||||
```
|
||||
Bad: "Get user data"
|
||||
Good: "Get a user's profile by ID. Returns name, email, plan, created_at.
|
||||
Use this when you need user details. Returns 404 if user not found."
|
||||
```
|
||||
|
||||
→ LLM 이 언제 사용할지 명시.
|
||||
|
||||
### Streamable HTTP transport (2024+)
|
||||
```ts
|
||||
// stream + persistent connection
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
→ Cloud-deployed multi-user MCP.
|
||||
|
||||
### OAuth (cloud server 보안)
|
||||
```ts
|
||||
// /authorize, /token endpoints
|
||||
// 사용자가 ChatGPT / Claude.ai 에서 OAuth flow
|
||||
// Server 가 user-scoped data 반환
|
||||
|
||||
app.use('/mcp', requireOAuthToken, async (req, res) => {
|
||||
const userId = await verifyToken(req.headers.authorization);
|
||||
// userId scope 으로 server 호출
|
||||
});
|
||||
```
|
||||
|
||||
### Resources (data exposure)
|
||||
```ts
|
||||
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: [
|
||||
{ uri: 'acme://users/active', name: 'Active users', mimeType: 'application/json' },
|
||||
{ uri: 'acme://docs/api', name: 'API docs', mimeType: 'text/markdown' },
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
||||
if (req.params.uri === 'acme://users/active') {
|
||||
const users = await db.users.findActive();
|
||||
return {
|
||||
contents: [{
|
||||
uri: req.params.uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(users),
|
||||
}],
|
||||
};
|
||||
}
|
||||
throw new Error('Not found');
|
||||
});
|
||||
```
|
||||
|
||||
### Prompts (재사용 template)
|
||||
```ts
|
||||
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
||||
prompts: [{
|
||||
name: 'analyze-customer',
|
||||
description: 'Analyze a customer\'s purchase history',
|
||||
arguments: [{ name: 'email', required: true }],
|
||||
}],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
||||
if (req.params.name === 'analyze-customer') {
|
||||
return {
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Analyze ${req.params.arguments?.email}'s orders. Look for patterns, churn risk, upsell opportunities.`,
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Permissions / 안전
|
||||
```ts
|
||||
// Sensitive 작업 = 명시 confirm
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
const dangerous = ['delete_user', 'send_email', 'charge_card'];
|
||||
if (dangerous.includes(req.params.name)) {
|
||||
// MCP 가 client 에 confirm 요청 (capability 가 elicitation)
|
||||
const ok = await server.request({
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
message: `Confirm: ${req.params.name} on ${req.params.arguments}`,
|
||||
},
|
||||
});
|
||||
if (!ok.confirmed) return { content: [...], isError: true };
|
||||
}
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Logging / observability
|
||||
```ts
|
||||
async function callTool(name: string, input: any) {
|
||||
const t = Date.now();
|
||||
try {
|
||||
const result = await tools[name].handler(input);
|
||||
log.info({ tool: name, ms: Date.now() - t, success: true });
|
||||
return result;
|
||||
} catch (e) {
|
||||
log.error({ tool: name, ms: Date.now() - t, error: e });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test
|
||||
```ts
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
|
||||
const client = new Client({ name: 'test', version: '1.0' }, { capabilities: {} });
|
||||
await client.connect(transport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
const result = await client.callTool({ name: 'search_orders', arguments: { email: 'a@b.com' } });
|
||||
expect(result.content).toBeDefined();
|
||||
```
|
||||
|
||||
### Inspector (debug)
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector node server.js
|
||||
# UI 에서 tool 호출 / 결과 확인
|
||||
```
|
||||
|
||||
### 배포
|
||||
```
|
||||
Local: config 에 command path
|
||||
Cloud HTTP: docker + behind LB + OAuth
|
||||
Distribution: npm package
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// claude_desktop_config.json
|
||||
{
|
||||
"mcpServers": {
|
||||
"acme": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/server.js"],
|
||||
"env": { "DB_URL": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| 로컬 단일 user | stdio |
|
||||
| Multi-user cloud | Streamable HTTP + OAuth |
|
||||
| 회사 내부 도구 | Stdio + 사내 npm publish |
|
||||
| Public service | Streamable HTTP + OAuth + rate limit |
|
||||
| Sensitive | Confirmation + audit |
|
||||
| Quick prototype | stdio + inspector |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **Tool description 빈약**: LLM 이 못 고름.
|
||||
- **Sensitive 자동 실행**: HITL.
|
||||
- **PII raw response**: 마스킹.
|
||||
- **Schema 자주 변경**: 등록된 client 깨짐.
|
||||
- **HTTP 무 OAuth prod**: 누구나 호출.
|
||||
- **Sync long task**: timeout. async + status tool.
|
||||
- **Tool 너무 많음 (50+)**: LLM 혼란. group / namespace.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Tool descrip = LLM prompt — 풍부 + 명확.
|
||||
- Zod schema → JSON Schema 자동.
|
||||
- Sensitive = elicitation.
|
||||
- HTTP cloud = OAuth.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[AI_MCP_Integration_Patterns]]
|
||||
- [[AI_Function_Calling_Deep]]
|
||||
- [[AI_Agentic_Patterns]]
|
||||
Reference in New Issue
Block a user