@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.
- package/README.md +96 -0
- package/dist/index.js +519 -0
- 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
|
+
}
|