Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/plugin-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,50 @@ const result = await host.ports.mcpConnectors?.beginDcrAuthorization({
`status(name)` returns only name, connection state, OAuth state and tool count.
It never returns an endpoint, vault binding, token, or secret value.

### Use a product-brokered short-lived token

When an organization owns the OAuth relationship, bind a runtime token source
from the product plugin and declare its non-secret id on the connector. The MCP
plugin asks the resolver only while it is about to dial; it writes neither the
access token it receives nor a refresh token or client secret to `mcp.json`.

```ts
import { definePlugin } from '@hoshi/harness'

export default [
definePlugin({
name: 'example-platform',
description: 'Makes organization-brokered connectors available.',
uses: ['mcpConnectors'],
setup(host) {
const connectors = host.ports.mcpConnectors
if (!connectors) return

connectors.bindTokenSource('platform.example', async ({ name, scopes }) => {
const token = await mintShortLivedTokenFromYourPlatform({ name, scopes })
return token ? { state: 'available', accessToken: token } : { state: 'unavailable' }
})

void connectors.install({
name: 'example',
transport: 'http',
url: 'https://mcp.example.com/',
tokenSource: { id: 'platform.example', scopes: ['documents.read'] },
})
},
}),
]
```

Bind the source during every plugin setup. Source bindings are process-local by
design, so a restart begins unbound; a stored connector whose source was not
registered is visibly `unreachable` with `needs-auth` rather than being dialled
without a credential. Resolver outcomes are sanitized: an expired token reports
`expired`; unavailable, revoked, or failed broker requests report `needs-auth`.
Do not return provider errors or token metadata. A connector uses either
`tokenSource` or `vaultHeaders`, never both; DCR remains the machine-owned OAuth
path above.

## Describe what the machine can do

`capability` is the public, stable identity of the unit your plugin adds. Its
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ export type {
McpConnectorPort,
McpConnectorSnapshot,
McpConnectorStatus,
McpConnectorTokenSource,
McpConnectorTokenSourceReference,
McpConnectorTokenSourceRequest,
McpConnectorTokenSourceResult,
McpConnectorTransport,
McpConnectorVaultHeader,
McpDcrAuthorizationRequest,
Expand Down
42 changes: 42 additions & 0 deletions src/mcp-connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,49 @@ export interface McpConnectorVaultHeader {
template: string
}

/** A runtime-only identity source supplied by another plugin. The persisted
* connector definition names this source, but never carries a token, a refresh
* token, or a client secret. */
export interface McpConnectorTokenSourceReference {
/** Stable, product-defined name of a source bound during plugin setup. */
id: string
/** Permissions the product asks its broker to mint for this connector. */
scopes?: string[]
}

/** What a product-owned broker can safely tell the MCP plugin. A token is
* consumed immediately to make one connection and is never written to storage.
* The non-ready cases intentionally contain no upstream error detail: a
* machine UI can distinguish an expired authorization from one needing action
* without exposing a provider response, secret, or account information. */
export type McpConnectorTokenSourceResult =
| { state: 'available'; accessToken: string }
| { state: 'unavailable' | 'expired' | 'revoked' }

export interface McpConnectorTokenSourceRequest {
/** Connector asking for the token; useful when one broker serves several. */
name: string
/** The declaration's scopes, copied rather than read from durable storage by
* the product plugin. */
scopes: string[]
}

/** A product plugin binds this during every boot. It is deliberately runtime
* state: a restarted machine must re-establish its Platform connection before
* a persisted connector can use the source again. */
export type McpConnectorTokenSource = (
input: McpConnectorTokenSourceRequest,
) => Promise<McpConnectorTokenSourceResult>

export interface McpConnectorDeclaration {
name: string
url: string
transport: McpConnectorTransport
/** Header name -> vault binding. Literal credentials are intentionally absent. */
vaultHeaders?: Record<string, McpConnectorVaultHeader>
/** A non-secret broker source. Mutually exclusive with vault headers: one
* connector has one credential owner. */
tokenSource?: McpConnectorTokenSourceReference
}

export type McpConnectorAuthStatus = 'authenticated' | 'needs-auth' | 'expired' | null
Expand All @@ -45,6 +82,11 @@ export interface McpDcrAuthorizationRequest {
/** The MCP plugin's narrow service for external product plugins. */
export interface McpConnectorPort {
install(input: McpConnectorDeclaration): Promise<void>
/** Bind a product-owned, short-lived bearer-token resolver for this process.
* Call from plugin setup on every boot. The returned function releases only
* this binding, so a plugin can cleanly shut down without affecting another
* source. */
bindTokenSource(id: string, resolve: McpConnectorTokenSource): () => void
beginDcrAuthorization(input: McpDcrAuthorizationRequest): Promise<{ url: string }>
status(name: string): Promise<McpConnectorSnapshot | null>
}
122 changes: 122 additions & 0 deletions src/plugins/mcp/connector-token-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

const mocks = vi.hoisted(() => ({
close: vi.fn(),
connect: vi.fn(),
}))

vi.mock('@openharness/core', () => ({
closeMCPClients: mocks.close,
connectMCPServers: mocks.connect,
}))

import { configureStateRoot, resetStateRoot } from '../../kernel/store.js'
import { setSecret } from '../../kernel/secrets.js'
import { installRemoteServer, listServers, mcpTools } from './servers.js'
import { bindTokenSource, clearTokenSources } from './token-sources.js'

describe('a provider-brokered connector', () => {
let state = ''

beforeEach(async () => {
state = await mkdtemp(path.join(tmpdir(), 'hoshi-mcp-token-source-'))
configureStateRoot(state)
clearTokenSources()
mocks.connect.mockReset()
mocks.close.mockReset()
mocks.connect.mockResolvedValue({ clients: [], tools: {} })
})

afterEach(async () => {
clearTokenSources()
resetStateRoot()
await rm(state, { recursive: true, force: true })
})

it('dials with a brokered bearer token while persisting only the source id and scopes', async () => {
await installRemoteServer({
name: 'google-drive',
transport: 'http',
url: 'https://mcp.example.test/',
tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] },
})
bindTokenSource('platform.google', async () => ({ state: 'available', accessToken: 'ephemeral-access-token' }))

await mcpTools()

expect(mocks.connect).toHaveBeenCalledWith({
'google-drive': {
type: 'http',
url: 'https://mcp.example.test/',
headers: { Authorization: 'Bearer ephemeral-access-token' },
},
})
const persisted = await readFile(path.join(state, 'mcp.json'), 'utf8')
expect(persisted).toContain('platform.google')
expect(persisted).toContain('drive.readonly')
expect(persisted).not.toContain('ephemeral-access-token')
expect(persisted).not.toMatch(/refresh|client.secret/i)
})

it('keeps the existing vault-header path when no runtime source is declared', async () => {
await installRemoteServer({
name: 'github',
transport: 'http',
url: 'https://mcp.example.test/',
vaultHeaders: { Authorization: { key: 'GITHUB_TOKEN', template: 'Bearer {{secret}}' } },
})
await setSecret('GITHUB_TOKEN', 'vault-token')

await mcpTools()

expect(mocks.connect).toHaveBeenCalledWith({
github: {
type: 'http',
url: 'https://mcp.example.test/',
headers: { Authorization: 'Bearer vault-token' },
},
})
})

it('does not dial without a source and exposes a safe missing-source status', async () => {
await installRemoteServer({
name: 'missing-source',
transport: 'http',
url: 'https://mcp.example.test/',
tokenSource: { id: 'platform.missing' },
})

await expect(listServers()).resolves.toMatchObject([
{
name: 'missing-source',
status: 'unreachable',
auth: 'needs-auth',
error: 'The required connector token source is not available.',
},
])
expect(mocks.connect).not.toHaveBeenCalled()
})

it('reports an expired brokered authorization without forwarding the broker response', async () => {
await installRemoteServer({
name: 'expired-source',
transport: 'http',
url: 'https://mcp.example.test/',
tokenSource: { id: 'platform.expired' },
})
bindTokenSource('platform.expired', async () => ({ state: 'expired' }))

await expect(listServers()).resolves.toMatchObject([
{
name: 'expired-source',
status: 'unreachable',
auth: 'expired',
error: 'The connector authorization has expired.',
},
])
expect(mocks.connect).not.toHaveBeenCalled()
})
})
1 change: 1 addition & 0 deletions src/plugins/mcp/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('the public MCP connector port', () => {

expect(provided?.mcpConnectors).toEqual({
install: expect.any(Function),
bindTokenSource: expect.any(Function),
beginDcrAuthorization: expect.any(Function),
status: expect.any(Function),
})
Expand Down
17 changes: 16 additions & 1 deletion src/plugins/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import oauthCallback from './mcp.oauth.callback.get.js'
import startOauth from './mcp.name.oauth.post.js'
import disconnectOauth from './mcp.name.oauth.delete.js'
import { refreshExpiringTokens } from './oauth-connect.js'
import { connectorStatus, installRemoteServer } from './servers.js'
import { connectorStatus, installRemoteServer, invalidateMcpConnections } from './servers.js'
import { beginDcrAuthorization } from './mcp.name.oauth.post.js'
import { bindTokenSource, clearTokenSources } from './token-sources.js'

/**
* ── MCP connectors ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -43,6 +44,14 @@ export default definePlugin({
...current,
mcpConnectors: {
install: installRemoteServer,
bindTokenSource: (id, resolve) => {
const release = bindTokenSource(id, resolve)
invalidateMcpConnections()
return () => {
release()
invalidateMcpConnections()
}
},
beginDcrAuthorization,
status: connectorStatus,
},
Expand Down Expand Up @@ -86,5 +95,11 @@ export default definePlugin({
/** Renew what is close to expiring. On the machine's own timer: the
* no-polling rule is about the client↔machine wire. */
host.jobs.every(5 * 60_000, refreshExpiringTokens)
return {
shutdown: () => {
clearTokenSources()
invalidateMcpConnections()
},
}
},
})
35 changes: 35 additions & 0 deletions src/plugins/mcp/servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,39 @@ describe('the public declared-connector boundary', () => {
}),
).toThrow(InvalidServerError)
})

it('persists only a named runtime token source and its scopes, never a bearer token', () => {
expect(
parseRemoteConnector({
name: 'google-drive',
transport: 'http',
url: 'https://mcp.example.test/',
tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] },
}),
).toEqual({
type: 'http',
url: 'https://mcp.example.test/',
tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] },
})
})

it('refuses malformed sources and a declaration that tries to combine credential owners', () => {
expect(() =>
parseRemoteConnector({
name: 'bad-source',
transport: 'http',
url: 'https://mcp.example.test/',
tokenSource: { id: 'not valid' },
}),
).toThrow(InvalidServerError)
expect(() =>
parseRemoteConnector({
name: 'two-auth-modes',
transport: 'http',
url: 'https://mcp.example.test/',
vaultHeaders: { Authorization: { key: 'TOKEN', template: 'Bearer {{secret}}' } },
tokenSource: { id: 'platform.google' },
}),
).toThrow(InvalidServerError)
})
})
Loading
Loading