openapi-explorer-mcp 0.0.2 → 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 +43 -39
- package/dist/auth.d.ts +40 -56
- package/dist/auth.js +136 -56
- package/dist/config.d.ts +0 -2
- package/dist/config.js +2 -5
- package/dist/schemas.d.ts +5 -6
- package/dist/schemas.js +11 -8
- package/dist/server.d.ts +11 -5
- package/dist/server.js +67 -56
- package/package.json +1 -8
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ the spec and one generic caller.
|
|
|
19
19
|
| `api_get` | Calls a GET endpoint. |
|
|
20
20
|
| `api_request` | Calls an endpoint with any method. Registered only with `OPENAPI_ALLOW_WRITE`; destructive endpoints need `confirm_danger: true`. |
|
|
21
21
|
| `api_call_log` | Journal of `api_request` calls with ids from responses, for cleaning up. |
|
|
22
|
-
| `
|
|
22
|
+
| `api_credentials` | Keeps credentials for the session or forgets them; shows where the credential of each scheme comes from, never the value. |
|
|
23
23
|
| `recipe` | Markdown recipes for this API. Registered only with `OPENAPI_RECIPES_DIR`. |
|
|
24
24
|
|
|
25
25
|
## Configuration
|
|
@@ -27,10 +27,9 @@ the spec and one generic caller.
|
|
|
27
27
|
| Variable | |
|
|
28
28
|
|---|---|
|
|
29
29
|
| `OPENAPI_SPEC_URL` | Required. URL or file path of an OpenAPI 3 JSON spec. URLs are cached on disk and revalidated with ETag. |
|
|
30
|
-
| `OPENAPI_BASE_URL` | Base URL for calls. Required
|
|
31
|
-
| `OPENAPI_AUTH_<SCHEME>` |
|
|
32
|
-
| `OPENAPI_HEADER_<NAME>` | A header sent with every call,
|
|
33
|
-
| `OPENAPI_AUTH_MODULE` | Path to an ES module that supplies credentials minted at runtime. |
|
|
30
|
+
| `OPENAPI_BASE_URL` | Base URL for calls. Required when a credential or header is configured in the environment; otherwise `servers[0].url` of the spec is used. |
|
|
31
|
+
| `OPENAPI_AUTH_<SCHEME>` | A permanent credential for a security scheme — see [Authentication](#authentication). |
|
|
32
|
+
| `OPENAPI_HEADER_<NAME>` | A header sent with every call, whatever the spec says. `OPENAPI_HEADER_X_API_KEY` sends `x-api-key`. |
|
|
34
33
|
| `OPENAPI_ENV_FILE` | Env file merged into the environment at startup; variables already set win. |
|
|
35
34
|
| `OPENAPI_ALLOW_WRITE` | `1`, `true` or `yes` registers `api_request`. Off by default. |
|
|
36
35
|
| `OPENAPI_DANGER_FILE` | JSON with danger overrides — see [Danger rules](#danger-rules). |
|
|
@@ -63,11 +62,34 @@ the spec and one generic caller.
|
|
|
63
62
|
## Authentication
|
|
64
63
|
|
|
65
64
|
The server doesn't invent headers — it reads them from the spec. `components.securitySchemes` says where a secret
|
|
66
|
-
goes, and each operation's `security` says which schemes it accepts.
|
|
65
|
+
goes, and each operation's `security` says which schemes it accepts. A scheme takes its value from the first of
|
|
66
|
+
three places that has one:
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
| Source | Lives | How |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| the call | one call | `credentials` argument of `api_get` and `api_request` |
|
|
71
|
+
| the session | until the server restarts | `api_credentials` with `set` and `clear` |
|
|
72
|
+
| the environment | as long as the configuration | `OPENAPI_AUTH_<SCHEME>` |
|
|
73
|
+
|
|
74
|
+
**Permanent credentials** — a static admin token, a service API key — belong in the environment: they stay out of
|
|
75
|
+
the conversation. The variable name is the scheme name upper-cased with every other character replaced by `_`:
|
|
76
|
+
`x-admin-token` → `OPENAPI_AUTH_X_ADMIN_TOKEN`, `bearer` → `OPENAPI_AUTH_BEARER`. A header every call must carry,
|
|
77
|
+
whatever the spec says, goes to `OPENAPI_HEADER_<NAME>`.
|
|
78
|
+
|
|
79
|
+
**Credentials the model supplies** — a token it just obtained, a key the user pasted into the chat — go through
|
|
80
|
+
`credentials` or `api_credentials`. Keys are scheme names as `api_spec_info` lists them; an `apiKey` scheme also
|
|
81
|
+
accepts its header, query or cookie name, so `X-Api-Key` finds a scheme named `ApiKeyAuth`. When the spec declares
|
|
82
|
+
no security schemes at all, the keys are sent as plain headers.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
api_request(method: "POST", endpoint: "POST /auth/login", body: { … })
|
|
86
|
+
api_credentials(set: { "bearer": "<accessToken from the response>" })
|
|
87
|
+
api_get(endpoint: "GET /me")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
When a call with a credential gets `401`, the response says so in `note.auth`: obtain a fresh value and set it again.
|
|
91
|
+
|
|
92
|
+
**Placement.** Whatever the source, the value goes where the scheme says:
|
|
71
93
|
|
|
72
94
|
| Scheme | Placement |
|
|
73
95
|
|---|---|
|
|
@@ -76,38 +98,20 @@ value is placed where the scheme says:
|
|
|
76
98
|
| `http` `basic` | `Authorization: Basic …` — give `user:password` or an already encoded value |
|
|
77
99
|
|
|
78
100
|
**Which scheme a call uses.** `security` is a list of alternatives. With `as: "auto"` (the default) the server
|
|
79
|
-
takes the first alternative whose schemes all have credentials. `as` can also name a scheme to
|
|
80
|
-
`"anonymous"`. When nothing is
|
|
81
|
-
but also answer without it); any other method fails
|
|
82
|
-
|
|
83
|
-
**Tokens minted at runtime.** `OPENAPI_AUTH_MODULE` points to an ES module whose default export creates a provider.
|
|
84
|
-
The `identity` argument of `api_get` and `api_request` is passed to it as is. Types are exported by the package:
|
|
85
|
-
|
|
86
|
-
```ts
|
|
87
|
-
import type { AuthProviderFactory } from 'openapi-explorer-mcp';
|
|
88
|
-
|
|
89
|
-
const createAuth: AuthProviderFactory = ({ baseUrl, timeoutMs, env }) => ({
|
|
90
|
-
canProvide: (scheme, { identity }) => scheme === 'bearer' && Boolean(identity ?? env.DEFAULT_USER),
|
|
91
|
-
getCredential: async (scheme, { identity, force }) => mintToken(baseUrl, identity ?? env.DEFAULT_USER, { force, timeoutMs }),
|
|
92
|
-
// optional: registers api_auth
|
|
93
|
-
authenticate: async ({ identity, force }) => ({ identity: identity ?? 'default', accessToken: await mintToken(/* … */) }),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
export default createAuth;
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
A static credential from `OPENAPI_AUTH_<SCHEME>` wins over the module for the same scheme. When a call that used a
|
|
100
|
-
module credential gets `401`, the server asks the module again with `force: true` and retries once.
|
|
101
|
+
takes the first alternative whose schemes all have credentials, from any source. `as` can also name a scheme to
|
|
102
|
+
force it, or be `"anonymous"`. When nothing is available, a GET is sent anonymously with a note (many GET endpoints
|
|
103
|
+
declare auth but also answer without it); any other method fails and names every way to supply the credential.
|
|
101
104
|
|
|
102
105
|
**What keeps credentials safe**
|
|
103
106
|
|
|
104
|
-
-
|
|
105
|
-
|
|
106
|
-
- Credentials
|
|
107
|
-
`servers`
|
|
108
|
-
|
|
109
|
-
-
|
|
110
|
-
|
|
107
|
+
- Credentials go only to the base URL. The origin of every request is checked against it before sending, and path
|
|
108
|
+
parameters are URL-encoded, so a path can't redirect a request elsewhere.
|
|
109
|
+
- Credentials from the environment need `OPENAPI_BASE_URL` set explicitly: a server configuration is long-lived and
|
|
110
|
+
often shared, while the spec's `servers` comes over the network. Credentials the model supplies go to the base URL
|
|
111
|
+
that `api_spec_info` and `api_credentials` report — `OPENAPI_BASE_URL`, or the first server of the spec.
|
|
112
|
+
- Tool output and the call journal never contain values: they name the scheme and where its credential came from,
|
|
113
|
+
e.g. `bearer (session)`. A value the model supplies is part of the conversation by nature — keep secrets that
|
|
114
|
+
must not be there in the environment.
|
|
111
115
|
|
|
112
116
|
## Danger rules
|
|
113
117
|
|
|
@@ -130,7 +134,7 @@ Every non-GET operation is `write`, and `destructive` when it is a `DELETE` or i
|
|
|
130
134
|
npm install
|
|
131
135
|
npm run typecheck
|
|
132
136
|
npm run build # tsc into dist/
|
|
133
|
-
npm run smoke # stdio checks against scripts/fixtures/pets.json, no
|
|
137
|
+
npm run smoke # stdio checks against scripts/fixtures/pets.json and a local HTTP server, no internet
|
|
134
138
|
npm run check # all three
|
|
135
139
|
npm run smoke:package # packs the tarball, installs it in a clean directory and runs the smoke there
|
|
136
140
|
```
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,51 +1,16 @@
|
|
|
1
1
|
import { type ExplorerConfig } from './config.js';
|
|
2
2
|
import type { Operation, SecurityScheme } from './spec-index.js';
|
|
3
|
+
/** Where a credential used by a call came from. */
|
|
4
|
+
export type CredentialSource = 'call' | 'session' | 'env';
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* Credentials the model supplied, resolved against the spec.
|
|
5
7
|
*/
|
|
6
|
-
export interface
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
|
|
8
|
+
export interface SuppliedCredentials {
|
|
9
|
+
/** Values keyed by security scheme name. */
|
|
10
|
+
schemes: Map<string, string>;
|
|
11
|
+
/** Plain headers keyed by lower-cased name; only for specs that declare no security schemes. */
|
|
12
|
+
headers: Map<string, string>;
|
|
11
13
|
}
|
|
12
|
-
/**
|
|
13
|
-
* Tokens a provider minted, shown by the api_auth tool.
|
|
14
|
-
*/
|
|
15
|
-
export interface AuthSession {
|
|
16
|
-
/** Who the tokens belong to. */
|
|
17
|
-
identity: string;
|
|
18
|
-
/** When the access token expires, ISO 8601. */
|
|
19
|
-
expiresAt?: string;
|
|
20
|
-
/** Access token. */
|
|
21
|
-
accessToken?: string;
|
|
22
|
-
/** Refresh token. */
|
|
23
|
-
refreshToken?: string;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Supplies credentials that can't be static, e.g. tokens minted per user.
|
|
27
|
-
*/
|
|
28
|
-
export interface AuthProvider {
|
|
29
|
-
/** Whether the provider can supply a credential for the scheme in this context, without doing I/O. */
|
|
30
|
-
canProvide(scheme: string, context: AuthContext): boolean;
|
|
31
|
-
/** Returns the credential value for the scheme. */
|
|
32
|
-
getCredential(scheme: string, context: AuthContext): Promise<string>;
|
|
33
|
-
/** Mints or refreshes tokens for an identity; registers the api_auth tool when present. */
|
|
34
|
-
authenticate?(context: AuthContext): Promise<AuthSession>;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* What a provider factory receives.
|
|
38
|
-
*/
|
|
39
|
-
export interface AuthProviderOptions {
|
|
40
|
-
/** OPENAPI_BASE_URL. */
|
|
41
|
-
baseUrl: string;
|
|
42
|
-
/** OPENAPI_TIMEOUT_MS. */
|
|
43
|
-
timeoutMs: number;
|
|
44
|
-
/** The process environment, with OPENAPI_ENV_FILE already merged in. */
|
|
45
|
-
env: NodeJS.ProcessEnv;
|
|
46
|
-
}
|
|
47
|
-
/** The default export of an OPENAPI_AUTH_MODULE. */
|
|
48
|
-
export type AuthProviderFactory = (options: AuthProviderOptions) => AuthProvider | Promise<AuthProvider>;
|
|
49
14
|
/** Which security alternative a call uses. */
|
|
50
15
|
export type AuthSelection = {
|
|
51
16
|
mode: 'anonymous';
|
|
@@ -55,36 +20,55 @@ export type AuthSelection = {
|
|
|
55
20
|
schemes: string[];
|
|
56
21
|
};
|
|
57
22
|
/**
|
|
58
|
-
*
|
|
23
|
+
* An empty set of supplied credentials.
|
|
59
24
|
*/
|
|
60
|
-
export declare function
|
|
25
|
+
export declare function emptyCredentials(): SuppliedCredentials;
|
|
61
26
|
/**
|
|
62
|
-
*
|
|
27
|
+
* Where a scheme puts its value, for humans: "header x-api-key" or "Authorization: Bearer".
|
|
28
|
+
*/
|
|
29
|
+
export declare function placement(scheme: SecurityScheme): string;
|
|
30
|
+
/**
|
|
31
|
+
* Maps the keys of supplied credentials onto the spec; for a spec without security schemes they are plain headers.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveSupplied(input: Record<string, string>, schemes: Record<string, SecurityScheme>): SuppliedCredentials;
|
|
34
|
+
/**
|
|
35
|
+
* Maps credentials onto the security schemes of the spec. A value passed in a call wins over one kept for the
|
|
36
|
+
* session, which wins over the environment.
|
|
63
37
|
*/
|
|
64
38
|
export declare class Credentials {
|
|
65
39
|
private readonly config;
|
|
66
|
-
private readonly
|
|
67
|
-
constructor(config: ExplorerConfig
|
|
40
|
+
private readonly session;
|
|
41
|
+
constructor(config: ExplorerConfig);
|
|
42
|
+
/**
|
|
43
|
+
* Keeps supplied credentials in memory for the rest of the server session.
|
|
44
|
+
*/
|
|
45
|
+
remember(supplied: SuppliedCredentials): void;
|
|
46
|
+
/**
|
|
47
|
+
* Forgets session credentials by key, or all of them for "*"; returns the names that were kept before.
|
|
48
|
+
*/
|
|
49
|
+
forget(keys: string[], schemes: Record<string, SecurityScheme>): string[];
|
|
68
50
|
/**
|
|
69
51
|
* Where the credential of a scheme comes from, or null when nothing supplies it.
|
|
70
52
|
*/
|
|
71
|
-
source(scheme: string,
|
|
53
|
+
source(scheme: string, call?: SuppliedCredentials): CredentialSource | null;
|
|
72
54
|
/**
|
|
73
|
-
* Credential status of a scheme
|
|
55
|
+
* Credential status of a scheme outside a call: its source, or "not configured".
|
|
74
56
|
*/
|
|
75
57
|
describe(scheme: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Names of plain headers kept for the session.
|
|
60
|
+
*/
|
|
61
|
+
sessionHeaders(): string[];
|
|
76
62
|
/**
|
|
77
63
|
* Picks the security alternative for a call: a forced scheme, the first alternative with all credentials, or anonymous.
|
|
78
64
|
*/
|
|
79
|
-
select(op: Operation, as: string,
|
|
65
|
+
select(op: Operation, as: string, call: SuppliedCredentials, schemes: Record<string, SecurityScheme>): AuthSelection;
|
|
80
66
|
/**
|
|
81
|
-
* Puts the credentials
|
|
67
|
+
* Puts the selected credentials and plain headers into the request; returns what was used, without values.
|
|
82
68
|
*/
|
|
83
|
-
apply(selection: AuthSelection, schemes: Record<string, SecurityScheme>,
|
|
84
|
-
fromProvider: boolean;
|
|
85
|
-
}>;
|
|
69
|
+
apply(selection: AuthSelection, schemes: Record<string, SecurityScheme>, call: SuppliedCredentials, headers: Record<string, string>, url: URL): string;
|
|
86
70
|
/**
|
|
87
|
-
* Tells
|
|
71
|
+
* Tells how to supply a credential for a scheme.
|
|
88
72
|
*/
|
|
89
73
|
private hint;
|
|
90
74
|
}
|
package/dist/auth.js
CHANGED
|
@@ -1,20 +1,67 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { schemeEnvName, schemeEnvSuffix } from './config.js';
|
|
2
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* An empty set of supplied credentials.
|
|
5
5
|
*/
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
export function emptyCredentials() {
|
|
7
|
+
return { schemes: new Map(), headers: new Map() };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Where a scheme puts its value, for humans: "header x-api-key" or "Authorization: Bearer".
|
|
11
|
+
*/
|
|
12
|
+
export function placement(scheme) {
|
|
13
|
+
if (scheme.type === 'apiKey')
|
|
14
|
+
return `${scheme.in ?? '?'} ${scheme.name ?? '?'}`;
|
|
15
|
+
if (scheme.type !== 'http')
|
|
16
|
+
return 'Authorization: Bearer';
|
|
17
|
+
const kind = (scheme.scheme ?? '').toLowerCase();
|
|
18
|
+
if (kind === 'bearer')
|
|
19
|
+
return 'Authorization: Bearer';
|
|
20
|
+
if (kind === 'basic')
|
|
21
|
+
return 'Authorization: Basic';
|
|
22
|
+
return `Authorization: ${scheme.scheme ?? '?'}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Lists the security schemes of a spec with where each one puts its value.
|
|
26
|
+
*/
|
|
27
|
+
function describeSchemes(schemes) {
|
|
28
|
+
const entries = Object.entries(schemes);
|
|
29
|
+
return entries.length ? entries.map(([name, scheme]) => `${name} (${placement(scheme)})`).join(', ') : 'none';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Finds the scheme a key names: the scheme name itself, or the header, query or cookie name of an apiKey scheme.
|
|
33
|
+
*/
|
|
34
|
+
function resolveSchemeName(key, schemes) {
|
|
35
|
+
if (schemes[key])
|
|
36
|
+
return key;
|
|
37
|
+
const lower = key.toLowerCase();
|
|
38
|
+
const matches = Object.entries(schemes)
|
|
39
|
+
.filter(([name, scheme]) => name.toLowerCase() === lower || (scheme.type === 'apiKey' && scheme.name?.toLowerCase() === lower))
|
|
40
|
+
.map(([name]) => name);
|
|
41
|
+
if (matches.length === 1)
|
|
42
|
+
return matches[0];
|
|
43
|
+
if (matches.length > 1)
|
|
44
|
+
throw new Error(`"${key}" matches several security schemes (${matches.join(', ')}); use the scheme name`);
|
|
45
|
+
throw new Error(`unknown credential "${key}"; the spec defines: ${describeSchemes(schemes)}`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Maps the keys of supplied credentials onto the spec; for a spec without security schemes they are plain headers.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveSupplied(input, schemes) {
|
|
51
|
+
const out = emptyCredentials();
|
|
52
|
+
const hasSchemes = Object.keys(schemes).length > 0;
|
|
53
|
+
for (const [key, value] of Object.entries(input)) {
|
|
54
|
+
if (/[\r\n]/.test(value))
|
|
55
|
+
throw new Error(`the value of "${key}" contains a line break`);
|
|
56
|
+
if (hasSchemes) {
|
|
57
|
+
out.schemes.set(resolveSchemeName(key, schemes), value);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!HEADER_NAME.test(key))
|
|
61
|
+
throw new Error(`"${key}" is not a valid header name; the spec declares no security schemes, so keys are sent as headers`);
|
|
62
|
+
out.headers.set(key.toLowerCase(), value);
|
|
16
63
|
}
|
|
17
|
-
return
|
|
64
|
+
return out;
|
|
18
65
|
}
|
|
19
66
|
/**
|
|
20
67
|
* Places a credential where its security scheme says: a header, the query string, a cookie or Authorization.
|
|
@@ -25,11 +72,11 @@ function applyScheme(name, scheme, value, headers, url) {
|
|
|
25
72
|
if (!scheme.name)
|
|
26
73
|
throw new Error(`security scheme "${name}" has no parameter name`);
|
|
27
74
|
if (scheme.in === 'header')
|
|
28
|
-
headers[scheme.name] = value;
|
|
75
|
+
headers[scheme.name.toLowerCase()] = value;
|
|
29
76
|
else if (scheme.in === 'query')
|
|
30
77
|
url.searchParams.set(scheme.name, value);
|
|
31
78
|
else if (scheme.in === 'cookie')
|
|
32
|
-
headers.
|
|
79
|
+
headers.cookie = [headers.cookie, `${scheme.name}=${encodeURIComponent(value)}`].filter(Boolean).join('; ');
|
|
33
80
|
else
|
|
34
81
|
throw new Error(`security scheme "${name}" uses an unsupported location "${scheme.in}"`);
|
|
35
82
|
return;
|
|
@@ -37,66 +84,97 @@ function applyScheme(name, scheme, value, headers, url) {
|
|
|
37
84
|
case 'http': {
|
|
38
85
|
const kind = (scheme.scheme ?? '').toLowerCase();
|
|
39
86
|
if (kind === 'bearer')
|
|
40
|
-
headers.
|
|
87
|
+
headers.authorization = `Bearer ${value}`;
|
|
41
88
|
else if (kind === 'basic')
|
|
42
|
-
headers.
|
|
89
|
+
headers.authorization = `Basic ${value.includes(':') ? Buffer.from(value).toString('base64') : value}`;
|
|
43
90
|
else
|
|
44
91
|
throw new Error(`security scheme "${name}" uses an unsupported HTTP scheme "${scheme.scheme}"`);
|
|
45
92
|
return;
|
|
46
93
|
}
|
|
47
94
|
case 'oauth2':
|
|
48
95
|
case 'openIdConnect':
|
|
49
|
-
headers.
|
|
96
|
+
headers.authorization = `Bearer ${value}`;
|
|
50
97
|
return;
|
|
51
98
|
default:
|
|
52
99
|
throw new Error(`security scheme "${name}" has an unsupported type "${scheme.type}"`);
|
|
53
100
|
}
|
|
54
101
|
}
|
|
55
102
|
/**
|
|
56
|
-
* Maps credentials
|
|
103
|
+
* Maps credentials onto the security schemes of the spec. A value passed in a call wins over one kept for the
|
|
104
|
+
* session, which wins over the environment.
|
|
57
105
|
*/
|
|
58
106
|
export class Credentials {
|
|
59
107
|
config;
|
|
60
|
-
|
|
61
|
-
constructor(config
|
|
108
|
+
session = emptyCredentials();
|
|
109
|
+
constructor(config) {
|
|
62
110
|
this.config = config;
|
|
63
|
-
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Keeps supplied credentials in memory for the rest of the server session.
|
|
114
|
+
*/
|
|
115
|
+
remember(supplied) {
|
|
116
|
+
for (const [name, value] of supplied.schemes)
|
|
117
|
+
this.session.schemes.set(name, value);
|
|
118
|
+
for (const [name, value] of supplied.headers)
|
|
119
|
+
this.session.headers.set(name, value);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Forgets session credentials by key, or all of them for "*"; returns the names that were kept before.
|
|
123
|
+
*/
|
|
124
|
+
forget(keys, schemes) {
|
|
125
|
+
const kept = [...this.session.schemes.keys(), ...this.session.headers.keys()];
|
|
126
|
+
if (keys.includes('*')) {
|
|
127
|
+
this.session.schemes.clear();
|
|
128
|
+
this.session.headers.clear();
|
|
129
|
+
return kept;
|
|
130
|
+
}
|
|
131
|
+
const hasSchemes = Object.keys(schemes).length > 0;
|
|
132
|
+
const names = keys.map((key) => (hasSchemes ? resolveSchemeName(key, schemes) : key.toLowerCase()));
|
|
133
|
+
for (const name of names) {
|
|
134
|
+
this.session.schemes.delete(name);
|
|
135
|
+
this.session.headers.delete(name);
|
|
136
|
+
}
|
|
137
|
+
return kept.filter((name) => names.includes(name));
|
|
64
138
|
}
|
|
65
139
|
/**
|
|
66
140
|
* Where the credential of a scheme comes from, or null when nothing supplies it.
|
|
67
141
|
*/
|
|
68
|
-
source(scheme,
|
|
142
|
+
source(scheme, call = emptyCredentials()) {
|
|
143
|
+
if (call.schemes.has(scheme))
|
|
144
|
+
return 'call';
|
|
145
|
+
if (this.session.schemes.has(scheme))
|
|
146
|
+
return 'session';
|
|
69
147
|
if (this.config.schemeCredentials.has(schemeEnvSuffix(scheme)))
|
|
70
148
|
return 'env';
|
|
71
|
-
return
|
|
149
|
+
return null;
|
|
72
150
|
}
|
|
73
151
|
/**
|
|
74
|
-
* Credential status of a scheme
|
|
152
|
+
* Credential status of a scheme outside a call: its source, or "not configured".
|
|
75
153
|
*/
|
|
76
154
|
describe(scheme) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
155
|
+
return this.source(scheme) ?? 'not configured';
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Names of plain headers kept for the session.
|
|
159
|
+
*/
|
|
160
|
+
sessionHeaders() {
|
|
161
|
+
return [...this.session.headers.keys()];
|
|
82
162
|
}
|
|
83
163
|
/**
|
|
84
164
|
* Picks the security alternative for a call: a forced scheme, the first alternative with all credentials, or anonymous.
|
|
85
165
|
*/
|
|
86
|
-
select(op, as,
|
|
166
|
+
select(op, as, call, schemes) {
|
|
87
167
|
if (as === 'anonymous')
|
|
88
168
|
return { mode: 'anonymous' };
|
|
89
169
|
if (as !== 'auto') {
|
|
90
|
-
if (!schemes[as])
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
if (!this.source(as, context))
|
|
170
|
+
if (!schemes[as])
|
|
171
|
+
throw new Error(`unknown security scheme "${as}"; the spec defines: ${describeSchemes(schemes)}`);
|
|
172
|
+
if (!this.source(as, call))
|
|
95
173
|
throw new Error(`no credential for "${as}": ${this.hint(as)}`);
|
|
96
174
|
return { mode: 'credentials', schemes: [as] };
|
|
97
175
|
}
|
|
98
176
|
for (const alternative of op.security) {
|
|
99
|
-
if (alternative.every((scheme) => this.source(scheme,
|
|
177
|
+
if (alternative.every((scheme) => this.source(scheme, call))) {
|
|
100
178
|
return alternative.length ? { mode: 'credentials', schemes: alternative } : { mode: 'anonymous' };
|
|
101
179
|
}
|
|
102
180
|
}
|
|
@@ -110,31 +188,33 @@ export class Credentials {
|
|
|
110
188
|
throw new Error(`${op.key} requires ${op.security.map((a) => a.join(' + ')).join(' or ')}: ${hints}`);
|
|
111
189
|
}
|
|
112
190
|
/**
|
|
113
|
-
* Puts the credentials
|
|
191
|
+
* Puts the selected credentials and plain headers into the request; returns what was used, without values.
|
|
114
192
|
*/
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (!this.provider)
|
|
193
|
+
apply(selection, schemes, call, headers, url) {
|
|
194
|
+
const used = [];
|
|
195
|
+
if (selection.mode === 'credentials') {
|
|
196
|
+
for (const name of selection.schemes) {
|
|
197
|
+
const scheme = schemes[name];
|
|
198
|
+
if (!scheme)
|
|
199
|
+
throw new Error(`the spec has no security scheme "${name}"`);
|
|
200
|
+
const source = this.source(name, call);
|
|
201
|
+
const value = call.schemes.get(name) ?? this.session.schemes.get(name) ?? this.config.schemeCredentials.get(schemeEnvSuffix(name));
|
|
202
|
+
if (!source || value === undefined)
|
|
126
203
|
throw new Error(`no credential for "${name}": ${this.hint(name)}`);
|
|
127
|
-
|
|
128
|
-
|
|
204
|
+
applyScheme(name, scheme, value, headers, url);
|
|
205
|
+
used.push(`${name} (${source})`);
|
|
129
206
|
}
|
|
130
|
-
applyScheme(name, scheme, value, headers, url);
|
|
131
207
|
}
|
|
132
|
-
|
|
208
|
+
for (const [name, value] of new Map([...this.session.headers, ...call.headers])) {
|
|
209
|
+
headers[name] = value;
|
|
210
|
+
used.push(`header ${name} (${call.headers.has(name) ? 'call' : 'session'})`);
|
|
211
|
+
}
|
|
212
|
+
return used.length ? used.join(' + ') : 'anonymous';
|
|
133
213
|
}
|
|
134
214
|
/**
|
|
135
|
-
* Tells
|
|
215
|
+
* Tells how to supply a credential for a scheme.
|
|
136
216
|
*/
|
|
137
217
|
hint(scheme) {
|
|
138
|
-
return `
|
|
218
|
+
return `pass credentials: { "${scheme}": "…" }, keep one with api_credentials, or set ${schemeEnvName(scheme)}`;
|
|
139
219
|
}
|
|
140
220
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -28,8 +28,6 @@ export interface ExplorerConfig {
|
|
|
28
28
|
instructionsFile?: string;
|
|
29
29
|
/** JSONL journal of api_request calls. */
|
|
30
30
|
callLog: string;
|
|
31
|
-
/** ES module whose default export creates an auth provider. */
|
|
32
|
-
authModule?: string;
|
|
33
31
|
/** Headers sent with every call, from OPENAPI_HEADER_<NAME>. */
|
|
34
32
|
staticHeaders: Record<string, string>;
|
|
35
33
|
/** Credentials keyed by the normalized scheme name, from OPENAPI_AUTH_<SCHEME>. */
|
package/dist/config.js
CHANGED
|
@@ -10,7 +10,6 @@ export class ConfigError extends Error {
|
|
|
10
10
|
}
|
|
11
11
|
const AUTH_PREFIX = 'OPENAPI_AUTH_';
|
|
12
12
|
const HEADER_PREFIX = 'OPENAPI_HEADER_';
|
|
13
|
-
const RESERVED_AUTH_KEYS = new Set(['OPENAPI_AUTH_MODULE']);
|
|
14
13
|
/**
|
|
15
14
|
* Normalizes a security scheme name into its environment variable suffix: x-admin-token becomes X_ADMIN_TOKEN.
|
|
16
15
|
*/
|
|
@@ -130,12 +129,11 @@ export function readConfig(env = process.env) {
|
|
|
130
129
|
if (key.startsWith(HEADER_PREFIX)) {
|
|
131
130
|
staticHeaders[key.slice(HEADER_PREFIX.length).toLowerCase().replace(/_/g, '-')] = value;
|
|
132
131
|
}
|
|
133
|
-
else if (key.startsWith(AUTH_PREFIX)
|
|
132
|
+
else if (key.startsWith(AUTH_PREFIX)) {
|
|
134
133
|
schemeCredentials.set(key.slice(AUTH_PREFIX.length), value);
|
|
135
134
|
}
|
|
136
135
|
}
|
|
137
|
-
|
|
138
|
-
if (!baseUrl && (schemeCredentials.size > 0 || Object.keys(staticHeaders).length > 0 || authModule)) {
|
|
136
|
+
if (!baseUrl && (schemeCredentials.size > 0 || Object.keys(staticHeaders).length > 0)) {
|
|
139
137
|
throw new ConfigError('OPENAPI_BASE_URL is required when credentials or headers are configured: they only go to an origin you set explicitly, never to one taken from the spec');
|
|
140
138
|
}
|
|
141
139
|
const rawCacheDir = env.OPENAPI_CACHE_DIR?.trim();
|
|
@@ -157,7 +155,6 @@ export function readConfig(env = process.env) {
|
|
|
157
155
|
recipesDir: optionalPath(env, 'OPENAPI_RECIPES_DIR'),
|
|
158
156
|
instructionsFile: optionalPath(env, 'OPENAPI_INSTRUCTIONS_FILE'),
|
|
159
157
|
callLog: rawCallLog ? path.resolve(expandHome(rawCallLog)) : path.join(cacheDir, 'calls.jsonl'),
|
|
160
|
-
authModule,
|
|
161
158
|
staticHeaders,
|
|
162
159
|
schemeCredentials,
|
|
163
160
|
};
|
package/dist/schemas.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export declare const getInput: {
|
|
|
50
50
|
path_params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
|
|
51
51
|
query: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
|
|
52
52
|
as: z.ZodDefault<z.ZodString>;
|
|
53
|
-
|
|
53
|
+
credentials: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
54
54
|
};
|
|
55
55
|
export declare const requestInput: {
|
|
56
56
|
method: z.ZodEnum<{
|
|
@@ -65,14 +65,13 @@ export declare const requestInput: {
|
|
|
65
65
|
query: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
|
|
66
66
|
body: z.ZodOptional<z.ZodUnknown>;
|
|
67
67
|
as: z.ZodDefault<z.ZodString>;
|
|
68
|
-
|
|
68
|
+
credentials: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
69
69
|
reason: z.ZodOptional<z.ZodString>;
|
|
70
70
|
confirm_danger: z.ZodDefault<z.ZodBoolean>;
|
|
71
71
|
};
|
|
72
|
-
export declare const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
show_token: z.ZodDefault<z.ZodBoolean>;
|
|
72
|
+
export declare const credentialsInput: {
|
|
73
|
+
set: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
74
|
+
clear: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
76
75
|
};
|
|
77
76
|
export declare const callLogInput: {
|
|
78
77
|
limit: z.ZodDefault<z.ZodNumber>;
|
package/dist/schemas.js
CHANGED
|
@@ -9,8 +9,12 @@ const renderMode = z.enum(['outline', 'json']).default('outline').describe('outl
|
|
|
9
9
|
const as = z
|
|
10
10
|
.string()
|
|
11
11
|
.default('auto')
|
|
12
|
-
.describe("'auto' uses the first security alternative
|
|
13
|
-
const
|
|
12
|
+
.describe("'auto' uses the first security alternative that has credentials; 'anonymous' sends none; or a security scheme name from the spec");
|
|
13
|
+
const credentialMap = z.record(z.string(), z.string().min(1));
|
|
14
|
+
const credentials = credentialMap
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Credentials for this call only, keyed by security scheme name from api_spec_info; an apiKey scheme also accepts its header name. ' +
|
|
17
|
+
'They win over api_credentials and the environment. When the spec declares no security schemes, keys are sent as headers');
|
|
14
18
|
const pathParams = z.record(z.string(), z.union([z.string(), z.number()])).default({}).describe('Path parameters');
|
|
15
19
|
const query = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}).describe('Query-string parameters');
|
|
16
20
|
export const specInfoInput = {
|
|
@@ -36,7 +40,7 @@ export const typesInput = {
|
|
|
36
40
|
include: z.array(z.enum(['request', 'response', 'params'])).default(['request', 'response', 'params']),
|
|
37
41
|
name_prefix: z.string().default('').describe('Prefix for generated type names'),
|
|
38
42
|
};
|
|
39
|
-
export const getInput = { endpoint, path_params: pathParams, query, as,
|
|
43
|
+
export const getInput = { endpoint, path_params: pathParams, query, as, credentials };
|
|
40
44
|
export const requestInput = {
|
|
41
45
|
method,
|
|
42
46
|
endpoint,
|
|
@@ -44,14 +48,13 @@ export const requestInput = {
|
|
|
44
48
|
query,
|
|
45
49
|
body: z.unknown().optional().describe('JSON body'),
|
|
46
50
|
as,
|
|
47
|
-
|
|
51
|
+
credentials,
|
|
48
52
|
reason: z.string().optional().describe('Note for the call journal: why the call was made'),
|
|
49
53
|
confirm_danger: z.boolean().default(false).describe('Required for operations classified as destructive'),
|
|
50
54
|
};
|
|
51
|
-
export const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
show_token: z.boolean().default(false).describe('Return full tokens instead of previews'),
|
|
55
|
+
export const credentialsInput = {
|
|
56
|
+
set: credentialMap.default({}).describe('Credentials to keep for this server session, keyed like `credentials` in api_get'),
|
|
57
|
+
clear: z.array(z.string()).default([]).describe('Keys to forget; ["*"] forgets everything kept in this session'),
|
|
55
58
|
};
|
|
56
59
|
export const callLogInput = {
|
|
57
60
|
limit: z.number().int().min(1).max(500).default(50),
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type AuthProvider } from './auth.js';
|
|
2
1
|
import { type ExplorerConfig } from './config.js';
|
|
3
2
|
import { type DangerRules } from './risk.js';
|
|
4
3
|
/**
|
|
@@ -6,11 +5,10 @@ import { type DangerRules } from './risk.js';
|
|
|
6
5
|
*/
|
|
7
6
|
export declare class OpenApiExplorerServer {
|
|
8
7
|
private readonly config;
|
|
9
|
-
private readonly provider;
|
|
10
8
|
private readonly server;
|
|
11
9
|
private readonly store;
|
|
12
10
|
private readonly credentials;
|
|
13
|
-
constructor(config: ExplorerConfig, rules: DangerRules,
|
|
11
|
+
constructor(config: ExplorerConfig, rules: DangerRules, extraInstructions: string);
|
|
14
12
|
/**
|
|
15
13
|
* Builds the server from environment variables; exits with a readable message when they are wrong.
|
|
16
14
|
*/
|
|
@@ -36,11 +34,19 @@ export declare class OpenApiExplorerServer {
|
|
|
36
34
|
*/
|
|
37
35
|
private baseUrl;
|
|
38
36
|
/**
|
|
39
|
-
*
|
|
37
|
+
* Base URL for calls, or null when there is none; for reports that must not fail.
|
|
38
|
+
*/
|
|
39
|
+
private baseUrlOrNull;
|
|
40
|
+
/**
|
|
41
|
+
* Security schemes with where each puts its value and where its credential comes from — never the value.
|
|
42
|
+
*/
|
|
43
|
+
private schemeStatus;
|
|
44
|
+
/**
|
|
45
|
+
* Calls an operation: resolves and picks credentials, checks the origin, explains a 401 and a 404.
|
|
40
46
|
*/
|
|
41
47
|
private performCall;
|
|
42
48
|
/**
|
|
43
|
-
* Registers every tool; api_request
|
|
49
|
+
* Registers every tool; api_request and recipe only when configured.
|
|
44
50
|
*/
|
|
45
51
|
private registerTools;
|
|
46
52
|
}
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
-
import { Credentials,
|
|
6
|
+
import { Credentials, emptyCredentials, placement, resolveSupplied } from './auth.js';
|
|
7
7
|
import { readConfig, schemeEnvName } from './config.js';
|
|
8
8
|
import { buildUrl, send } from './http.js';
|
|
9
9
|
import { appendJsonl, tailJsonl } from './journal.js';
|
|
@@ -24,7 +24,7 @@ const BASE_INSTRUCTIONS = [
|
|
|
24
24
|
'',
|
|
25
25
|
'- Refer to endpoints as "METHOD /path"; operationIds are not always unique.',
|
|
26
26
|
'- Summaries can be missing or wrong — check the path, method and response shape.',
|
|
27
|
-
'- Authentication follows the security schemes of the spec. api_spec_info shows which schemes have credentials; `as` picks
|
|
27
|
+
'- Authentication follows the security schemes of the spec. api_spec_info shows which schemes have credentials. Pass `credentials` for one call or keep them with api_credentials; `as` picks a scheme explicitly.',
|
|
28
28
|
'- Destructive endpoints need confirm_danger: true in api_request.',
|
|
29
29
|
].join('\n');
|
|
30
30
|
/**
|
|
@@ -32,15 +32,13 @@ const BASE_INSTRUCTIONS = [
|
|
|
32
32
|
*/
|
|
33
33
|
export class OpenApiExplorerServer {
|
|
34
34
|
config;
|
|
35
|
-
provider;
|
|
36
35
|
server;
|
|
37
36
|
store;
|
|
38
37
|
credentials;
|
|
39
|
-
constructor(config, rules,
|
|
38
|
+
constructor(config, rules, extraInstructions) {
|
|
40
39
|
this.config = config;
|
|
41
|
-
this.provider = provider;
|
|
42
40
|
this.store = new SpecStore(config, rules);
|
|
43
|
-
this.credentials = new Credentials(config
|
|
41
|
+
this.credentials = new Credentials(config);
|
|
44
42
|
const instructions = extraInstructions ? `${BASE_INSTRUCTIONS}\n\n${extraInstructions}` : BASE_INSTRUCTIONS;
|
|
45
43
|
this.server = new McpServer({ name: config.serverName, version: VERSION }, { capabilities: { tools: {} }, instructions });
|
|
46
44
|
this.registerTools();
|
|
@@ -52,9 +50,8 @@ export class OpenApiExplorerServer {
|
|
|
52
50
|
try {
|
|
53
51
|
const config = readConfig();
|
|
54
52
|
const rules = loadDangerRules(config.dangerFile);
|
|
55
|
-
const provider = await loadAuthProvider(config);
|
|
56
53
|
const extra = config.instructionsFile ? readFileSync(config.instructionsFile, 'utf8').trim() : '';
|
|
57
|
-
return new OpenApiExplorerServer(config, rules,
|
|
54
|
+
return new OpenApiExplorerServer(config, rules, extra);
|
|
58
55
|
}
|
|
59
56
|
catch (error) {
|
|
60
57
|
// stdout carries JSON-RPC, so startup errors go to stderr.
|
|
@@ -119,28 +116,48 @@ export class OpenApiExplorerServer {
|
|
|
119
116
|
throw new Error(`the spec server "${first}" is relative and the spec is a local file; set OPENAPI_BASE_URL`);
|
|
120
117
|
}
|
|
121
118
|
/**
|
|
122
|
-
*
|
|
119
|
+
* Base URL for calls, or null when there is none; for reports that must not fail.
|
|
120
|
+
*/
|
|
121
|
+
baseUrlOrNull(state) {
|
|
122
|
+
try {
|
|
123
|
+
return this.baseUrl(state);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Security schemes with where each puts its value and where its credential comes from — never the value.
|
|
131
|
+
*/
|
|
132
|
+
schemeStatus(schemes) {
|
|
133
|
+
return Object.entries(schemes).map(([name, scheme]) => ({
|
|
134
|
+
name,
|
|
135
|
+
type: scheme.type,
|
|
136
|
+
placement: placement(scheme),
|
|
137
|
+
credential: this.credentials.describe(name),
|
|
138
|
+
env: schemeEnvName(name),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Calls an operation: resolves and picks credentials, checks the origin, explains a 401 and a 404.
|
|
123
143
|
*/
|
|
124
144
|
async performCall(state, op, args) {
|
|
125
145
|
const base = this.baseUrl(state);
|
|
126
146
|
const origin = new URL(base).origin;
|
|
127
|
-
const context = { identity: args.identity };
|
|
128
147
|
const schemes = state.index.securitySchemes;
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
};
|
|
138
|
-
let { result, applied } = await attempt(false);
|
|
139
|
-
if (result.status === 401 && applied.fromProvider)
|
|
140
|
-
({ result, applied } = await attempt(true));
|
|
148
|
+
const call = args.credentials ? resolveSupplied(args.credentials, schemes) : emptyCredentials();
|
|
149
|
+
const selection = this.credentials.select(op, args.as, call, schemes);
|
|
150
|
+
const url = buildUrl(base, op.path, args.pathParams, args.query);
|
|
151
|
+
const headers = { ...this.config.staticHeaders };
|
|
152
|
+
const auth = this.credentials.apply(selection, schemes, call, headers, url);
|
|
153
|
+
if (url.origin !== origin)
|
|
154
|
+
throw new Error(`refusing to send the request to ${url.origin}; only ${origin} is allowed`);
|
|
155
|
+
const result = await send(args.method, url, headers, args.body, this.config.timeoutMs);
|
|
141
156
|
const note = {};
|
|
142
157
|
if (selection.mode === 'anonymous' && selection.note)
|
|
143
158
|
note.auth = selection.note;
|
|
159
|
+
else if (result.status === 401 && auth !== 'anonymous')
|
|
160
|
+
note.auth = `the API rejected ${auth}: the credential is wrong or expired — supply a fresh one`;
|
|
144
161
|
if (result.status === 404) {
|
|
145
162
|
const fresh = await this.store.forceRevalidate().catch(() => null);
|
|
146
163
|
note.specRecheck = fresh?.index.byKey.has(op.key)
|
|
@@ -148,13 +165,13 @@ export class OpenApiExplorerServer {
|
|
|
148
165
|
: 'the path is gone from the spec — the API has probably changed';
|
|
149
166
|
}
|
|
150
167
|
return {
|
|
151
|
-
auth
|
|
168
|
+
auth,
|
|
152
169
|
...result,
|
|
153
170
|
...(Object.keys(note).length ? { note } : {}),
|
|
154
171
|
};
|
|
155
172
|
}
|
|
156
173
|
/**
|
|
157
|
-
* Registers every tool; api_request
|
|
174
|
+
* Registers every tool; api_request and recipe only when configured.
|
|
158
175
|
*/
|
|
159
176
|
registerTools() {
|
|
160
177
|
this.server.registerTool('api_spec_info', {
|
|
@@ -175,15 +192,7 @@ export class OpenApiExplorerServer {
|
|
|
175
192
|
baseUrl: this.config.baseUrl ?? state.index.servers[0] ?? null,
|
|
176
193
|
counts: state.index.counts,
|
|
177
194
|
groups,
|
|
178
|
-
securitySchemes:
|
|
179
|
-
name,
|
|
180
|
-
type: scheme.type,
|
|
181
|
-
...(scheme.in ? { in: scheme.in } : {}),
|
|
182
|
-
...(scheme.name ? { parameter: scheme.name } : {}),
|
|
183
|
-
...(scheme.scheme ? { scheme: scheme.scheme } : {}),
|
|
184
|
-
credential: this.credentials.describe(name),
|
|
185
|
-
env: schemeEnvName(name),
|
|
186
|
-
})),
|
|
195
|
+
securitySchemes: this.schemeStatus(state.index.securitySchemes),
|
|
187
196
|
writes: this.config.allowWrite ? 'enabled' : 'disabled (set OPENAPI_ALLOW_WRITE to register api_request)',
|
|
188
197
|
...(state.meta.changed ? { changed: state.meta.changed } : {}),
|
|
189
198
|
...(state.offline ? { offline: { since: new Date(state.offline.since).toISOString(), reason: state.offline.reason } } : {}),
|
|
@@ -355,12 +364,12 @@ export class OpenApiExplorerServer {
|
|
|
355
364
|
description: 'Calls a GET endpoint and returns the response. Read-only: the method is fixed.',
|
|
356
365
|
inputSchema: schemas.getInput,
|
|
357
366
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
358
|
-
}, ({ endpoint, path_params, query, as,
|
|
367
|
+
}, ({ endpoint, path_params, query, as, credentials }) => this.run(async () => {
|
|
359
368
|
const state = await this.store.load();
|
|
360
369
|
const op = resolveEndpoint(state.index, endpoint);
|
|
361
370
|
if (op.method !== 'GET')
|
|
362
371
|
throw new Error(`${op.key} is not a GET endpoint${this.config.allowWrite ? '; use api_request' : ''}`);
|
|
363
|
-
const response = await this.performCall(state, op, { method: 'GET', pathParams: path_params, query, as,
|
|
372
|
+
const response = await this.performCall(state, op, { method: 'GET', pathParams: path_params, query, as, credentials });
|
|
364
373
|
return { key: op.key, ...response, ...this.specNote(state) };
|
|
365
374
|
}));
|
|
366
375
|
if (this.config.allowWrite) {
|
|
@@ -369,7 +378,7 @@ export class OpenApiExplorerServer {
|
|
|
369
378
|
description: 'Calls an endpoint with any method, including writes. Destructive endpoints need confirm_danger: true. Calls are recorded in api_call_log.',
|
|
370
379
|
inputSchema: schemas.requestInput,
|
|
371
380
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
372
|
-
}, ({ method, endpoint, path_params, query, body, as,
|
|
381
|
+
}, ({ method, endpoint, path_params, query, body, as, credentials, reason, confirm_danger }) => this.run(async () => {
|
|
373
382
|
const state = await this.store.load();
|
|
374
383
|
const op = resolveEndpoint(state.index, endpoint);
|
|
375
384
|
if (method !== op.method)
|
|
@@ -377,7 +386,7 @@ export class OpenApiExplorerServer {
|
|
|
377
386
|
if (op.danger === 'destructive' && !confirm_danger) {
|
|
378
387
|
throw new Error(`${op.key} is destructive: ${op.dangerReason}. Repeat with confirm_danger: true if this is intended.`);
|
|
379
388
|
}
|
|
380
|
-
const response = await this.performCall(state, op, { method, pathParams: path_params, query, body, as,
|
|
389
|
+
const response = await this.performCall(state, op, { method, pathParams: path_params, query, body, as, credentials });
|
|
381
390
|
const responseBody = response.body;
|
|
382
391
|
appendJsonl(this.config.callLog, {
|
|
383
392
|
ts: new Date().toISOString(),
|
|
@@ -393,25 +402,27 @@ export class OpenApiExplorerServer {
|
|
|
393
402
|
return { key: op.key, journaled: true, ...response, ...this.specNote(state) };
|
|
394
403
|
}));
|
|
395
404
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
this
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
405
|
+
this.server.registerTool('api_credentials', {
|
|
406
|
+
title: 'Session credentials',
|
|
407
|
+
description: 'Keeps credentials in memory for this server session or forgets them, and shows where the credential of each security scheme comes from — never the values. ' +
|
|
408
|
+
'Keys are scheme names from api_spec_info; an apiKey scheme also accepts its header name. A credential passed in a call wins over a session one, which wins over the environment.',
|
|
409
|
+
inputSchema: schemas.credentialsInput,
|
|
410
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
411
|
+
}, ({ set, clear }) => this.run(async () => {
|
|
412
|
+
const state = await this.store.load();
|
|
413
|
+
const schemes = state.index.securitySchemes;
|
|
414
|
+
// Resolve first, so a bad key in `set` fails before anything is forgotten.
|
|
415
|
+
const supplied = resolveSupplied(set, schemes);
|
|
416
|
+
const forgotten = clear.length ? this.credentials.forget(clear, schemes) : [];
|
|
417
|
+
this.credentials.remember(supplied);
|
|
418
|
+
const sessionHeaders = this.credentials.sessionHeaders();
|
|
419
|
+
return {
|
|
420
|
+
...(forgotten.length ? { forgotten } : {}),
|
|
421
|
+
baseUrl: this.baseUrlOrNull(state),
|
|
422
|
+
securitySchemes: this.schemeStatus(schemes),
|
|
423
|
+
...(sessionHeaders.length ? { sessionHeaders } : {}),
|
|
424
|
+
};
|
|
425
|
+
}));
|
|
415
426
|
this.server.registerTool('api_call_log', {
|
|
416
427
|
title: 'Call journal',
|
|
417
428
|
description: 'What api_request has called: endpoint, status and ids from responses — use it to clean up what was created.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openapi-explorer-mcp",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "MCP server for any OpenAPI 3 spec: search endpoints, inspect request and response shapes, generate TypeScript types, and call endpoints with credentials mapped to the spec's security schemes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eugene Trofimov",
|
|
@@ -8,13 +8,6 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"openapi-explorer-mcp": "dist/index.js"
|
|
10
10
|
},
|
|
11
|
-
"exports": {
|
|
12
|
-
".": {
|
|
13
|
-
"types": "./dist/auth.d.ts",
|
|
14
|
-
"default": "./dist/auth.js"
|
|
15
|
-
},
|
|
16
|
-
"./package.json": "./package.json"
|
|
17
|
-
},
|
|
18
11
|
"files": [
|
|
19
12
|
"dist",
|
|
20
13
|
"README.md",
|