create-futurity-plugin 1.0.0 → 1.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/index.ts +57 -16
- package/package.json +5 -2
- package/template/README.md +51 -0
- package/template/_gitignore +7 -0
- package/template/env.example +10 -0
- package/template/package.json.tmpl +3 -2
- package/template/scripts/keygen.ts +8 -0
- package/template/src/auth.ts +91 -0
- package/template/src/index.ts +59 -7
package/index.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { resolve, basename } from "node:path";
|
|
4
2
|
import { execSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { basename, join, resolve } from "node:path";
|
|
5
14
|
|
|
6
15
|
const arg = process.argv[2];
|
|
7
16
|
|
|
@@ -15,6 +24,13 @@ const isCwd = arg === ".";
|
|
|
15
24
|
const targetDir = isCwd ? process.cwd() : resolve(process.cwd(), arg);
|
|
16
25
|
const projectName = isCwd ? basename(process.cwd()) : arg;
|
|
17
26
|
|
|
27
|
+
/** A plugin id is a URL-safe slug, so it is derived rather than asked for. */
|
|
28
|
+
const slug =
|
|
29
|
+
projectName
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/^-+|-+$/g, "") || "my-plugin";
|
|
33
|
+
|
|
18
34
|
if (!isCwd) {
|
|
19
35
|
if (existsSync(targetDir)) {
|
|
20
36
|
console.error(`Error: Directory "${arg}" already exists.`);
|
|
@@ -23,26 +39,51 @@ if (!isCwd) {
|
|
|
23
39
|
mkdirSync(targetDir, { recursive: true });
|
|
24
40
|
}
|
|
25
41
|
|
|
26
|
-
// Copy template files
|
|
27
42
|
const templateDir = resolve(import.meta.dirname, "template");
|
|
28
43
|
cpSync(templateDir, targetDir, { recursive: true });
|
|
29
44
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
// npm strips a .gitignore from a published tarball and never unpacks a .env
|
|
46
|
+
// file, so both ship under a plain name and are restored here.
|
|
47
|
+
const renames: [from: string, to: string][] = [
|
|
48
|
+
["package.json.tmpl", "package.json"],
|
|
49
|
+
["_gitignore", ".gitignore"],
|
|
50
|
+
["env.example", ".env.example"],
|
|
51
|
+
];
|
|
52
|
+
for (const [from, to] of renames) {
|
|
53
|
+
const source = join(targetDir, from);
|
|
54
|
+
if (existsSync(source)) renameSync(source, join(targetDir, to));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const TEXT_FILE = /\.(ts|json|md|example)$|^\.(gitignore|env\.example)$/;
|
|
58
|
+
|
|
59
|
+
function fillPlaceholders(dir: string) {
|
|
60
|
+
for (const entry of readdirSync(dir)) {
|
|
61
|
+
if (entry === "node_modules") continue;
|
|
62
|
+
const path = join(dir, entry);
|
|
63
|
+
if (statSync(path).isDirectory()) {
|
|
64
|
+
fillPlaceholders(path);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!TEXT_FILE.test(entry)) continue;
|
|
68
|
+
|
|
69
|
+
const content = readFileSync(path, "utf-8");
|
|
70
|
+
if (!content.includes("{{")) continue;
|
|
71
|
+
writeFileSync(
|
|
72
|
+
path,
|
|
73
|
+
content.replace(/\{\{name\}\}/g, projectName).replace(/\{\{slug\}\}/g, slug),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
fillPlaceholders(targetDir);
|
|
38
78
|
|
|
39
|
-
// Install dependencies
|
|
40
79
|
console.log(`\nScaffolding ${projectName}...\n`);
|
|
41
80
|
execSync("bun install", { cwd: targetDir, stdio: "inherit" });
|
|
42
81
|
|
|
43
82
|
console.log(`\n✅ Created ${projectName}\n`);
|
|
44
83
|
console.log("Next steps:\n");
|
|
45
|
-
if (!isCwd) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.log(" bun run
|
|
84
|
+
if (!isCwd) console.log(` cd ${arg}`);
|
|
85
|
+
console.log(" cp .env.example .env");
|
|
86
|
+
console.log(" bun run keygen # put the private key in .env");
|
|
87
|
+
console.log(" bun run dev # serves on :3000\n");
|
|
88
|
+
console.log("Then write your tools in src/index.ts, and exchange your own");
|
|
89
|
+
console.log("credentials in the token endpoint in src/auth.ts.\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-futurity-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a new Futurity MCP plugin",
|
|
6
6
|
"author": "Futurity",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"test": "echo 'No tests'"
|
|
13
13
|
},
|
|
14
|
-
"files": [
|
|
14
|
+
"files": [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"template"
|
|
17
|
+
],
|
|
15
18
|
"keywords": [
|
|
16
19
|
"create",
|
|
17
20
|
"scaffold",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A Futurity MCP plugin. It serves tools over MCP, and a signed manifest that
|
|
4
|
+
Futurity checks before it trusts them.
|
|
5
|
+
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
cp .env.example .env
|
|
10
|
+
bun run keygen # writes a keypair; put the private half in .env
|
|
11
|
+
bun run dev # http://localhost:3000/mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
With `FUTURITY_SIGNING_KEY` set, the manifest is served at
|
|
15
|
+
`/.well-known/futurity/plugin` with its detached signature in the
|
|
16
|
+
`X-Futurity-Signature` header:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl -i http://localhost:3000/.well-known/futurity/plugin
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Register it
|
|
23
|
+
|
|
24
|
+
1. Put the plugin on a public HTTPS address and set `PUBLIC_URL` to match.
|
|
25
|
+
While you develop: `cloudflared tunnel --url http://localhost:3000`.
|
|
26
|
+
2. In Corint, open **Integrations → Developer Tools → Add**, paste the base
|
|
27
|
+
URL (not the `/mcp` path), and click **Discover**.
|
|
28
|
+
3. Paste the public key from `bun run keygen` and click **Register Plugin**.
|
|
29
|
+
The signature is verified at that moment.
|
|
30
|
+
|
|
31
|
+
Registration needs plugin developer access on your account. Futurity
|
|
32
|
+
re-verifies the manifest every 24 hours, so keep the address stable.
|
|
33
|
+
|
|
34
|
+
## Connect it
|
|
35
|
+
|
|
36
|
+
This plugin declares the `client_credentials` grant, so one set of credentials
|
|
37
|
+
belongs to the whole organization and an administrator saves them once. They
|
|
38
|
+
land at your `/oauth/token` endpoint in `src/auth.ts`; whatever `access_token`
|
|
39
|
+
you return there arrives on every tool call as `X-Plugin-Access-Token`, and
|
|
40
|
+
`requireAccessToken()` hands it to your handler.
|
|
41
|
+
|
|
42
|
+
Other grants — per-person OAuth, SAML bearer, an API key, or your own auth
|
|
43
|
+
flow — are in the plugin authentication reference.
|
|
44
|
+
|
|
45
|
+
## Where to edit
|
|
46
|
+
|
|
47
|
+
| File | What lives there |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `src/index.ts` | The manifest, the middleware wiring, and your tools |
|
|
50
|
+
| `src/auth.ts` | The token endpoint and the per-request credentials |
|
|
51
|
+
| `scripts/keygen.ts` | The Ed25519 keypair generator |
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Ed25519 private key that signs the plugin manifest. Generate with `bun run keygen`.
|
|
2
|
+
# Without it the server still runs, but it serves no manifest and cannot be registered.
|
|
3
|
+
FUTURITY_SIGNING_KEY=
|
|
4
|
+
|
|
5
|
+
# The address Futurity reaches this plugin on. Must be public HTTPS before you
|
|
6
|
+
# register — a quick tunnel works while you develop:
|
|
7
|
+
# cloudflared tunnel --url http://localhost:3000
|
|
8
|
+
PUBLIC_URL=http://localhost:3000
|
|
9
|
+
|
|
10
|
+
PORT=3000
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "{{
|
|
2
|
+
"name": "{{slug}}",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bun run --watch src/index.ts",
|
|
7
|
-
"start": "bun run src/index.ts"
|
|
7
|
+
"start": "bun run src/index.ts",
|
|
8
|
+
"keygen": "bun run scripts/keygen.ts"
|
|
8
9
|
},
|
|
9
10
|
"dependencies": {
|
|
10
11
|
"@futurity/plugins": "^2.0.0"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { generateKeyPair } from "@futurity/plugins/signing";
|
|
2
|
+
|
|
3
|
+
const { privateKey, publicKey } = generateKeyPair();
|
|
4
|
+
|
|
5
|
+
console.log("\nPrivate key (PKCS8 DER, base64) — keep secret, put it in .env:\n");
|
|
6
|
+
console.log(`FUTURITY_SIGNING_KEY=${privateKey}\n`);
|
|
7
|
+
console.log("Public key (SPKI DER, base64) — paste this into Futurity when you register:\n");
|
|
8
|
+
console.log(`${publicKey}\n`);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { Middleware } from "@futurity/plugins";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Futurity sends two credentials on every tool call. `Authorization` carries
|
|
6
|
+
* Futurity's own short-lived token, which identifies the platform. The token
|
|
7
|
+
* your API cares about is in `X-Plugin-Access-Token`, and any extra fields the
|
|
8
|
+
* administrator filled in ride along as JSON in `X-Futurity-Data-Params`.
|
|
9
|
+
*/
|
|
10
|
+
export type PluginRequest = {
|
|
11
|
+
accessToken: string | null;
|
|
12
|
+
dataParams: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const store = new AsyncLocalStorage<PluginRequest>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The credentials for the call being served. Tool handlers receive their input
|
|
19
|
+
* only, so the request travels here instead of through an argument.
|
|
20
|
+
*/
|
|
21
|
+
export function currentRequest(): PluginRequest {
|
|
22
|
+
return store.getStore() ?? { accessToken: null, dataParams: {} };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The forwarded token, or a thrown error when the caller sent none. */
|
|
26
|
+
export function requireAccessToken(): string {
|
|
27
|
+
const { accessToken } = currentRequest();
|
|
28
|
+
if (!accessToken) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"No X-Plugin-Access-Token on this request. Connect the integration in Futurity first.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return accessToken;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseDataParams(raw: string | null): Record<string, string> {
|
|
37
|
+
if (!raw) return {};
|
|
38
|
+
try {
|
|
39
|
+
const parsed: unknown = JSON.parse(raw);
|
|
40
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
41
|
+
return Object.fromEntries(
|
|
42
|
+
Object.entries(parsed as Record<string, unknown>).filter(
|
|
43
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bind each request's credentials for the duration of that request. An async
|
|
53
|
+
* store rather than a module variable, so overlapping calls cannot read each
|
|
54
|
+
* other's token.
|
|
55
|
+
*/
|
|
56
|
+
export const forwardedAuth: Middleware = (req, next) =>
|
|
57
|
+
store.run(
|
|
58
|
+
{
|
|
59
|
+
accessToken: req.headers.get("X-Plugin-Access-Token"),
|
|
60
|
+
dataParams: parseDataParams(req.headers.get("X-Futurity-Data-Params")),
|
|
61
|
+
},
|
|
62
|
+
() => next(req),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `client_credentials` token endpoint named by the manifest. Futurity posts
|
|
67
|
+
* the credentials an administrator saved, caches what you return until shortly
|
|
68
|
+
* before `expires_in`, and forwards `access_token` on every tool call.
|
|
69
|
+
*/
|
|
70
|
+
export const tokenEndpoint = (path: string): Middleware => {
|
|
71
|
+
return async (req, next) => {
|
|
72
|
+
if (new URL(req.url).pathname !== path) return next(req);
|
|
73
|
+
if (req.method !== "POST") return next(req);
|
|
74
|
+
|
|
75
|
+
const form = await req.formData();
|
|
76
|
+
const clientId = String(form.get("client_id") ?? "");
|
|
77
|
+
const clientSecret = String(form.get("client_secret") ?? "");
|
|
78
|
+
|
|
79
|
+
if (!clientId || !clientSecret) {
|
|
80
|
+
return Response.json({ error: "invalid_client" }, { status: 401 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// YOUR CODE HERE: exchange these credentials with the system you wrap, and
|
|
84
|
+
// return its token. The stub below lets the whole flow run end to end first.
|
|
85
|
+
return Response.json({
|
|
86
|
+
access_token: `${clientId}-demo-token`,
|
|
87
|
+
token_type: "Bearer",
|
|
88
|
+
expires_in: 3600,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
};
|
package/template/src/index.ts
CHANGED
|
@@ -1,16 +1,68 @@
|
|
|
1
|
-
import { mcp, t } from "@futurity/plugins";
|
|
1
|
+
import { cors, mcp, t } from "@futurity/plugins";
|
|
2
|
+
import type { PluginManifestOptions } from "@futurity/plugins";
|
|
3
|
+
import { forwardedAuth, requireAccessToken, tokenEndpoint } from "./auth";
|
|
4
|
+
|
|
5
|
+
const PORT = Number(process.env.PORT ?? 3000);
|
|
6
|
+
|
|
7
|
+
// Futurity reads the manifest and calls the tools from its own servers, so the
|
|
8
|
+
// URLs the manifest publishes must be the ones Futurity can reach.
|
|
9
|
+
const PUBLIC_URL = process.env.PUBLIC_URL ?? `http://localhost:${PORT}`;
|
|
10
|
+
const TOKEN_PATH = "/oauth/token";
|
|
11
|
+
|
|
12
|
+
const signingKey = process.env.FUTURITY_SIGNING_KEY;
|
|
13
|
+
if (!signingKey) {
|
|
14
|
+
console.warn(
|
|
15
|
+
"FUTURITY_SIGNING_KEY is not set, so /.well-known/futurity/plugin is not served.\n" +
|
|
16
|
+
"Run `bun run keygen`, put the private key in .env, and restart to register this plugin.",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const manifest: PluginManifestOptions | undefined = signingKey
|
|
21
|
+
? {
|
|
22
|
+
specVersion: 2,
|
|
23
|
+
pluginId: "{{slug}}",
|
|
24
|
+
name: "{{name}}",
|
|
25
|
+
version: "0.1.0",
|
|
26
|
+
mcpUrl: `${PUBLIC_URL}/mcp`,
|
|
27
|
+
auth: {
|
|
28
|
+
type: "forwarding",
|
|
29
|
+
grantType: "client_credentials",
|
|
30
|
+
tokenEndpoint: `${PUBLIC_URL}${TOKEN_PATH}`,
|
|
31
|
+
requiredScopes: [],
|
|
32
|
+
deliveryMethod: "header",
|
|
33
|
+
},
|
|
34
|
+
signingKey,
|
|
35
|
+
}
|
|
36
|
+
: undefined;
|
|
2
37
|
|
|
3
38
|
const app = mcp({
|
|
4
|
-
name: "
|
|
39
|
+
name: "{{slug}}",
|
|
5
40
|
version: "0.1.0",
|
|
41
|
+
pluginManifest: manifest,
|
|
6
42
|
});
|
|
7
43
|
|
|
44
|
+
app.use(cors()).middleware(tokenEndpoint(TOKEN_PATH)).middleware(forwardedAuth);
|
|
45
|
+
|
|
46
|
+
// A tool is a name, a description, an input schema, and a handler. The agent
|
|
47
|
+
// reads the description alone when it chooses a tool, so write it as *when to
|
|
48
|
+
// use this*, not as what it does internally.
|
|
8
49
|
app.tool("hello", {
|
|
9
|
-
description: "
|
|
50
|
+
description: "Use when someone asks this plugin to greet a person by name.",
|
|
10
51
|
input: t.obj({ name: t.str }),
|
|
11
|
-
handler: async ({ name }) => ({
|
|
12
|
-
|
|
13
|
-
|
|
52
|
+
handler: async ({ name }) => ({ greeting: `Hello, ${name}!` }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Delete this one once your own tools call a real API. `requireAccessToken()`
|
|
56
|
+
// returns the credential Futurity forwarded for the caller.
|
|
57
|
+
app.tool("whoami", {
|
|
58
|
+
description:
|
|
59
|
+
"Use to prove the connection works. Returns the identity behind the forwarded credentials.",
|
|
60
|
+
input: t.obj({}),
|
|
61
|
+
handler: async () => {
|
|
62
|
+
const token = requireAccessToken();
|
|
63
|
+
// YOUR CODE HERE: call the system you wrap with this token.
|
|
64
|
+
return { connected: true, tokenPreview: `${token.slice(0, 6)}…` };
|
|
65
|
+
},
|
|
14
66
|
});
|
|
15
67
|
|
|
16
|
-
await app.listen(
|
|
68
|
+
await app.listen(PORT);
|