@topolo/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +96 -0
  2. package/dist/index.js +519 -0
  3. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # TopoloMCP
2
+
3
+ Model Context Protocol server for the Topolo platform. Lets MCP-capable agents
4
+ (Claude Desktop, Claude Code, Codex, Cursor, etc.) call Topolo APIs as native
5
+ tools rather than shelling out.
6
+
7
+ ## Why both a CLI and an MCP server?
8
+
9
+ - The **CLI** is for humans, shell scripts, and agents without MCP support.
10
+ - The **MCP server** is for agents that speak the protocol natively — it gives
11
+ them typed tool schemas, scope-filtered tool advertisement, and structured
12
+ error responses. Both wrap the same `@topolo/sdk`.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install -g @topolo/mcp
18
+ ```
19
+
20
+ ## Register with an MCP client
21
+
22
+ Example (Claude Desktop, `claude_desktop_config.json`):
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "topolo": {
28
+ "command": "npx",
29
+ "args": ["-y", "@topolo/mcp"],
30
+ "env": {
31
+ "TOPOLO_API_KEY": "topo_live_...",
32
+ "TOPOLO_AGENT_NAME": "claude-desktop"
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ ## Supported env vars
40
+
41
+ | Var | Purpose |
42
+ | ---------------------------- | ----------------------------------------------------- |
43
+ | `TOPOLO_API_KEY` | Platform API key (preferred) |
44
+ | `TOPOLO_ACCESS_TOKEN` | Short-lived JWT (dev/testing) |
45
+ | `TOPOLO_AGENT_NAME` | Human-readable agent label for audit logs |
46
+ | `TOPOLO_SERVICE_URL_<ID>` | Override a service base URL (`_AUTH`, `_CRM`, ...) |
47
+
48
+ On startup the server:
49
+
50
+ 1. Refuses to start if no credential is set.
51
+ 2. Introspects the credential against TopoloAuth to load granted scopes.
52
+ 3. Filters the advertised tool list to only those the credential can use.
53
+
54
+ ## Tools (Phase 1)
55
+
56
+ | Tool | Required scopes | Destructive |
57
+ | ---------------------------- | --------------------- | ----------- |
58
+ | `topolo_whoami` | (none) | no |
59
+ | `topolo_crm_list_contacts` | `crm.contacts:read` | no |
60
+ | `topolo_crm_get_contact` | `crm.contacts:read` | no |
61
+
62
+ Phase 1 is intentionally read-only. Write tools will be added per-domain in
63
+ later phases alongside typed SDK modules, each annotated with
64
+ `destructiveHint: true` so host clients can surface confirmation UI.
65
+
66
+ ## Safety rails
67
+
68
+ - **No `orgId` parameter** on any tool. The organization is derived entirely
69
+ from the credential by each backend app. There is no path for Org A's agent
70
+ to see Org B's data.
71
+ - **Scope-gated tool advertisement.** Agents never see tools they can't use —
72
+ fewer wasted attempts and less prompt noise.
73
+ - **Audit headers.** Every request sends `X-Topolo-Client: topolo-mcp/<ver>`,
74
+ `X-Topolo-Agent: <label>`, `X-Topolo-Request-Id: <uuid>`.
75
+ - **Write-action confirmation.** The SDK refuses mutating HTTP methods unless
76
+ the call explicitly passes `confirm: true`. Mutating tools (when introduced)
77
+ will require the host client to approve before invocation.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ cd TopoloMCP
83
+ npm install
84
+ npm run build
85
+ TOPOLO_API_KEY=topo_live_... node dist/index.js
86
+ ```
87
+
88
+ The server speaks MCP over stdio; when running standalone it just blocks
89
+ waiting for JSON-RPC on stdin.
90
+
91
+ ## Phase 2 (planned)
92
+
93
+ - Accept OAuth 2.1 access tokens minted via authorization-code + device grants
94
+ in TopoloAuth.
95
+ - Dynamic tool registration as more `@topolo/sdk` modules ship.
96
+ - HTTP transport in addition to stdio, for hosted (non-subprocess) deployments.
package/dist/index.js ADDED
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+
11
+ // ../packages/topolo-sdk/dist/index.js
12
+ function applyAuthHeaders(headers, credential) {
13
+ if (credential.kind === "api_key") {
14
+ headers.set("X-Api-Key", credential.apiKey);
15
+ return;
16
+ }
17
+ headers.set("Authorization", `Bearer ${credential.accessToken}`);
18
+ }
19
+ function applyAuditHeaders(headers, agent, requestId) {
20
+ headers.set("X-Topolo-Client", `${agent.clientName}/${agent.clientVersion}`);
21
+ if (agent.agentName) headers.set("X-Topolo-Agent", agent.agentName);
22
+ headers.set("X-Topolo-Request-Id", requestId);
23
+ }
24
+ function generateRequestId() {
25
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
26
+ return crypto.randomUUID();
27
+ }
28
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
29
+ }
30
+ var TopoloSdkError = class extends Error {
31
+ code;
32
+ constructor(code, message) {
33
+ super(message);
34
+ this.name = "TopoloSdkError";
35
+ this.code = code;
36
+ }
37
+ };
38
+ var TopoloAuthError = class extends TopoloSdkError {
39
+ constructor(message, code = "auth_error") {
40
+ super(code, message);
41
+ this.name = "TopoloAuthError";
42
+ }
43
+ };
44
+ var TopoloPermissionError = class extends TopoloSdkError {
45
+ required;
46
+ constructor(message, required) {
47
+ super("permission_denied", message);
48
+ this.name = "TopoloPermissionError";
49
+ this.required = required;
50
+ }
51
+ };
52
+ var TopoloHttpError = class extends TopoloSdkError {
53
+ status;
54
+ body;
55
+ service;
56
+ path;
57
+ constructor(service, path, status, body, message) {
58
+ super("http_error", message ?? `HTTP ${status} from ${service}${path}`);
59
+ this.name = "TopoloHttpError";
60
+ this.status = status;
61
+ this.body = body;
62
+ this.service = service;
63
+ this.path = path;
64
+ }
65
+ };
66
+ var DEFAULT_SERVICE_URLS = {
67
+ auth: "https://auth.topolo.app",
68
+ crm: "https://topolo-crm-worker.topolo.workers.dev"
69
+ };
70
+ var PLATFORM_SERVICE_IDS = {
71
+ crm: "srv_iCwM4jGXcwlj"
72
+ };
73
+ function resolveServiceUrl(service, overrides) {
74
+ const override = overrides?.[service];
75
+ if (override) return override;
76
+ const envKey = `TOPOLO_SERVICE_URL_${service.toUpperCase()}`;
77
+ const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
78
+ if (envValue) return envValue;
79
+ return DEFAULT_SERVICE_URLS[service];
80
+ }
81
+ var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
82
+ var TopoloClient = class {
83
+ credential;
84
+ agent;
85
+ serviceUrls;
86
+ requireConfirmForWrites;
87
+ timeoutMs;
88
+ fetchImpl;
89
+ constructor(options) {
90
+ if (!options.credential) throw new TopoloAuthError("credential is required");
91
+ if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
92
+ this.credential = options.credential;
93
+ this.agent = options.agent;
94
+ this.serviceUrls = options.serviceUrls;
95
+ this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
96
+ this.timeoutMs = options.timeoutMs ?? 3e4;
97
+ this.fetchImpl = options.fetch ?? fetch;
98
+ }
99
+ /**
100
+ * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
101
+ * for anything a caller would reach for; this stays exported for escape-hatch
102
+ * use and for the generic `topolo api` / MCP passthrough tool.
103
+ */
104
+ async request(opts) {
105
+ const method = opts.method ?? "GET";
106
+ if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
107
+ throw new TopoloAuthError(
108
+ `Mutating ${method} requests require { confirm: true }. This guardrail prevents agents from issuing writes without an explicit human-in-the-loop acknowledgement.`
109
+ );
110
+ }
111
+ const baseUrl = resolveServiceUrl(opts.service, this.serviceUrls);
112
+ const url = new URL(opts.path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
113
+ if (opts.query) {
114
+ for (const [k, v] of Object.entries(opts.query)) {
115
+ if (v !== void 0) url.searchParams.set(k, String(v));
116
+ }
117
+ }
118
+ const headers = new Headers();
119
+ headers.set("Accept", "application/json");
120
+ if (opts.body !== void 0) headers.set("Content-Type", "application/json");
121
+ const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
122
+ if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
123
+ applyAuthHeaders(headers, this.credential);
124
+ applyAuditHeaders(headers, this.agent, generateRequestId());
125
+ if (opts.headers) {
126
+ for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
127
+ }
128
+ const controller = new AbortController();
129
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
130
+ const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
131
+ let res;
132
+ try {
133
+ res = await this.fetchImpl(url.toString(), {
134
+ method,
135
+ headers,
136
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
137
+ signal
138
+ });
139
+ } finally {
140
+ clearTimeout(timeoutId);
141
+ }
142
+ const contentType = res.headers.get("Content-Type") ?? "";
143
+ const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
144
+ if (!res.ok) {
145
+ if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
146
+ if (res.status === 403) {
147
+ throw new TopoloPermissionError(
148
+ describeError(parsed, "Permission denied"),
149
+ extractRequired(parsed)
150
+ );
151
+ }
152
+ throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
153
+ }
154
+ return parsed;
155
+ }
156
+ /**
157
+ * Introspect the current credential. Returns the caller's resolved identity,
158
+ * organization, and permission set. Used by CLI `whoami` and by MCP to gate
159
+ * advertised tools by scope.
160
+ *
161
+ * Both JWT access tokens and platform API keys are resolved via the unified
162
+ * `GET /api/auth/me` endpoint on TopoloAuth, which does not require service
163
+ * credentials. The caller's possession of the credential secret is proof.
164
+ */
165
+ async introspect() {
166
+ const res = await this.request({
167
+ service: "auth",
168
+ method: "GET",
169
+ path: "/api/auth/me"
170
+ });
171
+ const payload = res.data ?? res;
172
+ const user = payload.user;
173
+ const organization = payload.organization;
174
+ const kind = payload.credentialType === "api_key" ? "api_key" : "access_token";
175
+ return {
176
+ kind,
177
+ user: {
178
+ id: user.id,
179
+ email: user.email,
180
+ name: user.name ?? null,
181
+ role: user.role ?? (kind === "api_key" ? "service" : "member"),
182
+ permissions: payload.permissions ?? user.permissions ?? []
183
+ },
184
+ organization: organization ? {
185
+ id: organization.id,
186
+ slug: organization.slug,
187
+ name: organization.name ?? organization.slug
188
+ } : user.orgId && user.orgSlug ? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug } : null
189
+ };
190
+ }
191
+ };
192
+ function describeError(body, fallback) {
193
+ if (body && typeof body === "object") {
194
+ const maybe = body;
195
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
196
+ if (resolved) return resolved;
197
+ }
198
+ return fallback;
199
+ }
200
+ function pickString(value) {
201
+ if (typeof value === "string" && value.trim().length > 0) return value;
202
+ if (value && typeof value === "object") {
203
+ const nested = value;
204
+ if (typeof nested.message === "string" && nested.message.trim().length > 0) return nested.message;
205
+ if (typeof nested.description === "string" && nested.description.trim().length > 0)
206
+ return nested.description;
207
+ }
208
+ return void 0;
209
+ }
210
+ function extractRequired(body) {
211
+ if (body && typeof body === "object") {
212
+ const maybe = body;
213
+ if (Array.isArray(maybe.required)) return maybe.required.map(String);
214
+ }
215
+ return [];
216
+ }
217
+ function mergeSignals(a, b) {
218
+ if (a.aborted) return a;
219
+ if (b.aborted) return b;
220
+ const controller = new AbortController();
221
+ const onAbort = () => controller.abort();
222
+ a.addEventListener("abort", onAbort, { once: true });
223
+ b.addEventListener("abort", onAbort, { once: true });
224
+ return controller.signal;
225
+ }
226
+ var CrmModule = class {
227
+ constructor(client) {
228
+ this.client = client;
229
+ }
230
+ client;
231
+ async listContacts(options = {}) {
232
+ const res = await this.client.request({
233
+ service: "crm",
234
+ path: "/api/contacts",
235
+ query: {
236
+ q: options.q,
237
+ page: options.page,
238
+ pageSize: options.pageSize
239
+ }
240
+ });
241
+ const rows = Array.isArray(res?.contacts) ? res.contacts : Array.isArray(res?.data) ? res.data : [];
242
+ return {
243
+ contacts: rows.map(toSummary),
244
+ total: res?.total ?? res?.pagination?.total ?? rows.length,
245
+ page: res?.page ?? res?.pagination?.page ?? options.page ?? 1,
246
+ pageSize: res?.pageSize ?? res?.pagination?.pageSize ?? options.pageSize ?? rows.length
247
+ };
248
+ }
249
+ async getContact(contactId) {
250
+ if (!contactId) throw new Error("contactId is required");
251
+ const res = await this.client.request({
252
+ service: "crm",
253
+ path: `/api/contacts/${encodeURIComponent(contactId)}`
254
+ });
255
+ const row = res.data ?? res;
256
+ return row ? toSummary(row) : null;
257
+ }
258
+ };
259
+ function toSummary(row) {
260
+ return {
261
+ id: row.id,
262
+ firstName: row.first ?? null,
263
+ lastName: row.last ?? null,
264
+ email: row.email ?? null,
265
+ phone: row.phone ?? null,
266
+ company: row.company ?? null,
267
+ leadStatus: row.lead_status ?? null,
268
+ lifecycleStage: row.lifecycle_stage ?? null,
269
+ updatedAt: row.updated_at ?? null
270
+ };
271
+ }
272
+ var IdentityModule = class {
273
+ constructor(client) {
274
+ this.client = client;
275
+ }
276
+ client;
277
+ /** Returns the resolved user, organization, and permission set for the
278
+ * current credential. Equivalent to `TopoloClient.introspect()` — exposed
279
+ * on this module for symmetry with the other domain modules. */
280
+ whoami() {
281
+ return this.client.introspect();
282
+ }
283
+ };
284
+ function createTopolo(options) {
285
+ const client = new TopoloClient(options);
286
+ return {
287
+ client,
288
+ identity: new IdentityModule(client),
289
+ crm: new CrmModule(client)
290
+ };
291
+ }
292
+
293
+ // src/auth.ts
294
+ function resolveCredentialFromEnv() {
295
+ const apiKey = process.env.TOPOLO_API_KEY;
296
+ if (apiKey) return { kind: "api_key", apiKey };
297
+ const accessToken = process.env.TOPOLO_ACCESS_TOKEN;
298
+ if (accessToken) return { kind: "access_token", accessToken };
299
+ return null;
300
+ }
301
+ function resolveServiceUrlsFromEnv() {
302
+ const out = {};
303
+ for (const [k, v] of Object.entries(process.env)) {
304
+ if (k.startsWith("TOPOLO_SERVICE_URL_") && v) {
305
+ out[k.slice("TOPOLO_SERVICE_URL_".length).toLowerCase()] = v;
306
+ }
307
+ }
308
+ return Object.keys(out).length > 0 ? out : void 0;
309
+ }
310
+
311
+ // src/gating.ts
312
+ function hasScope(set, required) {
313
+ if (set.role === "super_admin") return true;
314
+ if (set.permissions.includes("*")) return true;
315
+ const [servicePart, actionPart] = splitPermission(required);
316
+ if (!servicePart) return set.permissions.includes(required);
317
+ const [serviceId, resource] = splitService(servicePart);
318
+ const candidates = new Set([
319
+ required,
320
+ "*",
321
+ serviceId ? `${serviceId}.*` : null,
322
+ serviceId && resource && actionPart ? `${serviceId}.${resource}:*` : null,
323
+ resource && actionPart ? `${resource}:*` : null
324
+ ].filter((v) => v !== null));
325
+ return set.permissions.some((granted) => candidates.has(granted));
326
+ }
327
+ function hasAnyScope(set, required) {
328
+ if (required.length === 0) return true;
329
+ return required.some((r) => hasScope(set, r));
330
+ }
331
+ function splitPermission(permission) {
332
+ const idx = permission.indexOf(":");
333
+ if (idx === -1) return [permission, null];
334
+ return [permission.slice(0, idx), permission.slice(idx + 1)];
335
+ }
336
+ function splitService(servicePart) {
337
+ const idx = servicePart.indexOf(".");
338
+ if (idx === -1) return [null, servicePart];
339
+ return [servicePart.slice(0, idx), servicePart.slice(idx + 1)];
340
+ }
341
+
342
+ // src/tools.ts
343
+ var TOOLS = [
344
+ {
345
+ name: "topolo_whoami",
346
+ title: "Describe the current Topolo credential",
347
+ description: "Returns the user, organization, and granted permissions attached to the current Topolo credential. Useful for confirming which organization the agent is currently acting on behalf of before taking further action. Never targets a different organization.",
348
+ requiredScopes: [],
349
+ destructive: false,
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {},
353
+ additionalProperties: false
354
+ },
355
+ handler: async (topolo) => topolo.identity.whoami()
356
+ },
357
+ {
358
+ name: "topolo_crm_list_contacts",
359
+ title: "List CRM contacts",
360
+ description: "List contacts in the authenticated organization from TopoloCRM. Supports full-text search and pagination. All results are scoped to the caller's organization \u2014 there is no way to query contacts from another org.",
361
+ requiredScopes: ["crm.contacts:read"],
362
+ destructive: false,
363
+ inputSchema: {
364
+ type: "object",
365
+ properties: {
366
+ query: { type: "string", description: "Full-text search string" },
367
+ page: { type: "integer", minimum: 1, default: 1 },
368
+ pageSize: { type: "integer", minimum: 1, maximum: 200, default: 25 }
369
+ },
370
+ additionalProperties: false
371
+ },
372
+ handler: async (topolo, args) => {
373
+ return topolo.crm.listContacts({
374
+ q: typeof args["query"] === "string" ? args["query"] : void 0,
375
+ page: typeof args["page"] === "number" ? args["page"] : void 0,
376
+ pageSize: typeof args["pageSize"] === "number" ? args["pageSize"] : void 0
377
+ });
378
+ }
379
+ },
380
+ {
381
+ name: "topolo_crm_get_contact",
382
+ title: "Fetch a single CRM contact",
383
+ description: "Fetch a single contact by id from TopoloCRM, scoped to the caller's organization.",
384
+ requiredScopes: ["crm.contacts:read"],
385
+ destructive: false,
386
+ inputSchema: {
387
+ type: "object",
388
+ properties: {
389
+ contactId: { type: "string", minLength: 1 }
390
+ },
391
+ required: ["contactId"],
392
+ additionalProperties: false
393
+ },
394
+ handler: async (topolo, args) => {
395
+ const contactId = args["contactId"];
396
+ if (typeof contactId !== "string" || !contactId) {
397
+ throw new Error("contactId is required");
398
+ }
399
+ const contact = await topolo.crm.getContact(contactId);
400
+ if (!contact) throw new Error(`Contact not found: ${contactId}`);
401
+ return contact;
402
+ }
403
+ }
404
+ ];
405
+ function filterToolsByScopes(tools, scopes) {
406
+ return tools.filter((t) => hasAnyScope(scopes, t.requiredScopes));
407
+ }
408
+
409
+ // src/version.ts
410
+ var MCP_VERSION = "0.1.0";
411
+
412
+ // src/index.ts
413
+ async function main() {
414
+ const credential = resolveCredentialFromEnv();
415
+ if (!credential) {
416
+ process.stderr.write(
417
+ "TopoloMCP: TOPOLO_API_KEY or TOPOLO_ACCESS_TOKEN must be set in the environment.\n"
418
+ );
419
+ process.exit(1);
420
+ }
421
+ const agentName = process.env.TOPOLO_AGENT_NAME;
422
+ const serviceUrls = resolveServiceUrlsFromEnv();
423
+ const topolo = createTopolo({
424
+ credential,
425
+ agent: {
426
+ clientName: "topolo-mcp",
427
+ clientVersion: MCP_VERSION,
428
+ ...agentName ? { agentName } : {}
429
+ },
430
+ serviceUrls,
431
+ requireConfirmForWrites: true
432
+ });
433
+ const scopes = await loadScopes(topolo);
434
+ const availableTools = filterToolsByScopes(TOOLS, scopes);
435
+ const server = new Server(
436
+ {
437
+ name: "topolo",
438
+ version: MCP_VERSION
439
+ },
440
+ {
441
+ capabilities: { tools: {} }
442
+ }
443
+ );
444
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
445
+ tools: availableTools.map((t) => ({
446
+ name: t.name,
447
+ title: t.title,
448
+ description: t.description,
449
+ inputSchema: t.inputSchema,
450
+ annotations: {
451
+ destructiveHint: t.destructive,
452
+ readOnlyHint: !t.destructive
453
+ }
454
+ }))
455
+ }));
456
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
457
+ const params = request.params;
458
+ const tool = availableTools.find((t) => t.name === params.name);
459
+ if (!tool) {
460
+ return toolError(
461
+ `Tool "${params.name}" is not available for this credential. Either the tool does not exist or the current scopes do not permit it.`
462
+ );
463
+ }
464
+ if (!hasAnyScope(scopes, tool.requiredScopes)) {
465
+ return toolError(
466
+ `Tool "${tool.name}" requires one of: ${tool.requiredScopes.join(", ")}. Current credential does not grant any of these.`
467
+ );
468
+ }
469
+ try {
470
+ const result = await tool.handler(topolo, params.arguments ?? {});
471
+ return {
472
+ content: [
473
+ { type: "text", text: JSON.stringify(result, null, 2) }
474
+ ]
475
+ };
476
+ } catch (err) {
477
+ if (err instanceof TopoloPermissionError) {
478
+ return toolError(`Permission denied. Required: ${err.required.join(", ") || "(unknown)"}`);
479
+ }
480
+ if (err instanceof TopoloAuthError) {
481
+ return toolError(`Auth error: ${err.message}`);
482
+ }
483
+ return toolError(err instanceof Error ? err.message : String(err));
484
+ }
485
+ });
486
+ const transport = new StdioServerTransport();
487
+ await server.connect(transport);
488
+ process.stderr.write(
489
+ `TopoloMCP v${MCP_VERSION} connected. Tools available: ${availableTools.map((t) => t.name).join(", ") || "(none)"}
490
+ `
491
+ );
492
+ }
493
+ async function loadScopes(topolo) {
494
+ try {
495
+ const info = await topolo.identity.whoami();
496
+ return {
497
+ permissions: info.user.permissions,
498
+ role: info.user.role
499
+ };
500
+ } catch (err) {
501
+ if (err instanceof TopoloAuthError) {
502
+ process.stderr.write(`TopoloMCP: credential rejected \u2014 ${err.message}
503
+ `);
504
+ process.exit(1);
505
+ }
506
+ throw err;
507
+ }
508
+ }
509
+ function toolError(message) {
510
+ return {
511
+ isError: true,
512
+ content: [{ type: "text", text: message }]
513
+ };
514
+ }
515
+ main().catch((err) => {
516
+ process.stderr.write(`TopoloMCP fatal: ${err instanceof Error ? err.message : String(err)}
517
+ `);
518
+ process.exit(1);
519
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@topolo/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for the Topolo platform. Exposes scope-gated tools that third-party agents (Claude, Codex, etc.) can call natively.",
5
+ "type": "module",
6
+ "bin": {
7
+ "topolo-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "dev": "tsup --watch",
15
+ "typecheck": "tsc --noEmit",
16
+ "start": "node dist/index.js",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@topolo/sdk": "file:../packages/topolo-sdk",
25
+ "@types/node": "^20.12.0",
26
+ "tsup": "^8.0.0",
27
+ "typescript": "^5.4.0",
28
+ "vitest": "^2.1.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ }
33
+ }