locker-mcp-server 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/dist/api.d.ts +19 -0
- package/dist/api.js +53 -0
- package/dist/api.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +41 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +95 -0
- package/dist/index.js.map +1 -0
- package/package.json +25 -0
- package/src/api.test.ts +109 -0
- package/src/api.ts +75 -0
- package/src/config.test.ts +15 -0
- package/src/config.ts +41 -0
- package/src/index.ts +110 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +8 -0
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { LockerConfig } from "./config";
|
|
2
|
+
export interface KeyResponse {
|
|
3
|
+
service: string;
|
|
4
|
+
key: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ServiceListItem {
|
|
7
|
+
service: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
lastUsed: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Retrieves a decrypted API key from the Locker API.
|
|
13
|
+
* Sends the agent identifier for audit logging.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getKey(config: LockerConfig, service: string, agentIdentifier?: string): Promise<KeyResponse>;
|
|
16
|
+
/**
|
|
17
|
+
* Lists all stored service names (not key values).
|
|
18
|
+
*/
|
|
19
|
+
export declare function listServices(config: LockerConfig): Promise<ServiceListItem[]>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getKey = getKey;
|
|
4
|
+
exports.listServices = listServices;
|
|
5
|
+
/**
|
|
6
|
+
* Retrieves a decrypted API key from the Locker API.
|
|
7
|
+
* Sends the agent identifier for audit logging.
|
|
8
|
+
*/
|
|
9
|
+
async function getKey(config, service, agentIdentifier = "mcp-server") {
|
|
10
|
+
const url = `${config.apiUrl}/keys/${encodeURIComponent(service)}`;
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
method: "GET",
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: `Bearer ${config.token}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
"X-Agent-Identifier": agentIdentifier,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
if (res.status === 401) {
|
|
20
|
+
throw new Error("Session expired. Run `locker login` to re-authenticate.");
|
|
21
|
+
}
|
|
22
|
+
if (res.status === 404) {
|
|
23
|
+
throw new Error(`No key found for service: ${service}. Store one with: locker set ${service} <key>`);
|
|
24
|
+
}
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const body = await res.json().catch(() => ({}));
|
|
27
|
+
throw new Error(body.error || `API error: ${res.status}`);
|
|
28
|
+
}
|
|
29
|
+
return (await res.json());
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Lists all stored service names (not key values).
|
|
33
|
+
*/
|
|
34
|
+
async function listServices(config) {
|
|
35
|
+
const url = `${config.apiUrl}/keys`;
|
|
36
|
+
const res = await fetch(url, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${config.token}`,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
if (res.status === 401) {
|
|
44
|
+
throw new Error("Session expired. Run `locker login` to re-authenticate.");
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const body = await res.json().catch(() => ({}));
|
|
48
|
+
throw new Error(body.error || `API error: ${res.status}`);
|
|
49
|
+
}
|
|
50
|
+
const data = (await res.json());
|
|
51
|
+
return data.services;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=api.js.map
|
package/dist/api.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":";;AAiBA,wBA6BC;AAKD,oCAuBC;AA7DD;;;GAGG;AACI,KAAK,UAAU,MAAM,CAC1B,MAAoB,EACpB,OAAe,EACf,kBAA0B,YAAY;IAEtC,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,MAAM,SAAS,kBAAkB,CAAC,OAAO,CAAC,EAAE,CAAC;IACnE,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;QAC3B,MAAM,EAAE,KAAK;QACb,OAAO,EAAE;YACP,aAAa,EAAE,UAAU,MAAM,CAAC,KAAK,EAAE;YACvC,cAAc,EAAE,kBAAkB;YAClC,oBAAoB,EAAE,eAAe;SACtC;KACF,CAAC,CAAC;IAEH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IAED,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,6BAA6B,OAAO,gCAAgC,OAAO,QAAQ,CAAC,CAAC;IACvG,CAAC;IAED,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAA6B,CAAA,CAA2B,CAAC;QACpG,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,cAAc,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5D,CAAC;IAED,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAgB,CAAC;AAC3C,CAAC;AAED;;GAEG;AACI,KAAK,UAAU,YAAY,CAChC,MAAoB;IAEpB,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,MAAM,OAAO,CAAC;IACpC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;QAC3B,MAAM,EAAE,KAAK;QACb,OAAO,EAAE;YACP,aAAa,EAAE,UAAU,MAAM,CAAC,KAAK,EAAE;YACvC,cAAc,EAAE,kBAAkB;SACnC;KACF,CAAC,CAAC;IAEH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IAED,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAA6B,CAAA,CAA2B,CAAC;QACpG,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,cAAc,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5D,CAAC;IAED,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAoC,CAAC;IACnE,OAAO,IAAI,CAAC,QAAQ,CAAC;AACvB,CAAC"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface LockerConfig {
|
|
2
|
+
token: string;
|
|
3
|
+
email: string;
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Reads the Locker CLI config from ~/.locker/config.
|
|
8
|
+
* This is the same file the CLI writes after `locker login`.
|
|
9
|
+
* Returns null if not found or invalid.
|
|
10
|
+
*/
|
|
11
|
+
export declare function readConfig(): LockerConfig | null;
|
|
12
|
+
/**
|
|
13
|
+
* Returns config or throws with a helpful message.
|
|
14
|
+
*/
|
|
15
|
+
export declare function requireConfig(): LockerConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readConfig = readConfig;
|
|
7
|
+
exports.requireConfig = requireConfig;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
const CONFIG_FILE = node_path_1.default.join(node_os_1.default.homedir(), ".locker", "config");
|
|
12
|
+
/**
|
|
13
|
+
* Reads the Locker CLI config from ~/.locker/config.
|
|
14
|
+
* This is the same file the CLI writes after `locker login`.
|
|
15
|
+
* Returns null if not found or invalid.
|
|
16
|
+
*/
|
|
17
|
+
function readConfig() {
|
|
18
|
+
try {
|
|
19
|
+
if (!node_fs_1.default.existsSync(CONFIG_FILE))
|
|
20
|
+
return null;
|
|
21
|
+
const raw = node_fs_1.default.readFileSync(CONFIG_FILE, "utf8");
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (!parsed.token || !parsed.email || !parsed.apiUrl)
|
|
24
|
+
return null;
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Returns config or throws with a helpful message.
|
|
33
|
+
*/
|
|
34
|
+
function requireConfig() {
|
|
35
|
+
const config = readConfig();
|
|
36
|
+
if (!config) {
|
|
37
|
+
throw new Error("Not logged in to Locker. Run `locker login` first to authenticate.");
|
|
38
|
+
}
|
|
39
|
+
return config;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":";;;;;AAiBA,gCAUC;AAKD,sCAQC;AAxCD,sDAAyB;AACzB,0DAA6B;AAC7B,sDAAyB;AAEzB,MAAM,WAAW,GAAG,mBAAI,CAAC,IAAI,CAAC,iBAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC;AAQjE;;;;GAIG;AACH,SAAgB,UAAU;IACxB,IAAI,CAAC;QACH,IAAI,CAAC,iBAAE,CAAC,UAAU,CAAC,WAAW,CAAC;YAAE,OAAO,IAAI,CAAC;QAC7C,MAAM,GAAG,GAAG,iBAAE,CAAC,YAAY,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACjD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QAClE,OAAO,MAAsB,CAAC;IAChC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,SAAgB,aAAa;IAC3B,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAC5B,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CACb,oEAAoE,CACrE,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
5
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
const config_1 = require("./config");
|
|
8
|
+
const api_1 = require("./api");
|
|
9
|
+
const server = new mcp_js_1.McpServer({
|
|
10
|
+
name: "locker",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
});
|
|
13
|
+
// ── Tool: locker_get ──
|
|
14
|
+
// Retrieves a decrypted API key for a given service.
|
|
15
|
+
server.tool("locker_get", "Retrieve an API key from Locker. Returns the decrypted key for the specified service. The retrieval is logged in the audit trail.", {
|
|
16
|
+
service: zod_1.z.string().describe("The service name (e.g. 'openai', 'resend', 'stripe')"),
|
|
17
|
+
agent: zod_1.z.string().optional().describe("Agent identifier for the audit log (defaults to 'mcp-server')"),
|
|
18
|
+
}, async ({ service, agent }) => {
|
|
19
|
+
try {
|
|
20
|
+
const config = (0, config_1.requireConfig)();
|
|
21
|
+
const result = await (0, api_1.getKey)(config, service, agent || "mcp-server");
|
|
22
|
+
return {
|
|
23
|
+
content: [
|
|
24
|
+
{
|
|
25
|
+
type: "text",
|
|
26
|
+
text: result.key,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return {
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
type: "text",
|
|
36
|
+
text: `Error: ${err.message}`,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// ── Tool: locker_list ──
|
|
44
|
+
// Lists all stored service names (never exposes key values).
|
|
45
|
+
server.tool("locker_list", "List all services that have API keys stored in Locker. Returns service names only — never the actual key values.", {}, async () => {
|
|
46
|
+
try {
|
|
47
|
+
const config = (0, config_1.requireConfig)();
|
|
48
|
+
const services = await (0, api_1.listServices)(config);
|
|
49
|
+
if (services.length === 0) {
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: "No keys stored in Locker. Store one with: locker set <service> <key>",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const lines = services.map((s) => {
|
|
60
|
+
const lastUsed = s.lastUsed
|
|
61
|
+
? `last used ${new Date(s.lastUsed).toLocaleDateString()}`
|
|
62
|
+
: "never used";
|
|
63
|
+
return ` ${s.service} (${lastUsed})`;
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: `${services.length} key${services.length === 1 ? "" : "s"} stored:\n${lines.join("\n")}`,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: "text",
|
|
79
|
+
text: `Error: ${err.message}`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// ── Start server ──
|
|
87
|
+
async function main() {
|
|
88
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
89
|
+
await server.connect(transport);
|
|
90
|
+
}
|
|
91
|
+
main().catch((err) => {
|
|
92
|
+
console.error("MCP server failed to start:", err);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
|
95
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "locker-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Locker — lets AI agents retrieve API keys securely",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"locker-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsx src/index.ts",
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"lint": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"tsx": "^4.19.0",
|
|
22
|
+
"typescript": "^5.6.0",
|
|
23
|
+
"vitest": "^2.1.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/api.test.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { getKey, listServices } from "./api";
|
|
3
|
+
|
|
4
|
+
const mockConfig = {
|
|
5
|
+
token: "jwt-test-token",
|
|
6
|
+
email: "test@example.com",
|
|
7
|
+
apiUrl: "http://localhost:3001",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const mockFetch = vi.fn();
|
|
11
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("getKey", () => {
|
|
19
|
+
it("sends authenticated request with agent identifier", async () => {
|
|
20
|
+
mockFetch.mockResolvedValueOnce({
|
|
21
|
+
ok: true,
|
|
22
|
+
status: 200,
|
|
23
|
+
json: async () => ({ service: "openai", key: "sk-abc123" }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const result = await getKey(mockConfig, "openai", "claude-code");
|
|
27
|
+
|
|
28
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
29
|
+
"http://localhost:3001/keys/openai",
|
|
30
|
+
expect.objectContaining({
|
|
31
|
+
method: "GET",
|
|
32
|
+
headers: expect.objectContaining({
|
|
33
|
+
Authorization: "Bearer jwt-test-token",
|
|
34
|
+
"X-Agent-Identifier": "claude-code",
|
|
35
|
+
}),
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
expect(result.key).toBe("sk-abc123");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("throws on 401 (expired token)", async () => {
|
|
42
|
+
mockFetch.mockResolvedValueOnce({
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 401,
|
|
45
|
+
json: async () => ({ error: "Invalid token" }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await expect(getKey(mockConfig, "openai")).rejects.toThrow("Session expired");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws on 404 (service not found)", async () => {
|
|
52
|
+
mockFetch.mockResolvedValueOnce({
|
|
53
|
+
ok: false,
|
|
54
|
+
status: 404,
|
|
55
|
+
json: async () => ({ error: "Not found" }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(getKey(mockConfig, "unknown")).rejects.toThrow("No key found");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("defaults agent identifier to mcp-server", async () => {
|
|
62
|
+
mockFetch.mockResolvedValueOnce({
|
|
63
|
+
ok: true,
|
|
64
|
+
status: 200,
|
|
65
|
+
json: async () => ({ service: "stripe", key: "sk-stripe-123" }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await getKey(mockConfig, "stripe");
|
|
69
|
+
|
|
70
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
71
|
+
expect.any(String),
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
headers: expect.objectContaining({
|
|
74
|
+
"X-Agent-Identifier": "mcp-server",
|
|
75
|
+
}),
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("listServices", () => {
|
|
82
|
+
it("returns service list", async () => {
|
|
83
|
+
mockFetch.mockResolvedValueOnce({
|
|
84
|
+
ok: true,
|
|
85
|
+
status: 200,
|
|
86
|
+
json: async () => ({
|
|
87
|
+
services: [
|
|
88
|
+
{ service: "openai", createdAt: "2026-01-01", lastUsed: null },
|
|
89
|
+
{ service: "resend", createdAt: "2026-01-02", lastUsed: "2026-01-03" },
|
|
90
|
+
],
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await listServices(mockConfig);
|
|
95
|
+
expect(result).toHaveLength(2);
|
|
96
|
+
expect(result[0].service).toBe("openai");
|
|
97
|
+
expect(result[1].service).toBe("resend");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("throws on 401", async () => {
|
|
101
|
+
mockFetch.mockResolvedValueOnce({
|
|
102
|
+
ok: false,
|
|
103
|
+
status: 401,
|
|
104
|
+
json: async () => ({}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await expect(listServices(mockConfig)).rejects.toThrow("Session expired");
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { LockerConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
export interface KeyResponse {
|
|
4
|
+
service: string;
|
|
5
|
+
key: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ServiceListItem {
|
|
9
|
+
service: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
lastUsed: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Retrieves a decrypted API key from the Locker API.
|
|
16
|
+
* Sends the agent identifier for audit logging.
|
|
17
|
+
*/
|
|
18
|
+
export async function getKey(
|
|
19
|
+
config: LockerConfig,
|
|
20
|
+
service: string,
|
|
21
|
+
agentIdentifier: string = "mcp-server"
|
|
22
|
+
): Promise<KeyResponse> {
|
|
23
|
+
const url = `${config.apiUrl}/keys/${encodeURIComponent(service)}`;
|
|
24
|
+
const res = await fetch(url, {
|
|
25
|
+
method: "GET",
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${config.token}`,
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
"X-Agent-Identifier": agentIdentifier,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (res.status === 401) {
|
|
34
|
+
throw new Error("Session expired. Run `locker login` to re-authenticate.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (res.status === 404) {
|
|
38
|
+
throw new Error(`No key found for service: ${service}. Store one with: locker set ${service} <key>`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const body = await res.json().catch(() => ({} as Record<string, string>)) as Record<string, string>;
|
|
43
|
+
throw new Error(body.error || `API error: ${res.status}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (await res.json()) as KeyResponse;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Lists all stored service names (not key values).
|
|
51
|
+
*/
|
|
52
|
+
export async function listServices(
|
|
53
|
+
config: LockerConfig
|
|
54
|
+
): Promise<ServiceListItem[]> {
|
|
55
|
+
const url = `${config.apiUrl}/keys`;
|
|
56
|
+
const res = await fetch(url, {
|
|
57
|
+
method: "GET",
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${config.token}`,
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (res.status === 401) {
|
|
65
|
+
throw new Error("Session expired. Run `locker login` to re-authenticate.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const body = await res.json().catch(() => ({} as Record<string, string>)) as Record<string, string>;
|
|
70
|
+
throw new Error(body.error || `API error: ${res.status}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = (await res.json()) as { services: ServiceListItem[] };
|
|
74
|
+
return data.services;
|
|
75
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("config", () => {
|
|
4
|
+
it("LockerConfig interface has required fields", () => {
|
|
5
|
+
// Type-level test — if this compiles, the interface is correct
|
|
6
|
+
const config = {
|
|
7
|
+
token: "jwt-123",
|
|
8
|
+
email: "test@example.com",
|
|
9
|
+
apiUrl: "http://localhost:3001",
|
|
10
|
+
};
|
|
11
|
+
expect(config.token).toBe("jwt-123");
|
|
12
|
+
expect(config.email).toBe("test@example.com");
|
|
13
|
+
expect(config.apiUrl).toBe("http://localhost:3001");
|
|
14
|
+
});
|
|
15
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILE = path.join(os.homedir(), ".locker", "config");
|
|
6
|
+
|
|
7
|
+
export interface LockerConfig {
|
|
8
|
+
token: string;
|
|
9
|
+
email: string;
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads the Locker CLI config from ~/.locker/config.
|
|
15
|
+
* This is the same file the CLI writes after `locker login`.
|
|
16
|
+
* Returns null if not found or invalid.
|
|
17
|
+
*/
|
|
18
|
+
export function readConfig(): LockerConfig | null {
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
21
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf8");
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (!parsed.token || !parsed.email || !parsed.apiUrl) return null;
|
|
24
|
+
return parsed as LockerConfig;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns config or throws with a helpful message.
|
|
32
|
+
*/
|
|
33
|
+
export function requireConfig(): LockerConfig {
|
|
34
|
+
const config = readConfig();
|
|
35
|
+
if (!config) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"Not logged in to Locker. Run `locker login` first to authenticate."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return config;
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { requireConfig } from "./config";
|
|
7
|
+
import { getKey, listServices } from "./api";
|
|
8
|
+
|
|
9
|
+
const server = new McpServer({
|
|
10
|
+
name: "locker",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// ── Tool: locker_get ──
|
|
15
|
+
// Retrieves a decrypted API key for a given service.
|
|
16
|
+
server.tool(
|
|
17
|
+
"locker_get",
|
|
18
|
+
"Retrieve an API key from Locker. Returns the decrypted key for the specified service. The retrieval is logged in the audit trail.",
|
|
19
|
+
{
|
|
20
|
+
service: z.string().describe("The service name (e.g. 'openai', 'resend', 'stripe')"),
|
|
21
|
+
agent: z.string().optional().describe("Agent identifier for the audit log (defaults to 'mcp-server')"),
|
|
22
|
+
},
|
|
23
|
+
async ({ service, agent }) => {
|
|
24
|
+
try {
|
|
25
|
+
const config = requireConfig();
|
|
26
|
+
const result = await getKey(config, service, agent || "mcp-server");
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: "text" as const,
|
|
32
|
+
text: result.key,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
return {
|
|
38
|
+
content: [
|
|
39
|
+
{
|
|
40
|
+
type: "text" as const,
|
|
41
|
+
text: `Error: ${err.message}`,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
isError: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// ── Tool: locker_list ──
|
|
51
|
+
// Lists all stored service names (never exposes key values).
|
|
52
|
+
server.tool(
|
|
53
|
+
"locker_list",
|
|
54
|
+
"List all services that have API keys stored in Locker. Returns service names only — never the actual key values.",
|
|
55
|
+
{},
|
|
56
|
+
async () => {
|
|
57
|
+
try {
|
|
58
|
+
const config = requireConfig();
|
|
59
|
+
const services = await listServices(config);
|
|
60
|
+
|
|
61
|
+
if (services.length === 0) {
|
|
62
|
+
return {
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: "text" as const,
|
|
66
|
+
text: "No keys stored in Locker. Store one with: locker set <service> <key>",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const lines = services.map((s) => {
|
|
73
|
+
const lastUsed = s.lastUsed
|
|
74
|
+
? `last used ${new Date(s.lastUsed).toLocaleDateString()}`
|
|
75
|
+
: "never used";
|
|
76
|
+
return ` ${s.service} (${lastUsed})`;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "text" as const,
|
|
83
|
+
text: `${services.length} key${services.length === 1 ? "" : "s"} stored:\n${lines.join("\n")}`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
} catch (err: any) {
|
|
88
|
+
return {
|
|
89
|
+
content: [
|
|
90
|
+
{
|
|
91
|
+
type: "text" as const,
|
|
92
|
+
text: `Error: ${err.message}`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
isError: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// ── Start server ──
|
|
102
|
+
async function main() {
|
|
103
|
+
const transport = new StdioServerTransport();
|
|
104
|
+
await server.connect(transport);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
main().catch((err) => {
|
|
108
|
+
console.error("MCP server failed to start:", err);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"sourceMap": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
18
|
+
}
|