|
| 1 | +# MCP Server Integration |
| 2 | + |
| 3 | +This guide shows how to build a **Model Context Protocol (MCP) server** that uses the Nexus SDK to make authorized API calls on behalf of a workspace/tenant — in TypeScript, Go, and Python. |
| 4 | + |
| 5 | +## What is MCP? |
| 6 | + |
| 7 | +[Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open standard for exposing tools and data sources to AI agents. An **MCP server** exposes a set of tools (e.g., "list GitHub repos", "post a Slack message") that an AI agent can invoke. MCP servers typically run as child processes communicating over stdio. |
| 8 | + |
| 9 | +When an MCP server needs to call a third-party API on behalf of a user, it needs an access token — and that token must be scoped to the right tenant. Nexus handles this automatically. |
| 10 | + |
| 11 | +## How it Works |
| 12 | + |
| 13 | +``` |
| 14 | +AI Agent → MCP Client → MCP Server (your code) → Nexus SDK → Nexus Gateway → Upstream API |
| 15 | + ↑ |
| 16 | + (resolves & caches token) |
| 17 | +``` |
| 18 | + |
| 19 | +1. The MCP server receives a tool invocation from the AI agent, e.g. `list_repos`. |
| 20 | +2. The tool handler calls the Nexus SDK's auth injector (`createFetcher` / `AuthenticatedHTTPClient` / `authenticated_fetch`). |
| 21 | +3. The SDK checks its token cache for the workspace+provider pair. On a cache miss, it calls `GET /v1/resolve` on the Nexus Gateway. |
| 22 | +4. The SDK injects the `Authorization: Bearer <token>` header and makes the upstream API call. |
| 23 | +5. The result is returned to the AI agent. |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## TypeScript MCP Server |
| 28 | + |
| 29 | +=== "Tool Handler" |
| 30 | + |
| 31 | + ```typescript |
| 32 | + import { Server } from '@modelcontextprotocol/sdk/server/index.js'; |
| 33 | + import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; |
| 34 | + import { NexusClient } from '@dromos/nexus-sdk'; |
| 35 | + |
| 36 | + const WORKSPACE_ID = process.env.WORKSPACE_ID!; |
| 37 | + const GATEWAY_URL = process.env.NEXUS_GATEWAY_URL!; |
| 38 | + |
| 39 | + const nexus = new NexusClient({ gatewayUrl: GATEWAY_URL }); |
| 40 | + const fetcher = nexus.createFetcher({ workspaceId: WORKSPACE_ID, provider: 'github' }); |
| 41 | + |
| 42 | + const server = new Server({ name: 'github-server', version: '1.0.0' }, { |
| 43 | + capabilities: { tools: {} }, |
| 44 | + }); |
| 45 | + |
| 46 | + server.setRequestHandler(ListToolsRequestSchema, async () => ({ |
| 47 | + tools: [{ |
| 48 | + name: 'list_repos', |
| 49 | + description: 'List GitHub repositories for the authenticated user', |
| 50 | + inputSchema: { type: 'object', properties: {}, required: [] }, |
| 51 | + }], |
| 52 | + })); |
| 53 | + |
| 54 | + server.setRequestHandler(CallToolRequestSchema, async (req) => { |
| 55 | + if (req.params.name === 'list_repos') { |
| 56 | + const resp = await fetcher('https://api.github.com/user/repos?per_page=10&sort=updated'); |
| 57 | + const repos = await resp.json() as any[]; |
| 58 | + return { |
| 59 | + content: [{ type: 'text', text: repos.map(r => r.full_name).join('\n') }], |
| 60 | + }; |
| 61 | + } |
| 62 | + throw new Error(`Unknown tool: ${req.params.name}`); |
| 63 | + }); |
| 64 | + |
| 65 | + const transport = new StdioServerTransport(); |
| 66 | + await server.connect(transport); |
| 67 | + // All console.error() calls are safe — NexusClient already uses stderr only. |
| 68 | + console.error('[github-server] Running on stdio'); |
| 69 | + ``` |
| 70 | + |
| 71 | +!!! warning "stdout is sacred" |
| 72 | + In an MCP stdio server, stdout is the JSON-RPC channel. Any `console.log()` call will corrupt the protocol. Always use `console.error()` for diagnostics. The Nexus SDK already does this internally. |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## Go MCP Server |
| 77 | + |
| 78 | +Using [`mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go): |
| 79 | + |
| 80 | +```go |
| 81 | +package main |
| 82 | + |
| 83 | +import ( |
| 84 | + "context" |
| 85 | + "encoding/json" |
| 86 | + "io" |
| 87 | + "time" |
| 88 | + |
| 89 | + "github.com/mark3labs/mcp-go/mcp" |
| 90 | + "github.com/mark3labs/mcp-go/server" |
| 91 | + oauthsdk "github.com/Prescott-Data/nexus-framework/nexus-sdk" |
| 92 | +) |
| 93 | + |
| 94 | +func main() { |
| 95 | + nexus := oauthsdk.New(os.Getenv("NEXUS_GATEWAY_URL")) |
| 96 | + cache := oauthsdk.NewTokenCache(30 * time.Second) |
| 97 | + workspace := os.Getenv("WORKSPACE_ID") |
| 98 | + |
| 99 | + // Authenticated HTTP client — token resolved and injected automatically |
| 100 | + gh := nexus.AuthenticatedHTTPClient(cache, workspace, "github") |
| 101 | + |
| 102 | + s := server.NewMCPServer("github-server", "1.0.0") |
| 103 | + |
| 104 | + s.AddTool(mcp.NewTool("list_repos", |
| 105 | + mcp.WithDescription("List GitHub repositories"), |
| 106 | + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| 107 | + resp, err := gh.Get("https://api.github.com/user/repos?per_page=10&sort=updated") |
| 108 | + if err != nil { |
| 109 | + return mcp.NewToolResultError(err.Error()), nil |
| 110 | + } |
| 111 | + defer resp.Body.Close() |
| 112 | + |
| 113 | + var repos []map[string]any |
| 114 | + json.NewDecoder(resp.Body).Decode(&repos) |
| 115 | + |
| 116 | + names := make([]string, 0, len(repos)) |
| 117 | + for _, r := range repos { |
| 118 | + names = append(names, r["full_name"].(string)) |
| 119 | + } |
| 120 | + return mcp.NewToolResultText(strings.Join(names, "\n")), nil |
| 121 | + }) |
| 122 | + |
| 123 | + server.ServeStdio(s) |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +--- |
| 128 | + |
| 129 | +## Python MCP Server |
| 130 | + |
| 131 | +Using the [`mcp`](https://github.com/modelcontextprotocol/python-sdk) Python package: |
| 132 | + |
| 133 | +```python |
| 134 | +import asyncio |
| 135 | +import json |
| 136 | +import os |
| 137 | + |
| 138 | +import mcp.server.stdio |
| 139 | +from mcp.server import Server |
| 140 | +from mcp.types import Tool, TextContent, CallToolResult |
| 141 | + |
| 142 | +from nexus_sdk import NexusClient, NexusClientOptions, TokenCache |
| 143 | + |
| 144 | +WORKSPACE_ID = os.environ["WORKSPACE_ID"] |
| 145 | +GATEWAY_URL = os.environ["NEXUS_GATEWAY_URL"] |
| 146 | + |
| 147 | +nexus = NexusClient(NexusClientOptions(gateway_url=GATEWAY_URL)) |
| 148 | +cache = TokenCache() |
| 149 | + |
| 150 | +app = Server("github-server") |
| 151 | + |
| 152 | +@app.list_tools() |
| 153 | +async def list_tools(): |
| 154 | + return [Tool( |
| 155 | + name="list_repos", |
| 156 | + description="List GitHub repositories for the authenticated user", |
| 157 | + inputSchema={"type": "object", "properties": {}, "required": []}, |
| 158 | + )] |
| 159 | + |
| 160 | +@app.call_tool() |
| 161 | +async def call_tool(name: str, arguments: dict): |
| 162 | + if name == "list_repos": |
| 163 | + status, _, body = nexus.authenticated_fetch( |
| 164 | + cache, WORKSPACE_ID, "github", |
| 165 | + "https://api.github.com/user/repos?per_page=10&sort=updated", |
| 166 | + headers={"User-Agent": "my-mcp-server/1.0"}, |
| 167 | + ) |
| 168 | + repos = json.loads(body) |
| 169 | + names = "\n".join(r["full_name"] for r in repos) |
| 170 | + return [TextContent(type="text", text=names)] |
| 171 | + raise ValueError(f"Unknown tool: {name}") |
| 172 | + |
| 173 | +async def main(): |
| 174 | + async with mcp.server.stdio.stdio_server() as (r, w): |
| 175 | + await app.run(r, w, app.create_initialization_options()) |
| 176 | + |
| 177 | +if __name__ == "__main__": |
| 178 | + asyncio.run(main()) |
| 179 | +``` |
| 180 | + |
| 181 | +--- |
| 182 | + |
| 183 | +## Common Pitfalls |
| 184 | + |
| 185 | +| Issue | Cause | Fix | |
| 186 | +|---|---|---| |
| 187 | +| MCP client gets garbled responses | Using `console.log` / `print()` to stdout | Use `console.error` (TS) / `logging` to stderr (Python) | |
| 188 | +| 401 errors after a few minutes | Token TTL too long, caching stale tokens | SDK defaults to 5-min TTL when `expires_at` is missing | |
| 189 | +| Notion returns 401 with valid token | `token_type: "bearer"` (lowercase) sent as-is | SDK normalizes to `Bearer` per RFC 6750 | |
| 190 | +| `connection_not_found` error | No active OAuth connection for the workspace | Run the consent flow for that workspace+provider pair | |
| 191 | +| Cold start latency | First request resolves token synchronously | Pre-warm: call `get_cached_token` at server startup | |
| 192 | + |
| 193 | +--- |
| 194 | + |
| 195 | +## Consent Flow |
| 196 | + |
| 197 | +Before an MCP server can fetch tokens, a connection must be established. Initiate the OAuth consent flow with any of the SDKs: |
| 198 | + |
| 199 | +=== "TypeScript" |
| 200 | + |
| 201 | + ```typescript |
| 202 | + const conn = await nexus.requestConnection({ |
| 203 | + userId: 'workspace-123', providerName: 'github', |
| 204 | + scopes: ['repo'], returnUrl: 'https://your-app.com/callback', |
| 205 | + }); |
| 206 | + console.log('Authorize here:', conn.authUrl); |
| 207 | + const status = await nexus.waitForActive(conn.connectionId); |
| 208 | + ``` |
| 209 | + |
| 210 | +=== "Go" |
| 211 | + |
| 212 | + ```go |
| 213 | + conn, _ := nexus.RequestConnection(ctx, oauthsdk.RequestConnectionInput{ |
| 214 | + UserID: "workspace-123", ProviderName: "github", |
| 215 | + Scopes: []string{"repo"}, ReturnURL: "https://your-app.com/callback", |
| 216 | + }) |
| 217 | + log.Println("Authorize here:", conn.AuthURL) |
| 218 | + nexus.WaitForActive(ctx, conn.ConnectionID, 1500*time.Millisecond) |
| 219 | + ``` |
| 220 | + |
| 221 | +=== "Python" |
| 222 | + |
| 223 | + ```python |
| 224 | + conn = nexus.request_connection(RequestConnectionInput( |
| 225 | + user_id='workspace-123', provider_name='github', |
| 226 | + scopes=['repo'], return_url='https://your-app.com/callback', |
| 227 | + )) |
| 228 | + print('Authorize here:', conn.auth_url) |
| 229 | + nexus.wait_for_active(conn.connection_id) |
| 230 | + ``` |
0 commit comments